深入 .NET 锁家族:lock、SemaphoreSlim、ReaderWriterLockSlim 与 SpinLock 的选型边界

写在前面

《线程安全的本质》回答了"锁到底在保证什么"——互斥、获取/释放语义与 happens-before。但工程里的下一个问题往往是选型:这里该用 lock 还是 SemaphoreSlim?读多写少要不要上 ReaderWriterLockSlimSpinLock 到底有没有用武之地?

这篇按"先问要不要锁 → 默认选择 → 特定场景的替代"组织:lock/Monitor 是默认答案,InterlockedSemaphoreSlimReaderWriterLockSlimSpinLockMutex 各自回答一个 lock 回答不了的问题。示例基于 .NET 10 / C# 14,涉及 System.Threading.Lock(.NET 9+)时单独标注。

前置:知道什么是临界区和数据竞争即可;锁的内存模型语义见《线程安全的本质:从 CPU 缓存到内存模型,锁到底在保证什么》,本文不重复。

一、先问要不要锁

锁清单变长的第一步,往往是"什么都上锁"。先过三道筛子:

1. 能不能不共享? 局部状态、每次请求新建的对象天然无竞争;Channel<T> 也可以把流程组织成“发送后不再由生产者访问”的所有权转移。注意 Channel<T> 只传递引用,并不会替你禁止双方继续访问同一可变对象,所有权约定仍要由程序保证。

2. 能不能不可变? 不可变对象、快照替换(通过 Volatile.Read/Volatile.WriteInterlocked.Exchange 发布整个引用)能让读者始终看到完整快照,只剩多个写者之间可能需要协调。

3. 单变量能不能用 Interlocked 解决? 官方最佳实践建议:对简单的状态变更,考虑使用 Interlocked,而不是完整的 lock 临界区。计数器、进度值、状态标志翻转通常都适合这一类原子操作;具体会生成什么机器指令取决于体系结构和 JIT,不能一概描述成“单条带 lock 前缀的指令”。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
private int _completed;                       // 计数:一条原子指令
Interlocked.Increment(ref _completed);

private int _max;                             // lock-free max:CAS 循环
public void Observe(int value)
{
    int current;
    do
    {
        current = _max;
        if (value <= current) return;
    }
    while (Interlocked.CompareExchange(ref _max, value, current) != current);
}

CAS 循环的边界:失败即自旋重试,竞争激烈时浪费 CPU;只适合"简单状态、快进快出"的场景。复合不变量(两个字段必须一起变)就该上锁了。

二、lock / Monitor:默认选择

lock 语句不是新东西,它就是 Monitor 的语法糖——编译产物等价于:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
object gate = new object();
bool lockTaken = false;
try
{
    Monitor.Enter(gate, ref lockTaken);
    // 临界区
}
finally
{
    if (lockTaken) Monitor.Exit(gate);
}

注意 Enter 位于 try 内部——自 C# 4 起编译器就这样展开,ref lockTaken 重载存在的意义正是消除"Enter 成功但还没进入 try"时锁泄漏的窗口。

几个被反复问到的语义:

  • 可重入:同一线程可以多次 Enter 不阻塞,但必须等次数地 Exit,其他等待线程才会解除阻塞——所以"锁内调用会再次拿同一把锁的方法"不会自锁死,但也容易掩盖设计问题。
  • 线程亲和:谁拿谁放。A 线程进入后,B 线程调用 Monitor.Exit 会抛 SynchronizationLockException
  • 竞争行为:文档只承诺"阻塞直到释放";“先自旋一会儿再挂起"是 CoreCLR 的实现细节(syncblk.cpp 中按处理器数自适应自旋),不是可以依赖的合同——写代码时按"可能阻塞、可能自旋"两种情况都正确来设计。

.NET 9+ 的 System.Threading.Lock 提供专用锁类型:lock 语句遇到它会编译成 using (x.EnterScope()),API 面包括 Enter/Exit/TryEnterIsHeldByCurrentThread 属性。官方建议 .NET 9 起新代码优先用它。语义上它同样可重入;但"线程在持有 Lock 时退出,行为未定义"是文档明说的——别指望它拯救泄漏的锁。

锁对象的选择比锁类型更容易踩坑:

1
2
3
4
5
6
7
// ❌ lock(this):外部代码也可能锁这个实例,锁不再私有
// ❌ lock(typeof(Foo)):每个应用域一个 Type 实例,跨代码共享
// ❌ lock("my-lock"):字符串驻留,字面量全局共享
// ❌ lock(42):值类型过不了编译(CS0185:lock 语句要求引用类型);
//    绕开 lock 直接调 Monitor.Enter(42) 才会踩装箱——每次实参装箱成不同对象,
//    Exit 时抛 SynchronizationLockException
private readonly object _gate = new();        // ✅ 私有、只读、专用

原则一句话:锁对象必须私有、专用、生命周期明确。C# 13+ 直接用 private readonly Lock _gate = new(); 更好。

三、lock 里的 await:为什么编译器直接拒绝

1
2
3
4
5
6
private readonly object _gate = new();

lock (_gate)
{
    await Task.Delay(100);    // CS1996: lock 语句体内不能 await
}

这不是编译器保守,而是线程亲和与异步续体不匹配:Monitor 锁由线程持有,await 之后的续体却可能在另一个线程上运行。假如手工把 Monitor.Enter 跨过 await,原锁不会因为挂起而自动释放,其他线程仍会被挡住;续体换线程后再调用 Exit 还会抛 SynchronizationLockException,从而造成锁泄漏。

上例的 _gate 是普通 object,当前 .NET 10 SDK 对这种跨 await 的写法报 CS1996。锁表达式换成 System.Threading.Lock 也不能跨 await;不过它可以出现在 async 方法中,只要整个 lock 作用域里没有 await。手动 Enter/Exit 虽可编译,类型文档仍明确要求两者之间不能出现 await

因此,需要让互斥范围跨过 await 时,必须改用一个“不绑定线程”的原语——这就轮到 SemaphoreSlim

四、SemaphoreSlim:BCL 中可 await 的互斥方案

SemaphoreSlim 本义是信号量,initialCount: 1 时它就是一把可异步的互斥锁:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
private readonly SemaphoreSlim _gate = new(1, 1);

public async Task<Document> LoadAsync(string path, CancellationToken ct)
{
    await _gate.WaitAsync(ct);
    try
    {
        return await LoadAndParseAsync(path, ct);   // await 也安全:信号量无线程亲和
    }
    finally
    {
        _gate.Release();
    }
}

选型视角的要点:

  • 无线程亲和WaitRelease 可以由不同线程调用(信号量的文档明说"不强制线程身份”)——这正是它能跨 await 存活的原因。
  • maxCount > 1 就是限流器:连接池上限、并发导入数、客户端节流——new SemaphoreSlim(8, 8) 允许至多 8 个并发进入,这是 lock 给不了的语义。
  • 顺序无保证:多个等待者被唤醒的顺序不保证 FIFO(文档原话);当前实现里 async 等待者按 FIFO 链表释放,但那是实现细节,不要依赖。
  • Release 是同步方法:BCL 里没有 SemaphoreSlim.ReleaseAsync;好在 Release() 本身不阻塞,直接在 finally 里同步调用即可。
  • Dispose 的边界Dispose 不与其他成员并发使用,文档要求等所有其他操作结束后再调用。不要把 Dispose 当成“唤醒等待者”的关闭信号:当前 runtime 实现不会替在途的 WaitAsync 完成、取消或设置异常。正确的关闭顺序是先发出取消信号并等待使用者退出,最后再 Dispose;调用已经 Dispose 的实例则会抛 ObjectDisposedException

它为什么"轻量":内部基于 Monitor 和自旋,不创建内核对象(只有在访问 AvailableWaitHandle 时才惰性创建内核句柄)。跨进程同步它做不到——那是内核信号量 Semaphore 的事(见第八节)。

五、ReaderWriterLockSlim:读多写少才考虑

读操作不互相互斥、只和写互斥——ReaderWriterLockSlim(下称 RWLS)表达这个语义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
private readonly ReaderWriterLockSlim _rw = new();   // 默认 LockRecursionPolicy.NoRecursion
private readonly Dictionary<string, Config> _cache = new();

public Config Get(string key)
{
    _rw.EnterReadLock();
    try
    {
        return _cache[key];
    }
    finally
    {
        _rw.ExitReadLock();
    }
}

它最有特色也最容易用错的是可升级读锁:“先读检查,必要时升级为写"这一 check-then-act 竞态的标准解法:

 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
public Config GetOrCreate(string key)
{
    _rw.EnterUpgradeableReadLock();
    try
    {
        if (_cache.TryGetValue(key, out var cached))
            return cached;

        _rw.EnterWriteLock();          // 升级:不放弃读权限的前提下拿写锁
        try
        {
            // 持有 upgradeable read 期间,其他写者无法进入;这里无需再次检查。
            cached = CreateConfig(key);        // 必须快速且不阻塞
            _cache[key] = cached;
            return cached;
        }
        finally
        {
            _rw.ExitWriteLock();
        }
    }
    finally
    {
        _rw.ExitUpgradeableReadLock();
    }
}

这个示例假设 CreateConfig 是快速的内存计算。不要在写锁里执行磁盘、网络 I/O;如果加载过程需要 await,应改用 Task/Lazy<Task<T>> 去重、异步缓存等设计,而不是让线程亲和的 RWLS 跨越异步等待。

规则要点(都有文档背书):

  • 同一时刻至多一个线程处于可升级模式——因此默认 NoRecursion 策略下升级不会死锁,这是官方推荐默认策略的原因之一。
  • 持读锁时直接 EnterWriteLock 会抛 LockRecursionException,不是死锁:文档把这个模式判为"极易死锁"直接禁止,异常消息会指引你改用 upgradeable。
  • Enter/Exit 严格配对:多退一次或退没进过的模式都会抛 SynchronizationLockException
  • 当前公平策略会照顾写者:有写者排队时会阻止新读者进入,以平衡读写双方;官方也明确保留未来调整公平策略的可能,不要把精确唤醒顺序当成合同。

什么时候不用它:读临界区很短时,普通 lock 的开销常常更低(读写锁的簿记本身就是成本)——官方没有给出 RWLS 与 lock 的通用性能结论,社区基准则普遍显示短临界区下 lock 占优,写多读少时更是纯亏。另一个常被忽略的替代:ConcurrentDictionary 内部用细粒度锁实现"读无锁、写按桶加锁”,缓存类场景往往比一把大 RWLS 更好。决策顺序:先用 ConcurrentDictionary 这类结构 → 读临界区确实长且读写比悬殊 → 再考虑 RWLS → 压测说话。RWLS 实现了 IDisposable,宿主对象结束使用且确认已无持有者或等待者后,也应将它 Dispose。

六、SpinLock:几乎不该出现在业务代码

SpinLock 只回答一个问题:临界区经过测量确实很短,而且自旋成本低于阻塞与唤醒成本时,自旋等待可能更便宜。不存在通用的“几十纳秒”阈值,结果取决于处理器、竞争强度和临界区内容。文档给出的使用条件相当苛刻:

1
2
3
private SpinLock _lock = new SpinLock(enableThreadOwnerTracking: true);
// 注意:SpinLock 是 struct——绝不能声明为 readonly 字段,
// 传参必须 by ref;误拷贝会产生两把"各自独立"的锁

持有它期间应避免的操作几乎排除了所有业务代码:阻塞、调用可能阻塞的代码、再持有别的锁、动态分发、调用不受自己控制的代码、分配内存。官方定位是"叶子级锁"——数据结构内部(无锁栈、缓冲池)经 profiling 证明后使用。两个细节:

  • Exit(Boolean useMemoryBarrier) 重载:传 false 会省略通常由退出操作提供的内存屏障,不能再按普通锁的发布语义理解;默认 Exit() 等价于传 true。除非在底层代码中已经证明内存顺序正确,否则保持默认。
  • 无 abandonment 检测:没进就 Exit腐蚀内部状态(文档原话 corrupt);线程持锁退出也无任何提示。线程所有权跟踪(构造参数 true)只帮助调试非持有者 Exit 的问题。

选型建议一句话:在想到 SpinLock 之前,先想 Interlocked;两者都不行,大概率你要的是普通 lock

七、跨进程:Mutex 与内核 Semaphore

前面所有锁都活在进程内。跨进程互斥需要内核对象:

Mutex——“比 Monitor 消耗更多系统资源,但可以跨应用域边界、支持多种等待、可以同步不同进程中的线程”。下面是 Windows 上单实例应用的简化写法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
using var mutex = new Mutex(
    initiallyOwned: true,
    name: @"Local\MyApp.SingleInstance",
    out bool createdNew);

if (!createdNew)
{
    // 已有实例在运行(Local\ 前缀 = 每会话一个;Global\ = 全机器)
    return 0;
}

try
{
    RunApplication();
}
finally
{
    // Dispose 负责释放句柄,ReleaseMutex 才是放弃当前线程的所有权。
    mutex.ReleaseMutex();
}

Mutex 同样可重入、有线程亲和(非持有者 Release 抛异常)。它独有的语义是遗弃(abandonment):线程终止而未释放时,mutex 被标记为遗弃,下一个获取它的线程收到 AbandonedMutexException——注意此时它已经持有该 mutex、必须照常释放。遗弃不是普通异常:它意味着持锁线程死在临界区中间,受保护的数据可能不一致,正确动作是校验完整性或直接进入安全路径,而不是重试。官方还有一条反向建议:如果无法保证 Exit 一定被调用,改用 Mutex——它在线程终止时由系统自动释放(尽管以遗弃为代价)。

Semaphore(内核版)——可在 Windows 上通过命名系统信号量实现跨进程计数限流;Unix 类系统不支持命名 Semaphore,跨平台方案需要另选 IPC 机制。它与 SemaphoreSlim 一样无线程亲和,但没有遗弃概念:信号量无主,线程退出只是少了一次 Release、计数变低,不会像 Mutex 那样抛 AbandonedMutexException。命名对象还要注意名字冲突与抢注风险;Windows 上可通过 System.Threading.AccessControl 包提供的 SemaphoreAcl/MutexAcl 在创建时设置 ACL。

八、死锁规避工具箱

四个来源、四类解法(消灭共享的四种并发策略见《线程安全的本质》第九节,UI 线程死锁场景见《WinForms 学习笔记(六):线程与 async——UI 线程、Invoke 与死锁》):

死锁来源解法
锁顺序不一致全局规定锁的获取顺序;一把锁能解决的不要用两把
临界区内等待外部事件临界区里不做阻塞等待、不发请求、不调回调
无法预知的交叉Monitor.TryEnter(gate, TimeSpan.FromMilliseconds(300)) 拿不到就放弃并记录——官方最佳实践给出的死锁探测手段
async 等待链不持锁 await;SemaphoreSlim 的等待也要能被 CancellationToken 取消

TryEnter 的超时不是"重试一下"用的——失败意味着可能已经死锁,正确的动作是告警并放弃这次操作,而不是睡 100ms 再来:

1
2
3
4
5
6
7
if (!Monitor.TryEnter(_gate, TimeSpan.FromMilliseconds(300)))
{
    _log.Warn("Potential deadlock detected: gate not acquired in 300ms");
    throw new TimeoutException("无法在预算内获得锁,疑似死锁");
}
try { /* 临界区 */ }
finally { Monitor.Exit(_gate); }

九、选型决策表

场景首选理由
保护复合不变量(多字段一致性)lock / Lock(.NET 9+)互斥语义完整、默认正确的那个选择
单变量原子更新Interlocked一条指令,无锁开销
async 方法内的互斥SemaphoreSlim(1,1) + WaitAsyncBCL 自带、可跨 await 的常用方案
限制并发数 NSemaphoreSlim(N,N)信号量本义
读多写少、读临界区长ReaderWriterLockSlim(先对比 ConcurrentDictionary读并行;短临界区不划算
极短临界区、叶子级数据结构SpinLock(profiling 之后)省上下文切换;禁忌多
跨进程互斥 / 单实例命名 Mutex内核对象;处理 AbandonedMutexException
Windows 跨进程计数限流命名 Semaphore内核信号量;Unix 不支持命名 Semaphore
生产者-消费者、流水线Channel<T>不是锁,是转移所有权——优先考虑

最后一行是提醒:选型表的上一级问题是"要不要锁"(第一节)。《.NET 高并发编程全景》里减少争用的手段(细粒度锁、无锁结构、并发集合),价值都高于换一把更快的锁。

十、验证与观测

锁选对没有,最终要靠数字:

争用观测Monitor.LockContentionCount(.NET Core 3.0+)统计进程中 Monitor(包括锁定普通对象的 lock)发生争用的次数;不要把它理解成所有同步原语的统一计数器。dotnet-counters 里对应指标在 .NET 9+ 为 dotnet.monitor.lock_contentions,.NET 8 及以下显示为 Monitor Lock Contention Count

1
dotnet-counters monitor --process-id <pid> --counters System.Runtime

争用计数不为零不是错误——它是信号:持续增长且伴随吞吐停滞,才值得投入优化(减少临界区、降低粒度、换结构)。

正确性验证。并发单测很难捕捉竞态,至少做到:用并行压力(Parallel.For + 随机延迟)反复冲击不变量断言;用 CancellationToken 注入取消验证 finally 配对;对可升级读锁专门测试“等待已有读者退出后升级,并在持有期间阻止其他写者”;对 SemaphoreSlim 专门测试关闭时的在途等待。

性能验证。换锁前后的基准用真实负载形态(读写比、临界区长度、核数)跑,BenchmarkDotNet 的并发场景要多线程压,单线程 micro-benchmark 说明不了争用行为。

总结

  • lock 是默认答案,其余每个原语都只回答一个 lock 回答不了的问题:Interlocked 答"单变量",SemaphoreSlim 答"async 和并发数",RWLS 答"读并行",SpinLock 答"经测量确实极短的叶子级临界区",Mutex 答"跨进程互斥",命名 Semaphore 答"Windows 跨进程计数"。
  • 锁对象要私有专用,lock(this)/Type/字符串/值类型都是事故;锁不能跨过 await,需要异步互斥时通常改用 SemaphoreSlim.WaitAsync
  • 选型顺序是倒过来的:先消灭共享,再消灭锁;留下的每一把锁,都应该能说出它独有语义的用武之地。

参考资料

微软官方文档:

源码与实现细节: