C# 单例模式与 SqlSugar 的 Scoped 生命周期:一场"唯一性"的认知纠偏
分享一个我在阅读 SqlSugar 源码时踩过的认知坑:把
DbScoped.Sugar当成"单例"去理解,结果怎么都对不上。拆开源码后才发现,它根本不属于单例模式的范畴,而是一套基于AsyncLocal<T>的 Scoped 生命周期实现。这篇文章从单例模式的几种写法讲起,逐步对比到 SqlSugar 的真实设计,最后落到一个常见的异步上下文踩坑。
目录
- 一、单例模式:全局唯一的四种写法
- 二、SqlSugar 的 DbScoped.Sugar:一个"像单例"的东西
- 三、从 CallContext 到 AsyncLocal
:SqlSugar 的生命力进化史 - 四、IocCallContext 源码解读:ConcurrentDictionary + AsyncLocal 的双层结构
- 五、单例 vs Scoped:一张对比表看懂本质区别
- 六、容易踩的坑:AsyncLocal 的值会随异步流继承
- 七、总结
一、单例模式:全局唯一的四种写法
单例模式的目标非常明确:整个进程内,某个类型只有一个实例。C# 里常见写法有四类,我按"从简单到复杂"的顺序过一遍。
1. 饿汉式:静态字段直接初始化
public sealed class Singleton
{
private static readonly Singleton _instance = new Singleton();
private Singleton() { }
public static Singleton Instance => _instance;
}
优点:由 CLR 保证线程安全,简单可靠。
缺点:类加载时就创建实例,无法延迟加载——如果构造开销大或依赖其他资源,会造成浪费。
2. 懒汉式:双重检查锁定
public sealed class Singleton
{
private static volatile Singleton _instance;
private static readonly object _lock = new object();
private Singleton() { }
public static Singleton Instance
{
get
{
if (_instance == null)
{
lock (_lock)
{
if (_instance == null)
_instance = new Singleton();
}
}
return _instance;
}
}
}
优点:延迟加载。
缺点:
- 必须加
volatile(防止指令重排导致其他线程拿到未初始化完的对象); - 双重检查 + 锁的代码容易写错;
- 每次访问都有一次 null 判断(虽然开销很小)。
3. Lazy<T> 写法
public sealed class Singleton
{
private static readonly Lazy<Singleton> _lazy =
new Lazy<Singleton>(() => new Singleton(), LazyThreadSafetyMode.ExecutionAndPublication);
private Singleton() { }
public static Singleton Instance => _lazy.Value;
}
优点:
- 延迟加载:第一次访问
Value时才创建; - 线程安全由框架保证,不需要手写锁和 volatile;
- 代码简洁、可读性高,不易出错;
- 可通过
LazyThreadSafetyMode(ExecutionAndPublication / PublicationOnly / None)灵活控制线程安全策略。
缺点:多一层 Lazy<T> 包装,有极微小的性能开销(可忽略);需要 .NET 4.0+(现在基本不是问题)。
4. 静态构造函数 + 嵌套类
public sealed class Singleton
{
private Singleton() { }
public static Singleton Instance => Nested._instance;
private static class Nested
{
internal static readonly Singleton _instance = new Singleton();
}
}
由 CLR 保证线程安全和延迟加载(首次访问 Nested 时才初始化),性能也很好,是兼顾延迟加载与线程安全的常见推荐方案。
二、SqlSugar 的 DbScoped.Sugar:一个"像单例"的东西
SqlSugar 框架内部有一段类似的"缓存取实例"的代码:
private static SqlSugarClient GetSqlSugarClient()
{
// 1. 从当前上下文取缓存
SqlSugarClient data = IocCallContext<SqlSugarClient>.GetData("SqlSugar_IOC_SqlSugar");
if (data == null)
{
// 2. 没有就 new 一个,然后存回当前上下文
SqlSugarClient newSugarClient = SugarHelper.GetNewSugarClient();
IocCallContext<SqlSugarClient>.SetData("SqlSugar_IOC_SqlSugar", newSugarClient);
return newSugarClient;
}
// 3. 有就直接返回
return data;
}
第一次看到这段代码时,我的第一反应是"这不就是个单例吗":一个静态方法,缓存一个实例,没有就创建。但仔细一想又不对劲——它跟单例的四种写法都对不上:没有锁、没有 Lazy<T>、volatile 也没有。
直觉告诉我内部应该是个字典。翻源码验证后,发现比我想象的更精巧,也更有意思。
三、从 CallContext 到 AsyncLocal:SqlSugar 的生命力进化史
要理解 SqlSugar 的 IocCallContext 为什么用 ConcurrentDictionary<string, AsyncLocal<T>> 这么一层结构,得先知道它在 .NET Framework 时代用的是 CallContext——一个基于序列化的异步上下文存储机制。
3.1 旧时代的 CallContext
在 .NET Framework 中,异步上下文的存储靠的是 System.Runtime.Remoting.CallContext:
CallContext.LogicalSetData("SqlSugar_IOC_SqlSugar", sqlSugarClient);
var client = CallContext.LogicalGetData("SqlSugar_IOC_SqlSugar");
CallContext 的底层实现是 LogicalCallContext,它是一个 LinkedHashMap(双向链表),每次读写都要进行序列化/反序列化来在不同线程间传递值。这意味着:
- 每次读/写都有性能开销;
- 传递的是"值拷贝",不是引用共享;
- 但它的优势是能在所有异步场景(包括
Task、Thread、ThreadPool)中正确传递。
3.2 新时代的 AsyncLocal
到了 .NET Core,SqlSugar 改用了 AsyncLocal<T>:
private static readonly AsyncLocal<SqlSugarClient> _context = new AsyncLocal<SqlSugarClient>();
_context.Value = sqlSugarClient; // 存储
var client = _context.Value; // 读取
相比 CallContext,AsyncLocal<T> 有本质提升:
| 维度 | CallContext | AsyncLocal |
|---|---|---|
| 底层实现 | 序列化 + 链表传递 | 线程本地存储(TLS)+ 异步上下文 |
| 性能开销 | 每次读写序列化,较贵 | 零序列化,直接读 TLS 或异步槽 |
| 数据传递 | 值拷贝,传递副本 | 引用传递,子上下文继承引用 |
| 线程安全 | 需要手动处理并发 | 天然隔离,无需额外锁 |
| 适用时代 | .NET Framework | .NET Core / .NET 5+ |
3.3 为什么 SqlSugar 最终选了 AsyncLocal
因为 CallContext 的序列化开销在高频访问场景下太明显了——每次 DbScoped.Sugar 访问都要经过序列化/反序列化,这在 Web 请求、高频异步调用场景下是不可接受的。AsyncLocal<T> 的出现完美解决了这个问题:
- 零序列化,性能接近直接读静态字段;
- 异步上下文隔离天然成立,无需额外锁;
- 子上下文继承引用,恰好能实现 Scoped 语义。
这也是为什么 SqlSugar 在 .NET Core 时代彻底重写了 IocCallContext 的底层实现。
四、IocCallContext 源码解读:ConcurrentDictionary + AsyncLocal 的双层结构
SqlSugar.IOC 里 IocCallContext<T> 的真实实现是:
internal static class IocCallContext<T>
{
// 第一层:全局字典,key 是 name,value 是 AsyncLocal<T>
private static ConcurrentDictionary<string, AsyncLocal<T>> state
= new ConcurrentDictionary<string, AsyncLocal<T>>();
public static void SetData(string name, T data)
{
// 从字典里取 name 对应的 AsyncLocal<T>,没有就创建一个
// 然后把 data 赋给它
state.GetOrAdd(name, (string _) => new AsyncLocal<T>()).Value = data;
}
public static T GetData(string name)
{
// 从字典里取 name 对应的 AsyncLocal<T>,然后读它的 Value
AsyncLocal<T> value;
return state.TryGetValue(name, out value) ? value.Value : default(T);
}
}
关键点有三个:
state是静态的ConcurrentDictionary,进程级共享;- 字典的 value 是
AsyncLocal<T>对象,不是T本身; AsyncLocal<T>才是真正承载"当前上下文值"的容器。
为什么用 ConcurrentDictionary<string, AsyncLocal<T>> 而不是 Dictionary<string, T>?因为它解决了两个问题:
问题 1:一个类要缓存多种类型的数据。 IocCallContext<T> 是泛型类,T 不同,state 就是不同的静态字段。但即使 T 相同,也可能有多个 key(比如 "SqlSugar_IOC_SqlSugar"、"SqlSugar_IOC_Other"),所以需要按 name 区分。
问题 2:AsyncLocal<T> 的"值"是按上下文隔离的,但"容器对象"是全局的。 这是这份源码最核心的设计:
AsyncLocal<T>对象本身是全局唯一的;- 它的
.Value是按异步上下文隔离的。
var al = new AsyncLocal<string>();
al.Value = "A"; // 上下文 A 里读到 "A"
// 换到上下文 B
al.Value = "B"; // 上下文 B 里读到 "B"
// 回到上下文 A
// al.Value 还是 "A"
同一个 AsyncLocal<T> 对象,在不同上下文里 .Value 不同。
所以整体设计是:state 字典全局唯一,保证每个 name 只有一个 AsyncLocal<T> 对象;AsyncLocal<T> 对象全局唯一,但它的 .Value 按上下文隔离。每个 name 对应的数据,在每个上下文里各有一份,互不干扰。
回到 GetSqlSugarClient() 的行为:
"SqlSugar_IOC_SqlSugar"这个 name,在state字典里对应唯一一个AsyncLocal<SqlSugarClient>对象;- 这个
AsyncLocal<SqlSugarClient>的.Value,在每个异步上下文里独立; - 所以:同一个异步上下文内,第一次调用创建
SqlSugarClient并存入.Value,后续调用读到同一个实例;不同异步上下文,各自.Value为 null,各自创建新的SqlSugarClient。
五、单例 vs Scoped:一张对比表看懂本质区别
| 维度 | 单例模式 | DbScoped.Sugar |
|---|---|---|
| 作用范围 | 整个进程 | 当前异步上下文 / 线程 |
| 实例数量 | 永远 1 个 | 每个上下文 1 个,多个上下文多个实例 |
| 容器结构 | 静态字段直接存实例 | ConcurrentDictionary<string, AsyncLocal<T>> |
| 值存放位置 | 静态字段 | AsyncLocal<T>.Value |
| 隔离粒度 | 无(全局) | 异步上下文 |
| 生命周期 | 进程级 | 上下文级(AsyncLocal 随异步流走) |
| 线程安全 | 靠锁 / Lazy / CLR | 靠 AsyncLocal 的上下文隔离 + ConcurrentDictionary 的并发安全 |
| 是否延迟加载 | 看写法 | 是,首次 GetData 为 null 才创建 |
| 目的 | 全局唯一 | 避免同一上下文内重复创建,同时隔离不同上下文 |
最大区别:单例是"一个对象全局共享";DbScoped.Sugar 是"一个 AsyncLocal 对象全局共享,但它的值按上下文隔离"。
AsyncLocal<T> 本身是全局单例,但它的 .Value 是 Scoped。这层"对象单例 + 值 Scoped"的设计,是它和普通单例最本质的不同。
所以 DbScoped.Sugar 不是单例,它是上下文作用域的单例(per-context singleton),也叫 Scoped 生命周期。把它和单例对比,你会发现"延迟加载"像懒汉,"线程安全"像 Lazy,但"唯一性范围"完全不同——这正是它容易让人误解的地方。
结构上可以用这样一张图概括:
单例模式(全局唯一)
├── 饿汉式:类加载即创建,全局 1 个
├── 懒汉式:首次访问创建,全局 1 个
├── Lazy<T>:首次访问创建,全局 1 个,框架保证线程安全
└── 嵌套类:首次访问创建,全局 1 个,CLR 保证
Scoped 模式(上下文唯一)—— DbScoped.Sugar 属于这类
└── AsyncLocal + Dictionary
├── 上下文 A:实例 A
├── 上下文 B:实例 B
└── 上下文 C:实例 C
六、容易踩的坑:AsyncLocal 的值会随异步流继承
因为 AsyncLocal<T>.Value 会随异步流传递,所以:
var db1 = DbScoped.Sugar; // 上下文 A,创建实例 1
await Task.Run(() =>
{
var db2 = DbScoped.Sugar; // 子上下文,可能拿到实例 1(值被继承)
});
AsyncLocal<T> 的默认行为是:子异步上下文会继承父上下文的值。所以 Task.Run 里的 DbScoped.Sugar 可能拿到和父上下文相同的实例,而不是新建。
这意味着:
- 如果你在父上下文创建了
SqlSugarClient,子任务里用同一个实例,就是多线程共享同一个SqlSugarClient,可能出问题; - 如果你希望子任务有独立实例,需要显式
.Value = null或用AsyncLocal<T>的ValueChanged事件控制。
这也是为什么 SqlSugar 官方推荐用 SqlSugarScope 而不是直接 SqlSugarClient 做并发操作——SqlSugarClient 本身不是线程安全的,SqlSugarScope 才是线程安全封装,内部为每个上下文/线程管理独立的 SqlSugarClient。
七、总结
- 单例模式解决"全局唯一",四种写法各有取舍:饿汉最简单最快但不延迟;懒汉延迟加载但容易写错;
Lazy<T>把线程安全交给框架;嵌套类由 CLR 保证延迟加载和线程安全。 DbScoped.Sugar解决"上下文唯一":内部用ConcurrentDictionary<string, AsyncLocal<T>>实现——字典全局唯一,AsyncLocal<T>对象全局唯一,但它的.Value按异步上下文隔离。- 它不是单例,也不该用单例的三种写法去套。它和单例的根本区别在于"唯一性范围":单例是全局唯一,它是上下文唯一。全局唯一 → 太重;每次新建 → 太浪费;每个上下文一个 → 刚刚好。
AsyncLocal的值会随异步流继承,父子上下文可能共享同一个SqlSugarClient,并发场景务必使用SqlSugarScope。- SqlSugar 的
IocCallContext底层机制经历了从CallContext(序列化传递)到AsyncLocal<T>(零序列化 TLS)的进化,性能从可接受提升到接近直接读静态字段。

浙公网安备 33010602011771号