C# 中的单例设计模式:第 1 部分






3.33/5 (11投票s)
本文以最简洁易懂的方式介绍了单例模式。文章还将讨论静态类以及单例设计模式与静态类之间的区别。
- 下载 1.BasicSingleton.zip - 128.8 KB
- 下载 PDFArticle1.zip - 757.4 KB
- 下载 2.ThreadSafeSingletonWithDoubleCheckLocking.zip - 129.1 KB
目录
引言
我一直想写一篇关于 C# 中单例设计模式的文章。尽管网上已经有很多关于单例设计模式的文章,但我会尝试以最简洁易懂的方式来介绍这个主题。文章还将讨论静态类以及单例设计模式与静态类之间的区别。这是一个两部分的教程系列,用于学习单例。以下是下一篇文章的链接。
模式本身
单例设计模式是一种创建型设计模式。设计模式有不同的类别,其中创建型类别涉及实例的创建和初始化。这种模式有助于程序员编写更灵活的代码,允许根据各种情况创建对象,例如单例、工厂、抽象工厂等。涵盖设计模式类型超出了本文的范围,因此我们专注于单例设计模式。单例设计模式表示或允许开发人员编写代码,其中只创建一个类的实例,并且线程或其他进程应引用该类的单个实例。在某些时候,我们可能需要在代码中只使用一个类的实例,如果其他类试图创建该类的对象,那么已经实例化的对象将被共享给该类。一个非常常见且合适的例子是日志写入类。我们可能需要维护一个单一文件来写入我们 .NET 应用程序来自各种客户端(如移动客户端、Web 客户端或任何 Windows 应用程序客户端)的请求日志。在这种情况下,应该有一个类负责将日志写入单个文件。由于请求同时来自多个客户端,因此需要一种机制,一次只记录一个请求。此外,其他请求不应被遗漏,而应被仔细记录,以便在记录时,这些请求不会覆盖或冲突已经记录的请求。为了实现这一点,我们可以通过遵循单例模式的准则来使一个类成为单例,其中一个线程安全的单例对象将在所有请求之间共享,我们将在本文中通过实际示例详细讨论这一点。
单例的优点
在深入实际实现之前,让我们先重点介绍一下单例类或模式的优点。从请求日志记录示例中我们可以得出第一个优点是,单例可以处理对共享资源的并发访问,这意味着如果我们同时与多个客户端共享一个资源,那么应该妥善处理。在我们的例子中,日志文件是共享资源,而单例确保每个客户端都能访问它,而不会发生死锁或冲突。第二个优点是,它只允许一个负责共享资源的类的实例,该实例在受控状态下被所有客户端或应用程序共享。
准则
每个模式都基于某些准则,在实现模式时应遵循这些准则。这些准则有助于我们构建健壮的模式,并且每个准则在我们创建单例类时都有其重要性。在 C# 中实现单例设计模式时,请始终遵循以下准则。
- 检查实现时,只创建一个类的实例,并且应该只有一个点可以创建实例。
- 单例类的构造函数应该是私有的,以便任何类都不能直接实例化单例类。
- 应该有一个静态属性/方法来负责单例类的实例化,并且该属性应该在应用程序之间共享,并专门负责返回单例实例。
- C# 单例类应该是密封的,以便任何其他类都不能继承它。当我们处理嵌套类结构时,这很有用。稍后在实现单例时,我们将讨论这种情况。
基本单例实现
理论讲了很多,现在让我们实际实现单例模式。我们将逐步进行。
- 打开 Visual Studio,创建一个名为 Singleton 的控制台应用程序(您可以选择任何喜欢的名称)。
- 添加一个名为
Singleton
的类,并将以下代码添加到其中以记录请求消息。目前,我们实际上并未将日志记录到文件中,而是在控制台上显示消息。
Singleton.cs
using static System.Console;
namespace Singleton
{
class Singleton
{
public void LogMessage(string message)
{
WriteLine("Message " + message);
}
}
}
我们还在类中添加了一个公共构造函数,其中包含一个变量来保存该单例类创建对象的计数器。每当创建 Singleton
类的实例时,我们都会递增 instanceCounter
变量,因此递增和打印它的最佳位置是构造函数。
using static System.Console;
namespace Singleton
{
class Singleton
{
static int instanceCounter = 0;
public Singleton()
{
instanceCounter++;
WriteLine("Instances created " + instanceCounter);
}
public void LogMessage(string message)
{
WriteLine("Message " + message);
}
}
}
请注意,我们此处没有使用任何单例准则,首先我们将尝试从主方法(例如,在 Program.cs 类中)调用此方法。因此,转到 Program.cs 类并编写以下代码,通过创建该类的对象来调用单例类的 LogMessage
方法。我们假设有两个客户端或两个类(Manager
类和 Employee
类)正在创建 Singleton
类的对象来记录其请求消息。我们将这些实例命名为“fromManager
”和“fromEmployee
”。
Program.cs
using static System.Console;
namespace Singleton
{
class Program
{
static void Main(string[] args)
{
Singleton fromManager = new Singleton();
fromManager.LogMessage("Request Message from Manager");
Singleton fromEmployee = new Singleton();
fromEmployee.LogMessage("Request Message from Employee");
ReadLine();
}
}
}
当我们运行应用程序时,我们会看到以下输出
在这里,我们看到我们创建了两个名为 Singleton
的类的实例,并且两个对象分别调用了方法。第一次创建 fromManager
实例时,计数器增加了 1,第二次创建 fromEmployee
实例时,计数器再次增加了 1,总共是 2。请注意,我们尚未实现单例准则,我们的目标是限制这种多对象创建。
- 如果我们再次参考准则,它说单例类的所有构造函数都应该是私有的,并且应该有一个属性或方法负责提供该单例类的实例。所以,让我们试试。将
Singleton
类中的公共构造函数更改为私有,如下所示:private Singleton() { instanceCounter++; WriteLine("Instances created " + instanceCounter ); }
但是当我们回到 Program.cs 类中创建实例的地方时,我们会看到如下错误,这是一个编译时错误,在此时编译程序时也会出现。
它说构造函数无法访问,这是因为我们将其设为私有。所以,让我们将提供对象的职责委托给 Singleton
类中的一个静态属性。在 Singleton
类中添加一个新属性,该属性使用后备字段来返回对象。
以下是代码
using static System.Console;
namespace Singleton
{
class Singleton
{
static int instanceCounter = 0;
private static Singleton singleInstance = null;
private Singleton()
{
instanceCounter++;
WriteLine("Instances created " + instanceCounter );
}
public static Singleton SingleInstance
{
get
{
if (singleInstance == null)
{
singleInstance = new Singleton();
}
return singleInstance;
}
}
public void LogMessage(string message)
{
WriteLine("Message " + message);
}
}
}
如果我们查看上面的代码,我们只是创建了一个名为 singleInstance 的后备字段和一个名为 SingleInstance
的公共静态属性。每当访问此属性时,它都会使用新的 Singleton
实例实例化后备字段并将其返回给客户端,但不是每次都返回,为了确保这一点,我们检查如果 singleInstance 为 null,则仅返回新实例,而不是每次访问属性时都返回。在所有其他时间,它应该返回最初创建的同一实例。由于此类的构造函数现在是私有的,因此只能从类成员内部访问,而不能从类外部访问。现在转到 program.cs 类并访问此属性以获取实例,因为由于私有构造函数,我们现在无法直接创建 Singleton 实例。
以下是代码
using static System.Console;
namespace Singleton
{
class Program
{
static void Main(string[] args)
{
Singleton fromManager = Singleton.SingleInstance;
fromManager.LogMessage("Request Message from Manager");
Singleton fromEmployee = Singleton.SingleInstance;
fromEmployee.LogMessage("Request Message from Employee");
ReadLine();
}
}
}
现在,当我们运行应用程序时,我们会得到以下输出。
这清楚地表明只创建了 Singleton
类的一个实例,但我们的方法由两个调用者分别调用。这是因为第一次创建实例时,但在 fromEmployee
访问该属性时,返回了已创建的对象。
所以,毫无疑问,我们实现了单例设计模式,并改变了这个 Singleton 类的对象创建策略,但这只是单例设计模式的一个非常基本的操作,它仍然没有处理死锁情况以及多线程环境中的类访问。让我们看看如何使这个类也线程安全。在继续之前,请将 singleton 类设置为密封。稍后我们将讨论为什么我们将其设置为密封。
线程安全的单例实现
问题
我们的基本级别实现仅适用于单线程系统,因为我们的实例创建是延迟初始化的,这意味着我们只在 SingleInstance
属性被调用时创建实例。假设存在两个线程同时尝试访问属性的情况,在这种情况下,可能出现两个线程同时命中 null 检查并获得访问新实例创建的机会,因为它们发现实例变量仍然为 null。让我们在当前的 Singleton 实现中测试此场景。
转到 Program.cs 类,创建两个名为 LogEmployeeRequest
和 LogManagersRequest
的方法,并将两个实例的日志记录代码移动到这些方法中,如下所示。
现在,让我们尝试使用 Parallel.Invoke
方法并行调用这些方法,如下所示。此方法可以并行调用多个方法,因此我们将遇到两个方法同时声明 Singleton 实例的情况。Parallel 是 System.Threading.Tasks
命名空间下的类。
以下是代码
using System.Threading.Tasks;
using static System.Console;
namespace Singleton
{
class Program
{
static void Main(string[] args)
{
Parallel.Invoke(() => LogManagerRequest(), () => LogEmployeeRequest());
ReadLine();
}
private static void LogManagerRequest()
{
Singleton fromManager = Singleton.SingleInstance;
fromManager.LogMessage("Request Message from Manager");
}
private static void LogEmployeeRequest()
{
Singleton fromEmployee = Singleton.SingleInstance;
fromEmployee.LogMessage("Request Message from Employee");
}
}
}
现在,当我们运行应用程序时,我们会得到以下输出
上面的输出清楚地表明我们最终创建了 Singleton
类的两个实例,因为我们的构造函数被调用了两次。这是因为两个方法同时执行。现在,为了克服这种情况,我们可以进一步增强我们的 Singleton
类。
解决方案
克服这种情况的一种方法是使用锁。如果任何线程尝试访问实例,我们可以锁定一个对象,在这种情况下,其他线程会等待直到锁被释放。让我们来实现这一点。按以下方式更新您的 Singleton
类。
以下是代码
using static System.Console;
namespace Singleton
{
sealed class Singleton
{
static int instanceCounter = 0;
private static Singleton singleInstance = null;
private static readonly object lockObject = new object();
private Singleton()
{
instanceCounter++;
WriteLine("Instances created " + instanceCounter );
}
public static Singleton SingleInstance
{
get
{
lock (lockObject)
{
if (singleInstance == null)
{
singleInstance = new Singleton();
}
}
return singleInstance;
}
}
public void LogMessage(string message)
{
WriteLine("Message " + message);
}
}
}
在上述源代码中,我们创建了一个私有的静态只读对象类型变量并对其进行了初始化。然后在 SingleInstance
属性中,我们将实例创建的代码包装在锁中,以便一次只有一个线程可以进入代码,而其他线程会等待直到第一个线程完成其执行。现在,让我们运行应用程序并检查输出。编译并运行。
所以,现在只创建了 Singleton
类的一个实例,这意味着我们的锁工作正常。当前实现仍然存在一个问题。问题在于,每次访问 SingleInstance
属性时都会调用我们的锁对象代码,这可能会对应用程序产生巨大的性能影响,因为当我们需要在应用程序中获得良好性能时,锁非常昂贵。因此,我们可以通过将锁包装在一个条件之下,只有当 singleInstance 后备字段为 null 时才能访问它,从而限制这种每次都访问锁代码。因此,我们属性的代码如下所示:
public static Singleton SingleInstance
{
get
{
if (singleInstance == null)
{
lock (lockObject)
{
if (singleInstance == null)
{
singleInstance = new Singleton();
}
}
}
return singleInstance;
}
}
现在,当我们运行应用程序时,锁代码不会每次都执行,而只会在第一次访问时执行,因为第二次它不会发现 singleInstance
字段为 null。完整的类代码如下。
using static System.Console;
namespace Singleton
{
sealed class Singleton
{
static int instanceCounter = 0;
private static Singleton singleInstance = null;
private static readonly object lockObject = new object();
private Singleton()
{
instanceCounter++;
WriteLine("Instances created " + instanceCounter );
}
public static Singleton SingleInstance
{
get
{
if (singleInstance == null)
{
lock (lockObject)
{
if (singleInstance == null)
{
singleInstance = new Singleton();
}
}
}
return singleInstance;
}
}
public void LogMessage(string message)
{
WriteLine("Message " + message);
}
}
}
我们将这种空实例检查锁定称为 **“双重检查锁定”**,这通常在面试中被问到。在下一篇文章中,我们将看到如何摆脱这种双重检查锁定,同时仍保持我们的单例功能完整和线程安全。
结论
在本文中,我们讨论了什么是单例设计模式,何时需要它以及它解决了什么问题。我们还讨论了如何一步步创建基本的单例类,以及如何通过锁定来增强该类以实现线程安全,并通过双重检查锁定来实现高性能。为了避免文章过长,我将学习单例主题分为两部分。请参考本文的下一部分,其中我们将讨论延迟初始化、饿汉初始化、如何在不使用双重检查锁的情况下创建单例,以及为什么将单例类设置为密封类以及单例与静态类之间的区别。下一篇文章>>