写在前面
多线程程序只要共享可变状态,就绕不开一个问题:两个执行流同时修改同一份数据时,怎样保证结果仍然正确?锁是最常见的答案,却不是一个单独的 API,而是一整套由 CPU 原子指令、内存顺序、运行时调度和操作系统等待队列共同完成的机制。
本文先建立与语言无关的锁模型:锁在保护什么、有哪些分类、竞争时底层发生了什么;再落到 .NET,系统讲清 Interlocked、lock / Monitor、System.Threading.Lock、SemaphoreSlim、ReaderWriterLockSlim、SpinLock、Mutex 与 Semaphore 的语义和选型边界。
示例基于 .NET 10 / C# 14;System.Threading.Lock 要求 .NET 9+ / C# 13+。CPU 缓存、内存模型与 happens-before 的完整背景见《线程安全的本质:从 CPU 缓存到内存模型,锁到底在保证什么》;本文保留理解锁所需的部分。
一、锁究竟在保护什么
锁保护的不是某个变量本身,而是一段访问共享状态的并发协议。
| |
“检查余额”和“扣减余额”共同维护 _balance >= 0。两个线程可能同时通过检查,再各自扣减,最终破坏这个不变量。锁要覆盖整个 check-then-act 过程:
| |
- 临界区(critical section):同一时刻只允许规定数量的执行流进入的代码区域;互斥锁通常只允许一个。
- 受保护状态:临界区内按同一协议访问的数据,可以是一个字段,也可以是多个对象共同组成的不变量。
- 锁对象:协调进入临界区的身份标识。两段代码即使修改同一变量,使用不同锁也没有保护作用。
一把正确使用的互斥锁通常同时提供:
- 原子性:从其他遵守同一协议的线程看,临界区不会被交错执行。
- 可见性:释放锁前的写入,在随后成功获取同一把锁的线程中可见。
- 有序性:编译器、JIT 和 CPU 不能把关键访问任意搬过获取/释放边界。
锁不会保护绕开协议的代码,也不会让锁内的数据库事务或远程调用获得跨系统原子性。
二、锁的分类:多个正交维度
一把锁可以同时拥有多个属性。把维度分开,比死记 API 名称更有用。
2.1 悲观与乐观
悲观并发控制先取得锁,失败者等待;lock、Mutex 和读写锁属于这一思路。
乐观并发控制先读取和计算,提交时验证状态是否仍未变化;失败就重试或放弃。基于 CAS(Compare-And-Swap)的原子更新、数据库版本号和 HTTP ETag 都体现这一思路。
二者不是“慢”和“快”的同义词。乐观方案在高竞争下可能不断重试;悲观锁在无竞争时则可能只走几条用户态指令。
2.2 互斥、读写与计数
| 类型 | 同时允许进入者 | 典型用途 |
|---|---|---|
| 互斥锁(mutex) | 1 | 保护复合不变量 |
| 读写锁 | 多个读者或一个写者 | 读多写少且读临界区较长 |
| 计数信号量 | 最多 N 个 | 并发限流、资源池容量 |
信号量不是“可重入 N 次的互斥锁”。它通常不记录所有者;互斥锁则往往具有所有权语义。
2.3 自旋、阻塞与混合
- 自旋锁:拿不到就在 CPU 上循环重试。等待很短时可避免挂起和唤醒,稍长就会浪费处理器。
- 阻塞锁:挂起等待线程,锁释放后再唤醒。等待时不占 CPU,但入队、调度和上下文切换有成本。
- 混合锁:先短暂自旋,仍失败再阻塞。现代通用锁常采用这种策略,具体阈值是实现细节。
2.4 所有权、重入性与作用域
线程亲和的锁要求“谁获取,谁释放”,例如 .NET 的 Monitor、System.Threading.Lock、RWLS 和 Mutex。它们不能跨越可能换线程的 await。
SemaphoreSlim 与 Semaphore 不强制线程身份,异步续体可以归还许可;这不代表可以多释放。
可重入锁允许同一线程再次获取,并维护递归计数;每次获取都要对应释放。不可重入锁再次获取自己时会失败、自旋或永久等待。SemaphoreSlim(1, 1) 没有所有者和重入语义,嵌套等待通常会把调用链卡住。
进程内用户态锁通常更轻;命名 Mutex 等内核对象可以跨进程,但涉及系统调用、命名空间和权限。
跨机器的“分布式锁”不是把 mutex 名称扩展到网络,而是要面对进程失联、网络分区、租约过期和 fencing token 等问题,属于另一类协调协议,见《分布式系统学习笔记(三):分布式锁》。
2.5 公平性
公平性回答“多个等待者谁先获得锁”。严格 FIFO 可限制饥饿,却可能降低吞吐并形成锁队列效应;允许插队可能更快,却让某些等待者长期得不到执行。
除非 API 明确承诺,不要依赖精确唤醒顺序。.NET 常用锁没有通用的严格 FIFO 合同。公平性、避免饥饿和线程优先级也是不同问题。
2.6 阻塞、lock-free 与 wait-free
这是并发算法的进展保证,不能和“等待线程是在自旋还是休眠”混为一谈:
- Blocking:某个线程停在关键位置,可能阻止其他相关操作完成。互斥锁保护的算法通常属于这一类;自旋锁即使不挂起线程,仍然依赖持有者释放。
- Lock-free:在有限步内,系统整体至少有一个操作取得进展;某个特定线程仍可能不断失败而饥饿。
- Wait-free:每个操作都能在有界步骤内完成,是更强也更难实现的保证。
Interlocked.CompareExchange 是构造非阻塞算法的原子积木,但用到 CAS 不会自动让完整算法获得 lock-free 或 wait-free 保证。
三、一把锁在底层怎样工作
平台实现各异,但多数高性能互斥锁可以用“状态字 + 快速路径 + 竞争路径”理解。
3.1 地基:原子读改写
CPU 提供原子读改写(read-modify-write)能力,例如 compare-and-swap、exchange 和 fetch-add。概念上的 CAS 是:
| |
若 0 表示空闲、1 表示占用,线程可用 CAS 尝试把 0 改为 1。成功就获得锁;失败说明有人抢先。
在 x86 等现代处理器上,原子指令操作可缓存内存时通常借助缓存一致性协议取得缓存行独占权,不等于每次都“锁住整条系统总线”。不同架构和内存类型的实现不同,把所有原子操作概括成“总线锁”并不准确。
3.2 快速路径与竞争路径
简化的互斥过程如下:
| |
CAS 失败后,通用实现通常会组合这些动作:
- 短暂自旋,期待持有者马上退出。
- 登记并排队,避免所有线程无序争抢。
- 再检查状态,堵住“准备休眠时锁恰好释放”的丢失唤醒窗口。
- 请求运行时或操作系统挂起线程。
- 解锁方发布写入并唤醒等待者。
Linux futex 与 Windows WaitOnAddress / WakeByAddress* 都体现“状态主要在用户态维护,只有等待和唤醒时才进入内核”的设计思想。它们是理解阻塞路径的例子,不表示每种 .NET 锁都直接一对一调用这些 API。
CoreCLR 的 Monitor 是混合锁。无竞争状态可以编码在对象头中;需要更多状态或使用 Monitor.Wait / Pulse 时,会使用完整的同步块结构。对象头、同步块膨胀和自旋策略属于 CoreCLR 实现,不是 C# 语言合同。
3.3 内存顺序:互斥之外的另一半
只有“同一时刻一个线程”还不够。获取锁要具备 acquire(获取)效果,释放锁要具备 release(释放)效果,前一持有者的写入才能按规则对后一持有者可见。
运行时会把这种语义映射到目标 CPU 所需的原子指令与屏障。不要用普通读写自行模仿锁状态;即使在某台 x64 机器上看似正常,换优化级别或体系结构也可能失败。
3.4 锁的成本来自哪里
- 无竞争时的原子指令、屏障和状态维护。
- 多核心反复争夺锁状态所在缓存行,形成 cache-line ping-pong。
- 自旋等待白白占用 CPU。
- 挂起、唤醒、调度延迟和上下文切换。
- 持有者被抢占后形成的锁队列效应(lock convoy)。
- 锁内 I/O、日志、回调、分配和未知代码放大临界区。
“哪把锁最快”没有脱离负载的答案;临界区长度、竞争强度、核心数和读写比缺一不可。
四、Interlocked 与 Volatile:先理解原子操作
Interlocked 不是锁,而是 .NET 对常用原子读改写操作的封装,适合计数、交换、状态转换和较小的 CAS 循环。
4.1 直接原子更新
| |
Increment、Add、Exchange 与 CompareExchange 各自是原子操作;具体生成一条还是多条机器指令、使用哪种指令,由体系结构和 JIT 决定。
4.2 CAS 循环
下面用 CompareExchange 实现只增不减的最大值:
| |
CAS 失败者不阻塞,但竞争越激烈,重试越多。它还有这些边界:
- 多个独立字段组成的不变量不能靠几次独立 CAS 自动变得原子。
- 值经历 A → B → A 后,CAS 只看到“仍为 A”,这就是 ABA 问题。版本戳、不可变节点或专门的内存回收方案可用于特定场景。
- 使用 CAS 不自动意味着整个算法是 lock-free;进度保证要分析完整算法。
- 复杂无锁结构的证明、测试和维护成本通常高于普通锁。
4.3 Volatile 不会把复合操作变原子
Volatile.Read / Volatile.Write 为单次访问提供相应的可见性和顺序语义,但不会把 ++ 变成原子操作:
| |
计数应使用 Interlocked.Increment。若要同时维护多个字段的不变量,普通锁往往更清楚可靠。
选择顺序是:不共享 → 不可变快照 → 单位置状态用 Interlocked → 复合状态用锁。只有 CAS 循环仍简单、可证明、可测试时才继续无锁化。
五、lock / Monitor:默认互斥方案
对普通对象执行 lock 时,C# 编译器使用 Monitor.Enter / Exit,语义可近似理解为:
| |
实际业务代码应使用 lock,不要手写展开式。Monitor 的关键语义是:
- 互斥、可重入;进入多少次就要退出多少次。
- 线程亲和;非持有线程
Exit会抛SynchronizationLockException。 - 获取/释放提供相应内存顺序。
- 不承诺严格 FIFO。
- 可能自旋也可能阻塞,调用方必须按“可能等待”设计。
5.1 锁对象要私有、专用、稳定
| |
避免锁定 this、typeof(Foo)、字符串、可被替换的字段和值类型。外部代码可能共享前三者;替换字段会让线程锁住不同对象;值类型不符合 lock 要求,手动交给 Monitor 还会因装箱产生不同对象。
锁对象只需稳定地代表一组受保护状态。建议在字段旁注明哪些成员由 _gate 保护。
5.2 Monitor.Wait / Pulse:锁内等待条件
互斥解决“不能同时改”,条件变量解决“条件不满足时先睡,变化后再检查”。Monitor.Wait 会原子释放当前锁并等待;被唤醒后,重新取得同一把锁才会返回。
| |
必须用 while 重查谓词,不能用 if 假定“被唤醒就一定轮到我”。Pulse / PulseAll 只是让等待者重新竞争锁,不会立即交出锁;没有等待者时,通知也不会为未来保存。
该示例用于解释条件变量。生产者—消费者流水线通常优先选择支持异步等待、完成、取消和背压的 Channel<T>。
5.3 TryEnter 只是超时边界
| |
超时能让调用方放弃并留下诊断线索,不能消除已形成的循环依赖。失败后无脑重试还可能演变成活锁。
六、System.Threading.Lock:.NET 9+ 专用锁
当 lock 表达式的编译期类型正是 System.Threading.Lock 时,编译器会使用其作用域 API,等价于 using (x.EnterScope()):
| |
它仍是线程亲和、可重入的互斥锁,提供 Enter、TryEnter、Exit、EnterScope 和 IsHeldByCurrentThread。专用类型的价值是明确同步身份并给运行时优化空间,不是改变锁的基本语义。
不要把 Lock 转成 object 再锁:转换后会走该对象的 Monitor,编译器也会对此警告。不要让持锁作用域跨越 await;C# 禁止在 lock 语句体中使用 await。
Lock 没有 Monitor.Wait / Pulse 条件变量 API。同步条件等待可用专用 object + Monitor;异步条件等待优先考虑 Channel<T> 或任务组合。
七、SemaphoreSlim:异步互斥与进程内限流
SemaphoreSlim 是轻量级计数信号量。计数为 1 时常用作可跨 await 的互斥门:
| |
它能跨 await,因为信号量不强制线程身份,续体换线程也能 Release。边界包括:
- 没有重入语义;同一调用链嵌套等待
SemaphoreSlim(1, 1)会把自己卡住。 - 只在成功等待后释放;超时返回
false或取消抛异常时不能释放。 - 指定
maxCount可抓住过度释放;new SemaphoreSlim(1, 1)多释放会抛SemaphoreFullException。 - 不保证 FIFO,不能用等待顺序实现业务优先级。
Dispose不可与其他成员并发调用,也不是唤醒等待者的关闭信号。先取消并等待使用者退出,再 Dispose。
计数大于 1 时,它表达并发预算:
| |
这里最多允许 8 个作业进入,不要求它们互相排斥。SemaphoreSlim 默认不创建内核同步对象;访问 AvailableWaitHandle 时才惰性创建相关句柄。它不能做命名跨进程同步。
八、ReaderWriterLockSlim:读并行不是免费午餐
RWLS 允许多个读者并行,或一个写者独占。它适合读明显多于写、读临界区不够短,并且基准证明收益超过簿记成本的场景。
| |
| 模式 | 同时数量 | 能否直接写 |
|---|---|---|
| Read | 多个 | 否 |
| Upgradeable Read | 至多一个,可与普通读者共存 | 可进一步取得 Write |
| Write | 一个,排斥其他模式 | 是 |
“读取,不存在就创建”要用可升级读锁:
| |
默认 LockRecursionPolicy.NoRecursion 下,普通读锁不能直接升级成写锁。由于同一时刻只有一个可升级读者,示例进入写锁后不需要并发写者双检。
RWLS 线程亲和,不能跨 await。当前实现会在写者等待时阻止新读者进入,但官方保留调整公平策略的权利。短临界区、写入频繁或可使用 ConcurrentDictionary / 不可变快照时,普通 lock 或并发集合往往更简单。是否使用 RWLS 必须由真实读写比下的基准决定。
确认没有持有者、等待者和并发调用后,再 Dispose RWLS。
九、SpinLock:极短的叶子级临界区
SpinLock 在拿不到锁时占用 CPU 循环。只有临界区经测量短到“自旋比挂起再唤醒更便宜”,且位于自己完全控制的底层数据结构中,才应考虑它。
| |
SpinLock是可变struct,字段不能是readonly,也不能随意按值复制;副本是另一把锁。- 默认不提供重入语义。所有权跟踪主要用于诊断,并会改变开销和部分异常行为。
- 持锁期间不应阻塞、分配、执行 I/O、获取其他锁或调用无法控制的代码。
Exit(false)可省略退出屏障;除非已证明内存顺序,否则使用默认Exit()。- 线程带锁退出不会留下遗弃通知;错误退出还可能破坏锁状态。
业务代码几乎总应先考虑 Interlocked 或普通 lock。不存在通用的纳秒阈值。
十、Mutex 与 Semaphore:跨进程内核同步
10.1 Mutex:带所有权的跨进程互斥
Mutex 是线程亲和、可重入的内核互斥原语。命名 mutex 可以协调不同进程,成本通常高于进程内锁。
| |
Dispose 关闭句柄,ReleaseMutex 才释放所有权。遗弃异常表示前一线程可能死在临界区中间,受保护数据需要校验或恢复。
Windows 上可使用 Local\ / Global\ 前缀区分当前会话与跨会话名称,并通过 ACL 限制访问。Unix 类系统的 .NET 实现使用文件系统支持命名 mutex,反斜杠属于保留字符,且当前没有对应的访问控制机制;存在不受信任的本机用户时不应依赖它充当安全边界。上例采用不含反斜杠的名称,便于在 .NET 10 支持的平台间复用。
10.2 Semaphore:跨进程计数
Semaphore 是内核计数信号量,没有 mutex 的线程所有权和遗弃语义,可表达“最多 N 个进程同时使用某资源”。在本文基线 .NET 10 中,命名 Mutex 可在 Windows 与 Unix 类系统上用于跨进程同步,命名 Semaphore 仍只受 Windows 支持;跨平台跨进程限流需选择目标系统适用的 IPC。
信号量无主,线程终止时系统不知道该归还多少许可。每次成功等待都要在 finally 中配对 Release,并设置合适的最大计数。
十一、这些同步原语不是锁
| 原语 | 主要语义 | 典型场景 |
|---|---|---|
AutoResetEvent | 一次信号放行一个等待者 | 线程间单次交接 |
ManualResetEventSlim | 保持有信号状态直到复位 | 启动门、暂停/继续 |
CountdownEvent | 计数归零后放行 | 等待 N 个同步任务完成 |
Barrier | 多参与者分阶段会合 | 分阶段并行算法 |
Channel<T> | 异步队列、完成和背压 | 生产者—消费者 |
TaskCompletionSource<T> | 一次异步结果或事件 | 将回调桥接为任务 |
Monitor.Wait/Pulse | 持锁检查条件并通知 | 同步条件变量 |
它们可能在内部使用锁,但给调用方的是通知、会合、队列或完成语义。用互斥锁轮询条件,或用信号事件保护复合不变量,通常是抽象选错了。
十二、锁的故障形态
12.1 死锁
经典死锁需要四个条件:互斥、持有并等待、不可抢占、循环等待。打破其中一个即可阻止这类死锁。
| |
最有效的规则是建立全局锁顺序,让所有路径按相同顺序获取;还应避免持锁等待 I/O、任务结果、UI 调度、回调和外部代码。
12.2 活锁、饥饿与优先级反转
活锁中线程都在退让、释放和重试,却无人完成;随机退避或单一协调者可以打破同步重试。
饥饿中系统整体前进,某个等待者却长期拿不到锁。非公平锁、持续到来的请求和优先级差异都可能触发它。
优先级反转中低优先级线程持锁,高优先级线程等待,而中优先级线程持续占用 CPU。首先应缩短临界区并避免实时路径共享锁;不要假设所有 .NET 锁都提供可依赖的优先级继承。
持锁线程被抢占或阻塞时,还会形成锁队列效应。分片状态、消息传递和缩短临界区通常比更换锁类有效。
十三、工程中怎样把锁写对
13.1 先定义不变量,再画锁边界
不要从“字段加什么锁”出发,而要写出必须始终成立的关系,如“队列长度等于索引中的任务数”。所有可能破坏同一不变量的路径都要遵守同一协议。
13.2 锁内只做最小状态变更
| |
不要在锁内做 I/O,或调用事件订阅者、插件和未知委托;回调可能重入、获取其他锁或无限阻塞。
13.3 控制粒度,优先高层抽象
一把全局锁易于证明却限制并行度;分段锁、每对象锁或按 key 锁提高并行度,却增加锁顺序和生命周期难度。按 key 动态创建锁时还要防止字典无限增长和移除竞态。先用清晰的锁得到正确实现,再用争用数据决定是否分片。
优先考虑 ConcurrentDictionary<TKey,TValue> 等并发集合、不可变快照、Channel<T>、共享 Task、Lazy<Task<T>> 或单一所有者模型。高层抽象未必内部无锁,但能封装易错协议。
13.4 不混合同步阻塞与异步等待
lock、Monitor、RWLS 与 Mutex 都绑定线程,不能跨 await。反过来,在同步上下文中用 .Result / .Wait() 堵住线程等待异步操作,也可能和调度依赖形成死锁。同步临界区保持短且同步;必须跨异步操作时改用 SemaphoreSlim.WaitAsync 或消息传递。
十四、.NET 选型决策表
| 场景 | 通常首选 | 关键边界 |
|---|---|---|
| 单计数器、单引用交换、简单状态转换 | Interlocked | 复杂 CAS 不一定优于锁 |
| 多字段复合不变量 | lock / Lock(.NET 9+) | 私有锁对象;不跨 await |
| 同步条件变量 | Monitor.Wait/Pulse | 谓词在 while 中复查 |
| 异步互斥 | SemaphoreSlim(1, 1) | 无重入和所有权语义 |
| 进程内并发数上限 N | SemaphoreSlim(N, N) | 不保证 FIFO |
| 读多写少、读临界区较长 | RWLS | 无 async;先实测 |
| 极短底层临界区 | SpinLock | 可变 struct;profiling 后使用 |
| 跨进程互斥 | 命名 Mutex | 处理遗弃、权限、命名范围 |
| Windows 跨进程计数 | 命名 Semaphore | 无所有权;Unix 不支持命名形式 |
| 生产者—消费者、异步背压 | Channel<T> | 队列抽象,不是锁 |
推荐顺序:消除共享 → 不可变或所有权转移 → 高层并发结构 → Interlocked → 普通锁 → 有证据时才选特化锁。
十五、诊断和验证
15.1 看争用,不猜争用
Monitor.LockContentionCount 统计进程启动以来 Monitor 的争用次数,只覆盖 Monitor 类锁,不是所有原语的统一计数器。
| |
.NET 9+ 中相关指标名为 dotnet.monitor.lock_contentions;旧版 EventCounter 名为 monitor-lock-contention-count,一些版本的 dotnet-counters 将它显示为 Monitor Lock Contention Count (Count / 1 sec)。争用不等于故障,持续增长并伴随吞吐下降或延迟上升才值得深挖。
进程存活却不推进时,连续抓取多次线程栈比只看一次更有价值。dotnet-stack、dump + SOS,以及 dotnet-trace / PerfView 的运行时争用事件可用于不同深度的分析。工具语法随版本变化,先查看当前帮助:
| |
15.2 压力测试不变量
- 多线程反复执行冲突操作,最终断言业务不变量。
- 在读、检查、写之间注入随机
Thread.Yield或短延迟。 - 注入取消、超时和异常,确认每条路径都释放成功取得的锁或许可。
- 测试“停止接收 → 取消等待 → 等使用者退出 → Dispose”的关闭顺序。
- 用真实核心数、读写比和临界区长度做基准,不用单线程 micro-benchmark 推断争用性能。
并发测试不能证明绝无竞态,但能主动扩大窗口。吞吐提高却偶发破坏数据的方案不是优化。
十六、总结
- 锁保护共享状态的访问协议与不变量,不是某个变量的“线程安全属性”。
- 锁可以从冲突策略、准入数量、等待方式、所有权、重入性、作用域和公平性分类;一个 API 往往横跨多个维度。
- 底层通常由 CPU 原子读改写建立快速路径,竞争后经过自旋、排队、挂起和唤醒;获取/释放还提供内存顺序。
Interlocked是原子操作,不是锁。它适合单位置的简单状态转换;Volatile不能把++或 check-then-act 变原子。- .NET 同步互斥优先用
lock/System.Threading.Lock,异步互斥用SemaphoreSlim;RWLS、SpinLock、Mutex 和内核 Semaphore 都有明确边界。 - 死锁之外还有活锁、饥饿、优先级反转和锁队列效应。真正有效的优化常是缩短临界区、减少共享、分片状态或改用高层抽象。
参考资料
处理器与操作系统机制:
- Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 2(LOCK 前缀)
- Linux kernel:futex2 用户空间 API
- Windows:WaitOnAddress
- Windows:WakeByAddressSingle
.NET 官方文档:
- .NET 同步基元概述
- C# lock 语句
- System.Threading.Lock API
- Interlocked API
- Volatile API
- Monitor.Wait
- Monitor.Pulse
- Semaphore 和 SemaphoreSlim
- ReaderWriterLockSlim
- SpinLock
- Mutex
- .NET 运行时内置指标
CoreCLR 设计与实现: