多工作线程应用程序池中的单例模式






4.67/5 (7投票s)
本文介绍了在 ASP.NET 中实现单例模式的跨“工作线程”解决方案。
引言
在我多年的编程经验中,我了解到单例是一个非常有用的模式,几乎在每个大型项目中都能找到它的用武之地——尤其是在多线程、多用户的应用程序中。最近,我大部分时间都在使用 C# 和 ASP.NET 框架创建 Web 应用程序。结果发现,在 Web 环境中创建和使用单例并不总是那么直接。
在 Web 应用程序中,我们可能会考虑使用三种单例:
- 每个 Web 请求一个实例
- 每个用户(会话)一个实例
- 整个 Web 应用程序一个实例
前两种情况并不是真正的问题;说实话,我可能会争论每个 Web 请求一个实例是否仍然可以称为单例,但这当然取决于 Web 请求的复杂性。我真正感兴趣的是第三点,即“整个 Web 应用程序一个实例”,这有时可能有点棘手。
通常,标准的单例模式会适用,因为默认情况下只有一个工作线程。在我过去几个项目中所遇到的一个问题是——如何在多工作线程环境中实现单例。如果我们只有一个工作线程,实现单例并不是一个真正的问题,因为所有 Web 请求都会在整个工作线程中共享其静态实例;唯一的问题是如何保护它,因为这仍然是一个多线程环境,这可以通过添加“lock
”来简单地避免,如下面的示例所示。
public class Singleton {
static Singleton instance = null;
static readonly object padlock = new object();
Singleton() { }
public static Singleton Instance {
get {
lock (padlock) {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
}
}
当我们在应用程序池中使用一个工作进程时,这将很好地工作,但如今,大多数当前的服务器平台都有多核处理器,而只有一个工作进程,我们无法充分利用它们。拥有一个工作线程的另一个缺点是,当一个请求运行缓慢时,所有其他请求都会直接受到影响。为了提高性能,我们可以增加工作线程的数量。但不幸的是,静态变量仅在一个工作进程中共享,所以我们最终会得到多个“单例”,每个进程一个——这在某些情况下是可以接受的,但通常可能会成为一个大问题(例如,可能会出现访问冲突),最终,这也不再遵循单例模式了。
.NET Remoting 单例
一个可能多个工作进程会成为问题的单例的例子是文件日志类——当每个请求都向同一个文件写入一些数据时。我们可以想象一下,当两个“单例”同时尝试写入同一个文件时会发生什么。
为了解决这个问题,我们需要在工作线程之间进行通信,以便它们都访问只存在于其中一个工作线程中的单例。
为此,我们可以使用 .NET Remoting 系统(更多详情:此处)。
其思想是,在第一次尝试访问 GetInstance()
方法时将创建一个单例 HTTP 服务器通道,这样我们将获得完全的惰性实例化(如第一个代码示例所示)。如果服务器通道已创建(我们可以通过捕获 Socket Exception 来确定:第二个代码块),工作线程将尝试连接到它以创建代理,这将允许我们访问单例。
try {
channel = new HttpChannel(8089);
ChannelServices.RegisterChannel(channel, false);
RemotingConfiguration.RegisterWellKnownServiceType(typeof(Singleton),
"Singleton", WellKnownObjectMode.Singleton);
instance = (Singleton)Activator.GetObject(typeof(Singleton),
"https://:8089/Singleton");
} catch (SocketException) {
channel = new HttpChannel();
ChannelServices.RegisterChannel(channel, false);
instance = (Singleton)Activator.GetObject(typeof(Singleton),
"https://:8089/Singleton");
}
在此示例中,我们为端口号和服务名称使用了硬编码值——将这些值保留在 Web.Config 中可能是个更好的主意。
因此,如果服务器上已经在特定端口号上创建了服务器通道实例——new HttpChannel(8089)
将抛出 Socket Exception,因此跳过服务器创建部分,并尝试从现有通道获取当前实例。
如果通道已创建但单例尚未完全注册,我们可能会遇到问题。如果另一个工作线程尝试连接到它——它将失败。这有点难解决——所以依我之见,我们应该尽量避免创建庞大的构造函数。如果一个构造函数需要很长时间,那么找出最坏情况并添加一个带延迟的循环以在单例尚未创建时提供一些时间可能是一个很好的练习(第三个代码块)。
for (int i = 0; i <= 6; i++) {
try {
instance = (Singleton)Activator.GetObject(typeof(Singleton),
"https://:8089/Singleton");
break;
} catch (RemotingException) {
Thread.Sleep(300);
}
}
另一个问题是——当主工作线程(创建了单例和服务器通道的线程)死亡时该怎么办?
IIS 应用程序池设置允许您指定空闲时间,我们可以将其最大化以防止这种情况发生,但这可能不是最好的主意,因为如果某个工作线程出现问题,它仍然可能会崩溃,我们又会丢失我们的单例,并且其他线程中的代理将指向一个不再存在的对象。
我发现的解决此问题的方法是在返回实例给 GetInstance()
调用者之前检查连接和对象是否有效。为此,我创建了一个虚拟的 CheckConnection()
(下面有示例)方法,它实际上什么都不做,只返回一个布尔值。如果此方法抛出套接字异常,我们就知道该引用不再有效。
public bool CheckConnection() {
return true;
}
因此,当我们知道单例服务器连接已断开——我们可以在当前工作线程中创建一个新的。
更多思考
当然,这不是解决此问题的唯一方法。我们可以创建一个带有某种接口的 Windows 服务,在单个工作线程上运行的 Web 服务,我们的应用程序可以与之通信,以及可能还有许多其他方法。我认为我描述的解决方案非常有用的主要优势在于可维护性——我们不必创建任何其他应用程序池、Web 服务,或者注册和确保 Windows 服务正常工作。它也非常健壮;大多数异常都会在 GetInstace()
方法中处理。另一个便利之处是从程序员的角度使用它——我们不必初始化连接或确保某个实例已在别处创建,因为这再次由 GetInstace()
方法全部处理。
我认为我找到的所有这些优点在一个需要维护大量中小型应用程序的环境中都非常有益。我们可能会争论 .NET Remoting 从性能角度来看是否是实现单例的最佳解决方案,但我认为这是一个折衷——应用程序将更具可伸缩性,如果它在多个工作线程上运行,但然后,我们必须为应用程序的某些部分使用 Remoting;当然,由我们决定何时以及是否会影响性能。通过 TCP 或 IPC 二进制通道可能会获得更好的性能,但不幸的是,IIS 只支持 HTTP。