C# 单例模式与 SqlSugar 的 Scoped 生命周期:一场"唯一性"的认知纠偏

分享一个我在阅读 SqlSugar 源码时踩过的认知坑:把 DbScoped.Sugar 当成"单例"去理解,结果怎么都对不上。拆开源码后才发现,它根本不属于单例模式的范畴,而是一套基于 AsyncLocal<T> 的 Scoped 生命周期实现。这篇文章从单例模式的几种写法讲起,逐步对比到 SqlSugar 的真实设计,最后落到一个常见的异步上下文踩坑。

目录

一、单例模式:全局唯一的四种写法

单例模式的目标非常明确:整个进程内,某个类型只有一个实例。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);
    }
}

关键点有三个:

  1. state 是静态的 ConcurrentDictionary,进程级共享;
  2. 字典的 value 是 AsyncLocal<T> 对象,不是 T 本身;
  3. 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() 的行为:

  1. "SqlSugar_IOC_SqlSugar" 这个 name,在 state 字典里对应唯一一个 AsyncLocal<SqlSugarClient> 对象;
  2. 这个 AsyncLocal<SqlSugarClient> 的 .Value,在每个异步上下文里独立;
  3. 所以:同一个异步上下文内,第一次调用创建 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。

七、总结

  1. 单例模式解决"全局唯一",四种写法各有取舍:饿汉最简单最快但不延迟;懒汉延迟加载但容易写错;Lazy<T> 把线程安全交给框架;嵌套类由 CLR 保证延迟加载和线程安全。
  2. DbScoped.Sugar 解决"上下文唯一":内部用 ConcurrentDictionary<string, AsyncLocal<T>> 实现——字典全局唯一,AsyncLocal<T> 对象全局唯一,但它的 .Value 按异步上下文隔离。
  3. 它不是单例,也不该用单例的三种写法去套。它和单例的根本区别在于"唯一性范围":单例是全局唯一,它是上下文唯一。全局唯一 → 太重;每次新建 → 太浪费;每个上下文一个 → 刚刚好。
  4. AsyncLocal 的值会随异步流继承,父子上下文可能共享同一个 SqlSugarClient,并发场景务必使用 SqlSugarScope。
  5. SqlSugar 的 IocCallContext 底层机制经历了从 CallContext(序列化传递)到 AsyncLocal<T>(零序列化 TLS)的进化,性能从可接受提升到接近直接读静态字段。
posted @ 2026-10-13 02:51  孤沉  阅读(19)  评论(0)    收藏  举报