设计模式实战(八):单例模式——全局唯一与它的反模式面

写在前面

单例是 23 个模式里名声最差的一个——不是因为它没用,而是因为它被滥用得最狠。新手最爱写单例(Instance 多酷),然后收获一堆"测试不了、并发炸了、依赖藏起来"的坑。

这篇不光讲怎么写单例,更要讲怎么别写单例——以及 .NET DI 容器提供的正确姿势。


一、问题:全局唯一实例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
有些对象,整个进程只要一个:
  - 配置读取器(Configuration)
  - 日志工厂(LoggerFactory)
  - 数据库连接池
  - 全局缓存(IMemoryCache)
  - 设备访问(打印机、硬件接口)

  为什么必须唯一:
    - 多个实例浪费资源(重复加载配置、多个连接池)
    - 多个实例导致状态不一致(两个缓存各存各的)
    - 有些资源本身是单点的(一个打印机)

  → 需求:保证一个类只有一个实例,全局可访问。
  → 这就是单例模式。

二、单例:保证全局唯一

1
2
3
4
5
6
7
8
意图:确保一个类只有一个实例,并提供全局访问点。

  要点:
    1. 构造函数私有(外部不能 new)
    2. 类内持有一个静态实例
    3. 提供一个静态访问点(Instance)

  关键词:唯一 + 全局可访问。

三、.NET 线程安全的几种写法

单例最大的技术难点是多线程下也要保证唯一。几种写法,从糙到精:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// ❌ 错:非线程安全,多线程下可能造出多个
public class BadSingleton
{
    private static BadSingleton? _instance;
    public static BadSingleton Instance =>
        _instance ??= new BadSingleton();   // 竞态:两个线程同时进来
}

// ✅ 写法 1:双检锁(经典,但要 volatile + lock)
public class DclSingleton
{
    private static DclSingleton? _instance;
    private static readonly object _lock = new();
    public static DclSingleton Instance
    {
        get
        {
            if (_instance is null)                  // 一检:已有就直接返,避免锁开销
                lock (_lock)
                    if (_instance is null)          // 二检:进了锁再确认一次
                        _instance = new DclSingleton();
            return _instance;
        }
    }
}

// ✅✅ 写法 2:静态只读字段(最简洁,CLR 保证线程安全的懒初始化)
public class StaticSingleton
{
    public static readonly StaticSingleton Instance = new();
    private StaticSingleton() { }   // 私有构造
}   // CLR 在首次访问类时初始化 static 字段,且保证线程安全

// ✅✅✅ 写法 3:Lazy<T>(显式控制,延迟到首次访问 Instance)
public class LazySingleton
{
    private static readonly Lazy<LazySingleton> _lazy = new(() => new LazySingleton());
    public static LazySingleton Instance => _lazy.Value;   // 线程安全 + 懒加载
    private LazySingleton() { }
}
1
2
现代 .NET 推荐:写法 2(static readonly,最简洁)或写法 3(Lazy<T>,最灵活)。
双检锁是 Java 时代的老黄历,C# 里基本不用了。

四、但请优先用 DI 容器的 Singleton

上面三种手写单例,现代 .NET 开发几乎都不用。理由是 DI 容器给了更干净的方案:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 注册为 Singleton —— 容器保证全局唯一
services.AddSingleton<IConfigurationReader, JsonConfigurationReader>();
services.AddSingleton<ILoggerFactory, LoggerFactory>();
services.AddSingleton<IMemoryCache, MemoryCache>();

// 用:构造函数注入,不直接碰 Instance
public class OrderService(IConfigurationReader _config, IMemoryCache _cache)
{
    ...
}
1
2
3
4
5
6
7
8
9
DI Singleton vs 手写单例:

  ✓ 全局唯一由容器保证(你不用操心线程安全)
  ✓ 显式依赖(构造函数注入,依赖一目了然,不藏)
  ✓ 可测试(单测时换 Transient/假实现,改注册即可)
  ✓ 生命周期集中管理(Singleton/Scoped/Transient 统一规则)
  ✓ 没有静态全局状态(Instance 全局可访问 = 反模式,下面讲)

  → 唯一性需求,首选 services.AddSingleton,别手写 Instance。

五、单例的反模式面(为什么名声差)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
手写单例(静态 Instance)的五大罪:

  1. 全局状态
     Instance 是全局可变点,任何代码都能访问修改
     → 状态在程序各处飘,谁改的、什么时候改的,无从追踪

  2. 隐藏依赖
     public void DoWork() { Config.Instance.Get(...) }
     → 方法签名看不出依赖 Config,像个隐式全局变量
     → 代码可读性、可测试性都差

  3. 测试困难
     单元测试想隔离,但 Instance 是全局静态,没法 mock/替换
     → 测试时不得不连真实配置/真实缓存
     → 详见《.NET 单元测试(四):依赖注入与可测试性》

  4. 并发陷阱
     单例被多线程共享,状态必须自己加锁保护
     → 稍不留神就是竞态、死锁

  5. 生命周期捕获(.NET DI 专属大坑)
     services.AddSingleton<Foo>();  Foo 注入了一个 Scoped 服务
     → Singleton 持有 Scoped,Scoped 被强制升级为单例
     → 跨请求共享本不该共享的状态,数据串了
     → 容器会抛异常提醒(Cannot resolve scoped service from root provider)

  所以社区共识:能不用手写单例就不用,用 DI Singleton 替代。

六、什么时候仍需要单例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
不全是反模式,少数场景仍合理:

  ✓ 互斥的外部资源(硬件接口、单点打印机、全局信号量)
  ✓ 框架/底层组件的引导(LoggerFactory、Configuration 根)
  ✓ 性能敏感、确实只要一份的全局缓存

  但即便这些,也优先用 DI Singleton 表达"唯一性",
  只在"必须用静态全局访问点"时(如扩展方法里取服务、遗留代码)才手写。

  判断:要的是"唯一性",还是"全局可访问"?
       多数时候只要"唯一性" → DI Singleton 就够。
       真需要"全局随便谁能取"才手写 Instance。

七、何时用 / 何时别用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
该用(以 DI Singleton 形式):
  ✓ 确需进程内唯一的共享资源(配置、缓存、连接池、日志工厂)
  ✓ 构造昂贵、可复用的无状态服务

别用:
  ✗ 只是为了"方便取"就上单例 → 那是全局变量,用依赖注入
  ✗ 有状态的"上下文"对象 → 单例会跨请求/跨线程串数据
  ✗ 为了少传参数 → 把依赖藏进单例 → 测试灾难
  ✗ Singleton 注入 Scoped 服务(生命周期捕获)

  信号:当你想写 `static Instance` 时,先问自己:
       "我能不能用 services.AddSingleton + 构造函数注入 代替?"
       99% 的答案是可以。

八、小结

  • 意图:保证一个类只有一个实例,全局可访问
  • 手写:私有构造 + 静态实例;线程安全用 static readonlyLazy<T>(双检锁过时了)
  • 正确姿势services.AddSingleton<T>() —— 容器保证唯一,干净、可测
  • 反模式五罪:全局状态、隐藏依赖、测试困难、并发陷阱、生命周期捕获(Singleton 注入 Scoped)
  • 多数时候你要的是"唯一性"而非"全局可访问" → DI Singleton 足矣,别手写 Instance
  • 要单例,先想 DI;手写静态 Instance 是最后手段

下一篇(收官)讲命令与状态模式——正好接上 DDD 里的 CQRS Command 和订单状态机,给整个模式系列收口。