文章目录
-
- [一、CLR、JIT 与 lock 升级全过程](#一、CLR、JIT 与 lock 升级全过程)
-
- [1.1 前置概念:CLR 与 JIT 是什么](#1.1 前置概念:CLR 与 JIT 是什么)
- [1.2 现象:同样的 lock,性能差 10 倍](#1.2 现象:同样的 lock,性能差 10 倍)
- [1.3 原理:对象头与锁状态机](#1.3 原理:对象头与锁状态机)
- [1.4 JIT 与 CLR 的相关优化](#1.4 JIT 与 CLR 的相关优化)
- [1.5 避坑要点](#1.5 避坑要点)
- [二、CAS 与自旋退避策略](#二、CAS 与自旋退避策略)
-
- [2.1 现象:无锁反而比有锁慢](#2.1 现象:无锁反而比有锁慢)
- [2.2 原理:CAS 竞争的总线风暴](#2.2 原理:CAS 竞争的总线风暴)
- [2.3 SpinWait 自适应退避](#2.3 SpinWait 自适应退避)
- [2.4 错误退避 vs 正确退避](#2.4 错误退避 vs 正确退避)
- [2.5 总结](#2.5 总结)
- 三、锁车队效应与公平性取舍
-
- [3.1 现象:线程越多,吞吐反而越低](#3.1 现象:线程越多,吞吐反而越低)
- [3.2 原理:锁车队(Lock Convoy)怎么形成](#3.2 原理:锁车队(Lock Convoy)怎么形成)
- [3.3 公平性 vs 吞吐的取舍](#3.3 公平性 vs 吞吐的取舍)
- [3.4 缓解策略](#3.4 缓解策略)
- [3.5 总结](#3.5 总结)
- [四、内存屏障体系:volatile / Interlocked / 显式 API](#四、内存屏障体系:volatile / Interlocked / 显式 API)
-
- [4.1 现象:双重检查锁定偶尔拿到未构造对象](#4.1 现象:双重检查锁定偶尔拿到未构造对象)
- [4.2 为什么需要内存屏障](#4.2 为什么需要内存屏障)
- [4.3 原理:屏障强度决定发布安全性](#4.3 原理:屏障强度决定发布安全性)
- [4.4 三种正确写法](#4.4 三种正确写法)
- [4.5 volatile 的正确使用场景](#4.5 volatile 的正确使用场景)
- [4.6 显式内存屏障 API](#4.6 显式内存屏障 API)
- [4.7 总结](#4.7 总结)
- 五、死锁的两种形态
-
- [5.1 形态一:传统锁顺序死锁](#5.1 形态一:传统锁顺序死锁)
-
- [5.1.1 现象:两个线程都拿不到对方的锁](#5.1.1 现象:两个线程都拿不到对方的锁)
- [5.1.2 原理:死锁四必要条件](#5.1.2 原理:死锁四必要条件)
- [5.1.3 解决方案一:统一锁顺序(打破循环等待)](#5.1.3 解决方案一:统一锁顺序(打破循环等待))
- [5.1.4 解决方案二:TryEnter 超时兜底(打破不可剥夺)](#5.1.4 解决方案二:TryEnter 超时兜底(打破不可剥夺))
- [5.2 形态二:异步死锁(sync-over-async)](#5.2 形态二:异步死锁(sync-over-async))
-
- [5.2.1 现象:async 方法调用后永久挂起](#5.2.1 现象:async 方法调用后永久挂起)
- [5.2.2 原理:SynchronizationContext 捕获](#5.2.2 原理:SynchronizationContext 捕获)
- [5.2.3 解决方案与替代模式](#5.2.3 解决方案与替代模式)
- [5.2.4 异步锁专属注意事项](#5.2.4 异步锁专属注意事项)
- [5.3 两种死锁的快速鉴别](#5.3 两种死锁的快速鉴别)
- [六、GC 暂停与线程池饥饿](#六、GC 暂停与线程池饥饿)
-
- [6.1 GC 对锁对象的影响](#6.1 GC 对锁对象的影响)
-
- [6.1.1 现象:莫名其妙的死锁 / 锁突然变慢](#6.1.1 现象:莫名其妙的死锁 / 锁突然变慢)
- [6.1.2 问题一:终结器线程死锁](#6.1.2 问题一:终结器线程死锁)
- [6.1.3 问题二:锁对象本身被移动或回收](#6.1.3 问题二:锁对象本身被移动或回收)
- [6.1.4 问题三:GC 暂停延长锁持有时间](#6.1.4 问题三:GC 暂停延长锁持有时间)
- [6.2 线程池饥饿](#6.2 线程池饥饿)
-
- [6.2.1 现象:请求雪崩,延迟突然飙升](#6.2.1 现象:请求雪崩,延迟突然飙升)
- [6.2.2 原理:线程池注入速度太慢](#6.2.2 原理:线程池注入速度太慢)
- [6.2.3 缓解与解决](#6.2.3 缓解与解决)
- [6.3 总结](#6.3 总结)
- 七、读写锁写饥饿与不可变替代模式
-
- [7.1 现象:写入永远排不上队](#7.1 现象:写入永远排不上队)
- [7.2 原理:写饥饿从哪来](#7.2 原理:写饥饿从哪来)
- [7.3 三种工程解法](#7.3 三种工程解法)
- [7.4 不可变对象 + 引用原子替换(Copy-on-Write)](#7.4 不可变对象 + 引用原子替换(Copy-on-Write))
-
- [7.4.1 原理:引用赋值是原子的](#7.4.1 原理:引用赋值是原子的)
- [7.4.2 与读写锁的对比](#7.4.2 与读写锁的对比)
- [7.4.3 注意事项](#7.4.3 注意事项)
- [7.5 选型建议](#7.5 选型建议)
- 八、伪共享:缓存行级性能杀手
-
- [8.1 现象:两个独立计数器,多线程下慢得离谱](#8.1 现象:两个独立计数器,多线程下慢得离谱)
- [8.2 原理:缓存行与 MESI](#8.2 原理:缓存行与 MESI)
- [8.3 解决:缓存行对齐填充](#8.3 解决:缓存行对齐填充)
-
- [8.3.1 错误写法](#8.3.1 错误写法)
- [8.3.2 正确写法:结构体填充对齐](#8.3.2 正确写法:结构体填充对齐)
- [8.3.3 更简洁的 .NET 现代写法](#8.3.3 更简洁的 .NET 现代写法)
- [8.4 注意事项](#8.4 注意事项)
- [8.5 总结](#8.5 总结)
- [九、CancellationToken 与可取消等待](#九、CancellationToken 与可取消等待)
-
- [9.1 为什么需要可取消等待](#9.1 为什么需要可取消等待)
- [9.2 可取消等待的 API 体系](#9.2 可取消等待的 API 体系)
- [9.3 标准用法](#9.3 标准用法)
-
- [9.3.1 同步路径:TryEnter + 超时](#9.3.1 同步路径:TryEnter + 超时)
- [9.3.2 异步路径:WaitAsync(ct)](#9.3.2 异步路径:WaitAsync(ct))
- [9.3.3 组合超时与取消](#9.3.3 组合超时与取消)
- [9.4 注意事项](#9.4 注意事项)
- [9.5 总结](#9.5 总结)
- 十、对照表
- 十一、总结
一、CLR、JIT 与 lock 升级全过程
进入正题前,先把后文反复出现的两个角色交代清楚------理解了它们,后面的优化细节才不会觉得抽象。
1.1 前置概念:CLR 与 JIT 是什么
- CLR(Common Language Runtime,公共语言运行时):.NET 程序的「运行管家」。你写的 C# 代码并不会直接变成机器码,而是先编译成一种中间语言 IL(Intermediate Language),再交给 CLR 加载执行。CLR 在运行期间几乎包办了所有底层事务:内存分配与回收(GC)、线程调度、类型安全检查、异常处理、与操作系统打交道。可以把它类比为 Java 世界里的 JVM------你写的是 C#,但「养」你程序的就是 CLR。
- JIT(Just-In-Time Compiler,即时编译器):CLR 内部的「翻译官」。它负责把 IL 在运行时按方法逐个翻译成当前 CPU 能直接执行的机器码。翻译有成本,所以 CLR 会缓存结果------同一个方法只 JIT 一次,后续直接复用机器码。JIT 相比 AOT(提前编译)最大的优势是能拿到运行时信息:哪些代码是热点、当前是 x64 还是 ARM64、机器几个核,据此做针对性优化。后文讲的「Fast Path 内联」「锁膨胀自适应自旋」都是 JIT 在用这些信息做决策。
lock语句在底层会被编译成对Monitor.Enter/Monitor.Exit的调用,而Monitor正是 CLR 提供的同步原语。所以讨论lock的性能与行为,本质就是在讨论 CLR 怎么实现Monitor。
1.2 现象:同样的 lock,性能差 10 倍
很多开发者以为 lock 就是一个固定开销的操作。实际上,CLR 对 Monitor.Enter 做了三级状态机优化,不同竞争程度下性能差异巨大:
text
无锁 (Fast Path) → 薄锁 Thin Lock → 厚锁 Heavy Lock
↑ ↑ ↑
无竞争访问 少量短暂竞争 多线程激烈竞争
~2ns ~20-50ns(自旋) ~200ns+(含内核切换与挂起)
1.3 原理:对象头与锁状态机
每个托管对象都有一个机器字大小(32/64 位)的对象头(Object Header)。CLR 利用对象头中的同步块索引(SyncBlock Index)记录锁状态:
| 状态 | 对象头存什么 | 等待线程如何处理 |
|---|---|---|
| 无锁 | 不含锁信息(或普通同步位) | Fast Path:一次 CAS 即可拿到锁 |
| 薄锁 Thin Lock | 直接在对象头中记录持有线程 ID + 递归计数 | 用户态自旋重试,不挂起线程 |
| 厚锁 Heavy Lock | 指向 SyncBlock 表项(内含内核事件句柄、递归计数、持有者) | 在内核事件上挂起等待 |
升级触发条件:
- 无锁 → 薄锁:发生竞争时,通过 CAS 争抢对象头,抢不到的线程开始自旋。
- 薄锁 → 厚锁:自旋达到阈值或发生上下文切换时,CLR 执行锁膨胀(Inflation),分配 SyncBlock 并创建内核事件,等待线程被挂起。
降级机制:GC 期间会扫描 SyncBlock 表,若发现厚锁长期无竞争,会执行锁收缩(Deflation),释放 SyncBlock 回到薄锁/无锁状态。
说明:.NET CLR 没有「偏向锁」。偏向锁(Biased Locking)是 HotSpot JVM 的概念,很多文章把 JVM 的四态模型(无锁 → 偏向 → 轻量 → 重量)套到 .NET 上,这是错误的。CLR 只有薄锁、厚锁两级(外加无锁的 Fast Path)。
1.4 JIT 与 CLR 的相关优化
① Fast Path 内联
Monitor.Enter 的快速路径(无锁/薄锁状态)被 JIT 直接内联到调用点,只有走到慢路径(需要膨胀、挂起)时才真正调用运行时辅助函数。这就是无竞争时 lock 只要约 2ns 的原因:
csharp
// 无竞争时,底层大致等价于:
// 一条 CAS 指令操作对象头 → 成功就直接进入临界区
lock (_lock)
{
_x = 1;
}
② 锁膨胀的自适应自旋
CLR 根据自旋是否等到锁、当前核心数、是否发生上下文切换等动态决定自旋次数,而不是写死的固定循环。多核短临界区下自旋收益高,单核心或长临界区下更快挂起。
③ .NET 9 专用 Lock 类型
.NET 9 / C# 13 新增了 System.Threading.Lock 类型,专为 lock 语句优化,对象头路径更短:
csharp
private readonly Lock _lock = new();
public void Update()
{
lock (_lock) // C# 13 起 lock 语句原生支持 Lock 类型
{
// ...
}
}
注意:JIT 不会帮你「消除」锁。 网上流传的「相邻两个 lock 自动合并」「逃逸分析后删除未跨方法的 lock」都是 HotSpot JVM 的优化(锁粗化、锁消除),CoreCLR 并不做这类优化。不要指望 JIT 删掉多余的锁------无意义的
lock要自己删。
1.5 避坑要点
- 不要频繁创建短生命周期的锁对象:每个新对象都从无锁状态开始,无法受益于热路径优化。
- 避免在热路径混用不同同步原语:不同机制的等待队列不互通,还会打断 CPU 缓存的局部性。
- 用工具确认锁状态:PerfView 的 Contention 事件可以观察实际发生了多少次竞争、线程被挂起了多久。
二、CAS 与自旋退避策略
2.1 现象:无锁反而比有锁慢
使用 Interlocked.CompareExchange 实现无锁队列,在高竞争下吞吐量暴跌,甚至不如普通 lock。
2.2 原理:CAS 竞争的总线风暴
CAS 本质是一条 CPU 指令(x86:LOCK CMPXCHG),但失败时需要重新读取内存并重试。当 N 个线程同时 CAS 同一个变量:
- 每次失败都会触发缓存行失效协议(MESI),其他核心的缓存行被 invalidate;
- 重试间隔为零时,N 个线程以全速冲击同一缓存行,形成总线带宽瓶颈;
- 参考量级:8 核机器上 8 线程零退避 CAS,有效吞吐可能只剩理论值的 5%~10%。
2.3 SpinWait 自适应退避
.NET 提供的 SpinWait 不是简单的 Thread.Sleep,而是一个三阶段自适应退避器(以下为简化示意):
csharp
// 简化版内部逻辑
public struct SpinWait
{
private int _count;
public void SpinOnce()
{
if (_count < 10)
{
// 阶段1:纯 PAUSE/YIELD 指令(约前 10 次)
Thread.SpinWait(4 << _count); // 指数增长的 PAUSE 循环
}
else if (_count < 20)
{
// 阶段2:Thread.Yield(),让出给同优先级线程
Thread.Yield();
}
else
{
// 阶段3:Thread.Sleep(0)/Sleep(1),主动放弃时间片
if ((_count & 1) == 0)
Thread.Sleep(0);
else
Thread.Sleep(1);
}
_count++;
}
}
关键设计意图:
| 阶段 | 假设 | 策略 |
|---|---|---|
| 阶段1 | 锁持有时间极短(纳秒级) | CPU 空转等待,避免上下文切换 |
| 阶段2 | 持锁时间稍长 | 让出 CPU 给同优先级线程,降低调度压力 |
| 阶段3 | 竞争已激烈 | 主动放弃时间片,防止活锁 |
2.4 错误退避 vs 正确退避
csharp
// 错误1:零退避死循环,制造总线风暴
while (Interlocked.CompareExchange(ref _flag, 1, 0) != 0) { }
// 错误2:固定 Sleep,最小粒度 1ms,远超多数锁持有时间
while (Interlocked.CompareExchange(ref _flag, 1, 0) != 0)
{
Thread.Sleep(1);
}
// 正确1:SpinWait 自适应退避
var spinner = new SpinWait();
while (Interlocked.CompareExchange(ref _flag, 1, 0) != 0)
{
spinner.SpinOnce();
}
// 正确2:再加超时保护,避免逻辑 bug 导致永久自旋
var spinner = new SpinWait();
var sw = Stopwatch.StartNew();
while (Interlocked.CompareExchange(ref _flag, 1, 0) != 0)
{
if (sw.ElapsedMilliseconds > 100)
throw new TimeoutException("获取锁超时");
spinner.SpinOnce();
}
2.5 总结
- 永远不要在 CAS 循环中使用空 body 或固定
Sleep(1)。 SpinWait是值类型,不要装箱,不要跨方法复制传递。- 预期竞争极低时用 CAS + SpinWait;预期竞争持续偏高时直接用
lock------CAS 的优势只在低竞争区间。
三、锁车队效应与公平性取舍
3.1 现象:线程越多,吞吐反而越低
8 核机器上 4 线程跑 100 万次 lock 操作耗时 1 秒;加到 32 线程,耗时不是 1 秒而是 8 秒------线性扩展彻底失效。但临界区只有几行赋值,理论上不该这么慢。
3.2 原理:锁车队(Lock Convoy)怎么形成
当多个线程高频争抢同一把锁,它们会排成一条长队,一个挨一个通过临界区,这种现象叫锁车队。它带来三重叠加开销:
- 持锁线程被抢占:OS 调度把正在持锁的线程切走(时间片耗尽、中断、优先级抢占),整条车队都得等它被重新调度回来。锁的「实际持有时间」被拉长到时间片级别(毫秒),而临界区本该是纳秒级。
- 上下文切换雪崩:车队中每个线程拿到锁后立刻又被切走,CPU 大量时间花在切换而不是干活。
- 缓存行失效放大:锁对象所在缓存行在多个核心间反复漂移,每次传递都触发 MESI 的 invalidate/transfer。
关键认知:锁车队的罪魁往往不是临界区代码慢,而是持锁线程被 OS 抢占。线程数越多,任一时刻持锁线程被抢占的概率越高,车队停滞越频繁。
3.3 公平性 vs 吞吐的取舍
锁的公平性指线程拿锁的顺序是否按到达顺序(FIFO)。两种策略各有代价:
| 策略 | 行为 | 优点 | 代价 |
|---|---|---|---|
| 公平锁(FIFO) | 新线程必须排在队尾,不能插队 | 防饥饿,延迟可预测 | 车队严重:持锁线程被切走时,即使有空闲核心也不能 barging 上去 |
| 非公平锁(barging) | 锁释放瞬间,任何线程都能抢 | 吞吐高,能利用空闲核心 | 可能饥饿,长尾延迟高 |
Monitor(即 lock 语句)默认是非公平的------这正是为了缓解车队效应。CLR 在释放锁时不会严格按 FIFO 唤醒,允许正在 CPU 上跑的线程 barging 抢锁,避免「持锁线程被切走 → 车队停滞」。
遗憾的是 .NET 没有提供「公平
lock」的开关。如果业务必须严格公平,只能用SemaphoreSlim配合自实现 FIFO 队列,或上ReaderWriterLockSlim(它的排队接近 FIFO 但不保证严格)。代价就是吞吐下降,要做基准测试再决定。
3.4 缓解策略
① 缩短临界区,锁内不阻塞
csharp
// 错误:锁内做 IO,持锁时间被拉到秒级
lock (_lock)
{
var data = File.ReadAllText(_path); // 千万别
_cache = data;
}
// 正确:锁外读,锁内只赋值
var data = File.ReadAllText(_path);
lock (_lock) { _cache = data; }
② 分片锁(Striped Lock)分散热点
把一把大锁拆成 N 把小锁,按 key 哈希分流,车队就被拆成 N 条短队:
csharp
public class StripedCounter
{
private readonly object[] _stripes;
private readonly int[] _counts;
public StripedCounter(int stripeCount)
{
_stripes = Enumerable.Range(0, stripeCount).Select(_ => new object()).ToArray();
_counts = new int[stripeCount];
}
public void Increment(int key)
{
int idx = (key & 0x7FFFFFFF) % _stripes.Length;
lock (_stripes[idx]) { _counts[idx]++; }
}
}
分片数一般取 ProcessorCount * 4 起步,根据竞争强度调优。
③ .NET 9 Lock 类的改进
新的 System.Threading.Lock 缩短了对象头路径,无竞争场景开销更低,间接缓解车队------但不能根治,车队本质还是「持锁被抢占」。
3.5 总结
- 高频热锁 + 多线程 = 必然车队,先用分片锁拆热点。
- 锁内禁止任何阻塞调用(IO、
Thread.Sleep、Task.Wait)。 - 不要追求「公平锁」除非业务真的需要,吞吐代价远超想象。
四、内存屏障体系:volatile / Interlocked / 显式 API
4.1 现象:双重检查锁定偶尔拿到未构造对象
不加任何屏障的双检锁(DCL)在弱内存模型架构上不安全:
csharp
// 错误示范:_instance 没有 volatile 也没有 Interlocked
if (_instance == null)
{
lock (_lock)
{
if (_instance == null)
_instance = new ExpensiveObject();
}
}
return _instance;
4.2 为什么需要内存屏障
先快速回顾三个并发可见性问题(第一篇有详解,这里只点关键):
- 可见性:A 线程写了变量,B 线程可能读到旧值------因为每个 CPU 核心有自己的缓存(L1/L2),写操作可能还没同步到主存。
- 有序性 :编译器和 CPU 为了性能会把指令重排序 ,源码里
a=1; b=2;实际执行可能是b=2; a=1;。单线程下没区别,多线程下会要命。 - 原子性 :
i++看起来一条语句,实际是「读-改-写」三步,中间可能被打断。
内存屏障(Memory Barrier) 就是告诉 CPU/编译器「屏障前的操作不能和屏障后的操作乱序跨越,且屏障前的写必须对其他线程可见」。不同 API 提供不同强度的屏障。
4.3 原理:屏障强度决定发布安全性
| 操作 | 提供的屏障 | 阻止的重排 |
|---|---|---|
volatile 读 |
LoadLoad + LoadStore(Acquire 语义) | 之后的读/写不能提前到此读之前 |
volatile 写 |
LoadStore + StoreStore(Release 语义) | 之前的读/写不能延后到此写之后 |
Interlocked.* |
全屏障 Full Fence(含 StoreLoad) | 前后所有读写均不可跨越 |
Thread.MemoryBarrier() |
全屏障 Full Fence | 同上,显式插入 |
Interlocked.MemoryBarrier() |
全屏障 Full Fence | 同上,.NET Core 3.0+ 推荐使用 |
_instance = new ExpensiveObject() 包含「①分配内存并执行构造函数 → ②发布引用」两步。没有任何屏障时,②可能重排到①完成之前,另一个线程看到非 null 但字段尚未构造完的对象。这在 x86/x64(强内存模型)上几乎不发生,在 ARM64(Apple Silicon、Azure ARM VM)上是真实可复现的 bug。
澄清一个常见误传:给字段加上
volatile后,DCL 在 .NET 中就是官方安全写法 。CLR 在所有平台上实际实现的内存模型强于 ECMA-335 规范,volatile写的 Release 语义足以保证对象先构造、后发布。真正不安全的是「连 volatile 都不加」的裸 DCL。
4.4 三种正确写法
csharp
// 方案1:volatile 字段 + DCL(官方推荐的标准写法)
private static volatile ExpensiveObject? _instance;
private static readonly object _lock = new();
public static ExpensiveObject Instance
{
get
{
if (_instance is null)
{
lock (_lock)
{
_instance ??= new ExpensiveObject();
}
}
return _instance;
}
}
// 方案2:Lazy<T>(最省心,无需手写任何锁)
private static readonly Lazy<ExpensiveObject> _lazy =
new(() => new ExpensiveObject(), LazyThreadSafetyMode.ExecutionAndPublication);
public static ExpensiveObject GetInstance() => _lazy.Value;
// 方案3:Interlocked.CompareExchange(全屏障,跨运行时最强保证)
public static ExpensiveObject GetInstanceStrong()
{
if (_instance is null)
{
lock (_lock)
{
Interlocked.CompareExchange(ref _instance, new ExpensiveObject(), null);
// 注意:构造函数可能被多个线程各执行一次,只有一个实例留下
// 工厂必须无副作用,否则改用 Lazy<T>
}
}
return _instance;
}
4.5 volatile 的正确使用场景
volatile 并非无用,它适合标志位通信这类不需要全屏障的场景:
csharp
private volatile bool _stopRequested;
// 生产者/控制线程
_stopRequested = true; // volatile 写:保证之前的数据写入对其他线程可见
// 消费者工作线程
while (!_stopRequested) // volatile 读:保证每次读到最新值
{
ProcessItem();
}
4.6 显式内存屏障 API
除 volatile 和 Interlocked,.NET 还提供两个显式屏障方法,了解它们有助于读懂框架源码,但日常业务几乎用不到:
csharp
// Thread.MemoryBarrier():全屏障,阻止前后读写重排
// Interlocked.MemoryBarrier():同上,.NET Core 3.0+ 推荐(更明确属于原子操作语义)
private int _data;
private volatile bool _ready;
void Producer()
{
_data = 42;
Interlocked.MemoryBarrier(); // 确保 _data 的写在 _ready=true 之前对其他线程可见
_ready = true;
}
void Consumer()
{
if (_ready)
{
Interlocked.MemoryBarrier(); // 双保险,确保读到 _ready=true 后能看到 _data 的写
Console.WriteLine(_data); // 此处必为 42
}
}
何时真正需要显式屏障:
| 场景 | 是否需要显式屏障 |
|---|---|
用 volatile 字段做标志位 |
否,volatile 已带屏障 |
用 Interlocked.* 做原子操作 |
否,Interlocked 自带全屏障 |
在 lock 临界区内 |
否,Monitor.Enter/Exit 已含全屏障 |
| 无锁数据结构手写发布协议 | 是,此时没有任何原语兜底 |
黄金法则:99% 的业务代码用
volatile+Interlocked+lock三件套就够了 ,不要轻易手写显式屏障。一旦开始用Thread.MemoryBarrier(),意味着你在自己实现无锁算法------这通常是个错误信号,改用Channel<T>或ConcurrentDictionary更安全。
4.7 总结
- 标志位、状态标记 →
volatile - 复合操作(
i++)、发布对象、计数器 →Interlocked - 一次性初始化 →
Lazy<T> - 多字段组合逻辑 →
lock - 手写无锁发布协议 → 才考虑显式
MemoryBarrier - 不确定时 → 往强的选,宁可过度安全
五、死锁的两种形态
死锁是并发最经典的灾难。.NET 里有两种形态完全不同的死锁,诊断思路和解决方案也不同,必须分开识别。
5.1 形态一:传统锁顺序死锁
5.1.1 现象:两个线程都拿不到对方的锁
csharp
// 线程 A:先拿锁1再拿锁2
lock (_lock1) { lock (_lock2) { DoA(); } }
// 线程 B:先拿锁2再拿锁1
lock (_lock2) { lock (_lock1) { DoB(); } }
A 持有锁1等锁2,B 持有锁2等锁1,永远等下去。这是教科书级的死锁,但 .NET 生态文章常忽略它(因为大家都去讲异步死锁了),新手却最常踩。
5.1.2 原理:死锁四必要条件
死锁成立必须同时满足四个条件,缺一不可------记住这四条,所有死锁解决方案本质都是打破其中一条:
| 条件 | 含义 | 打破方法 |
|---|---|---|
| ① 互斥(Mutual Exclusion) | 资源同一时刻只能被一个线程持有 | 不可打破(锁的本质) |
| ② 持有并等待(Hold and Wait) | 持有资源的线程还可以申请新资源 | 一次性获取所有锁,要么全拿到要么全不拿 |
| ③ 不可剥夺(No Preemption) | 资源不能被强制夺走,只能自愿释放 | 用 TryEnter 超时,到点自动放弃 |
| ④ 循环等待(Circular Wait) | 等待关系形成环 | 统一锁顺序,让等待图变成有向无环 |
5.1.3 解决方案一:统一锁顺序(打破循环等待)
最经典也最有效。给所有锁定一个全局顺序,所有线程都按同一顺序获取:
csharp
// 约定:所有地方都先拿 _lock1 再拿 _lock2,永不反序
private static readonly object _lock1 = new();
private static readonly object _lock2 = new();
void TransferA()
{
lock (_lock1) { lock (_lock2) { /* ... */ } }
}
void TransferB()
{
lock (_lock1) { lock (_lock2) { /* ... */ } } // 也按 1→2 顺序
}
工程实践要点:
- 锁顺序通常按对象的某个自然标识排序(如账户 ID 升序)。
- 跨业务模块约定锁顺序时,写进文档,靠人肉约定易腐化。
- 静态分析工具(如 Roslyn analyzer)可以检测锁顺序违规,建议接入。
5.1.4 解决方案二:TryEnter 超时兜底(打破不可剥夺)
当无法保证统一顺序(锁来自不同模块、第三方库),用 Monitor.TryEnter 加超时,到点放弃已持有的锁重试:
csharp
bool LockBoth(int timeoutMs)
{
bool got1 = false, got2 = false;
try
{
got1 = Monitor.TryEnter(_lock1, timeoutMs);
if (!got1) return false;
got2 = Monitor.TryEnter(_lock2, timeoutMs);
if (!got2) return false;
DoWork();
return true;
}
finally
{
if (got2) Monitor.Exit(_lock2);
if (got1) Monitor.Exit(_lock1);
}
}
// 调用:失败则退避重试,避免永久死锁
while (!LockBoth(1000))
{
Thread.SpinWait(100);
if (++retries > 10) throw new InvalidOperationException("获取锁超时");
}
.NET 9 的 Lock 类型也支持 Enter(Span<LockCookie>) 配合超时。原则一致:任何可能死锁的获取都加超时。
注意:
TryEnter是「退路」不是「常态」。超时回退意味着业务降级或重试,要先想清楚失败路径。真正健壮的系统仍应以统一锁顺序为主。
5.2 形态二:异步死锁(sync-over-async)
5.2.1 现象:async 方法调用后永久挂起
csharp
// 类库代码
public async Task LoadDataAsync()
{
await _semaphore.WaitAsync();
try
{
var data = await FetchFromDbAsync(); // 挂起在这里,永不返回
_cache.Update(data);
}
finally { _semaphore.Release(); }
}
// 调用方(UI / WCF / ASP.NET Classic)
button_Click(...)
{
LoadDataAsync().Wait(); // 同步阻塞等待异步方法
}
5.2.2 原理:SynchronizationContext 捕获
这不是「两个线程互相等待」的传统死锁,而是单线程上下文耗尽导致的逻辑死锁:
- UI / ASP.NET Classic 的
SynchronizationContext是单线程的; await默认捕获当前上下文,续体(continuation)被 Post 回该上下文执行;.Wait()阻塞了这个唯一线程;- 续体无法调度 →
await永不完成 →.Wait()永不返回 → 死锁。
ASP.NET Core 和控制台程序没有 SynchronizationContext,不会出现这个死锁;但类库代码必须假设调用方可能存在 SC。
顺带说明为什么 lock 不能配合 await:Monitor 是线程亲和 的------Enter 和 Exit 必须是同一个线程,而 await 之后续体可能跑在另一个线程上。所以编译器直接禁止在 lock 块里 await,异步互斥请用 SemaphoreSlim。
5.2.3 解决方案与替代模式
方案1:ConfigureAwait(false)(类库代码必选)
csharp
await _semaphore.WaitAsync().ConfigureAwait(false);
var data = await FetchFromDbAsync().ConfigureAwait(false);
不捕获 SC,续体在线程池线程上执行,不受调用方上下文影响。
方案2:全链路 async(应用代码首选)
csharp
// 调用方也改为 async,不阻塞任何线程
private async void button_Click(...)
{
await LoadDataAsync();
}
方案3:Task.Run 隔离(遗留代码应急)
csharp
// 丢到线程池执行,脱离当前 SC
await Task.Run(() => LoadDataAsync()).ConfigureAwait(false);
方案4:用 Channel 替代异步锁做串行化
需要的不是「互斥」而是「串行处理」时,直接用通道队列:
csharp
private readonly Channel<Func<Task>> _workQueue =
Channel.CreateBounded<Func<Task>>(1);
public async Task ExecuteSerialAsync(Func<Task> work)
{
await _workQueue.Writer.WriteAsync(work);
}
// 后台单消费者,天然串行、无锁
private async Task ProcessLoopAsync(CancellationToken ct)
{
await foreach (var work in _workQueue.Reader.ReadAllAsync(ct))
{
await work();
}
}
方案5:封装 AsyncLock(需要互斥语义时的标准模式)
csharp
public sealed class AsyncLock : IDisposable
{
private readonly SemaphoreSlim _semaphore = new(1, 1);
public async Task<IDisposable> LockAsync(CancellationToken ct = default)
{
await _semaphore.WaitAsync(ct).ConfigureAwait(false);
return new Releaser(_semaphore);
}
private readonly struct Releaser : IDisposable
{
private readonly SemaphoreSlim _s;
public Releaser(SemaphoreSlim s) => _s = s;
public void Dispose() => _s.Release();
}
public void Dispose() => _semaphore.Dispose();
}
// 用法
await using var scope = await _asyncLock.LockAsync(ct);
// 临界区...
5.2.4 异步锁专属注意事项
| 陷阱 | 说明 |
|---|---|
| SemaphoreSlim 不支持重入 | 同一线程连续两次 WaitAsync 不释放,会把自己阻塞死 |
| 只有获取成功才能 Release | WaitAsync 超时或被取消时抛异常,不能再 Release,否则计数错乱 |
| Release 必须在 finally 中 | 异常路径漏掉 Release,信号量永久泄漏 |
| 持锁期间不要调用同步阻塞 API | 同步阻塞抵消异步优势,还可能触发 5.2 的 SC 死锁 |
| 善用 CancellationToken | WaitAsync(ct) 让异步锁可被取消,防止关闭流程永久挂起 |
5.3 两种死锁的快速鉴别
| 鉴别点 | 锁顺序死锁 | 异步死锁 |
|---|---|---|
| 线程数 | 至少 2 个工作线程 | 通常 1 个 UI/请求线程 |
| 等待对象 | 持有对方的锁 | 等自己的续体被调度 |
| 触发场景 | 多把锁反序获取 | sync-over-async(.Wait() 异步方法) |
| 调试器表现 | 多线程都卡在 Monitor.Enter |
单线程卡在 .Wait(),无可用工作线程 |
| 根本解法 | 统一锁顺序 + TryEnter | ConfigureAwait(false) / 全链路 async |
六、GC 暂停与线程池饥饿
锁性能的两个隐形敌人:一个冻结所有线程(GC 暂停),一个耗尽所有可用线程(线程池饥饿)。两者都看不见摸不着,但能把锁的实际延迟放大几个数量级。
6.1 GC 对锁对象的影响
6.1.1 现象:莫名其妙的死锁 / 锁突然变慢
程序运行一段时间后,原本正常的锁出现超长等待,或者终结器线程卡死导致整个进程挂起。
6.1.2 问题一:终结器线程死锁
csharp
// 危险:在析构函数(终结器)中获取锁
~MyResource()
{
lock (_globalLock) // 终结器线程尝试获取锁
{
Cleanup();
}
}
原理:GC 的终结器线程是单线程 的。如果某个对象的 Finalize 阻塞在锁上,而持锁线程又在等待 GC 完成(触发了 GC.Collect 或 LOH 压缩),就形成终结器线程与工作线程的死锁。终结器一卡,所有待终结对象都无法释放,最终内存泄漏。
正确做法:
csharp
// 1. 终结器中绝不获取任何锁,只做非托管资源释放,且不调用任何可能阻塞的方法
~MyResource()
{
NativeMethods.Free(_handle);
}
// 2. 优先用 SafeHandle 替代手写 Finalize,框架已处理好终结安全
private readonly SafeFileHandle _handle;
6.1.3 问题二:锁对象本身被移动或回收
锁对象是普通托管对象,同样受 GC 影响:
- 永远不要用可能被外部持有的对象当锁(
this、typeof(T)、字符串),第一篇已讲; - 锁对象应该是小的、专用的、长生命周期的对象,永远在小对象堆(SOH)上,不涉及大对象堆(LOH)碎片问题;
- .NET 9 可用专用
Lock类型,为锁场景精简了布局:
csharp
// 推荐:小对象 + 专用 + 长生命周期
private readonly object _lock = new();
// .NET 9+:专用锁类型
private readonly Lock _lock9 = new();
// 按需启用 LOH 压缩(.NET Core 3.0+),缓解大对象碎片
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
6.1.4 问题三:GC 暂停延长锁持有时间
GC 暂停期间所有托管线程都会被冻结,包括正在持有锁的线程:
text
锁的实际持有时间 = 临界区代码执行时间 + GC 暂停时间
即使临界区只需 1μs,若期间发生 200ms 的 GC 暂停,其他等待线程也要等 200ms 以上。
缓解策略:
- 减少 LOH 分配,降低 GC 暂停的频率和时长;
- 关键临界区前可用
GC.TryStartNoGCRegion临时抑制 GC(需谨慎,区域内分配过多会抛异常); - 分析锁性能数据时,把 GC 暂停指标作为上下文一起看。
6.2 线程池饥饿
6.2.1 现象:请求雪崩,延迟突然飙升
ASP.NET(经典)或 Task.Run 密集的服务在流量上来后,响应延迟从 50ms 飙到 30s+,CPU 却不忙、内存也不爆------典型的线程池饥饿。
6.2.2 原理:线程池注入速度太慢
.NET 线程池有个「缓慢注入」机制:默认每秒只新增约 1 个工作线程(避免瞬时打鸡血造成更多竞争),直到达到 MaxThreads(通常 32767)。当请求里大量是同步阻塞 调用(.Result、.Wait()、阻塞 lock 后做 IO),线程被卡住,新请求排队,而线程池又不肯快速扩容,雪崩就开始:
text
请求QPS↑ → 线程被同步调用卡住 → 队列堆积 → 线程池每秒只加1线程 → 堆积速度远超扩容 → 饥饿
与锁的关联 :锁本身不阻塞时极快(2ns),但锁内做阻塞 IO 会把宝贵的线程池线程钉死在 Monitor.Enter 的等待队列里------这正是「锁车队 + 线程池饥饿」联手的灾难现场:
csharp
// 危险组合:锁内同步阻塞 IO,线程池线程被钉死
public string GetData(int id)
{
lock (_lock)
{
return _httpClient.GetStringAsync($"/api/{id}").Result; // 双重罪
}
}
线程池线程 A 拿到锁,开始同步等 HTTP;线程 B~N 想拿锁,全部在 Monitor.Enter 上排队阻塞;线程池想加线程来处理新请求,但每秒只加 1 个,根本追不上。
6.2.3 缓解与解决
① 根治:异步化,线程池线程不阻塞
csharp
public async Task<string> GetDataAsync(int id, CancellationToken ct)
{
await _semaphore.WaitAsync(ct).ConfigureAwait(false);
try
{
return await _httpClient.GetStringAsync($"/api/{id}", ct).ConfigureAwait(false);
}
finally { _semaphore.Release(); }
}
异步路径里线程池线程在 await 时被释放回去处理别的请求,吞吐量级提升。
② 缩短锁持有时间:用 .NET 9 Lock + 锁内只做内存操作
把 IO 移出锁外,锁内只做赋值/缓存更新(纳秒级),即使排队也排得快。
③ 临时调高线程池下限(应急,非根治)
csharp
// 启动时配置,让线程池敢于更快扩容
ThreadPool.GetMinThreads(out int wt, out int _);
ThreadPool.SetMinThreads(Math.Max(wt, 200), Math.Max(wt, 200));
注意:这只是给「同步阻塞代码」买时间,根因还是要异步化。调太高会让上下文切换成本反噬吞吐。
④ 监控线程池健康
csharp
ThreadPool.GetAvailableThreads(out int worker, out int io);
ThreadPool.GetMaxThreads(out int maxWorker, out _);
if (worker < maxWorker * 0.1)
_logger.Warn("线程池接近耗尽: 可用={Worker}", worker);
或接 ETW 的 System.Threading.ThreadPool 事件做告警。
6.3 总结
- 终结器 /
SafeHandle.ReleaseHandle中禁止获取任何锁。 - 锁对象必须小型、专用、长生命周期。
- 不要在锁内分配大对象,不要在锁内做任何阻塞 IO。
- 同步阻塞代码上量前必须异步化,否则线程池饥饿只是时间问题。
- 关注 GC 暂停与线程池可用线程两项指标,把它们纳入锁性能分析的上下文。
七、读写锁写饥饿与不可变替代模式
读多写少场景下,读写锁看似理想,但写饥饿是个深坑。更激进的做法是干脆不上锁,用不可变对象 + 原子替换。
7.1 现象:写入永远排不上队
读多写少场景下使用读写锁,结果写入操作等待数秒甚至超时,而读取从未中断。
7.2 原理:写饥饿从哪来
先澄清两代读写锁的差异:
- 老的
ReaderWriterLock(不要用) :真正的读者优先------只要有读者持锁,新读者就可以持续进入,写者可能被无限推迟,且性能差、递归语义混乱。新代码应一律使用ReaderWriterLockSlim。 ReaderWriterLockSlim:当已有写者在队列中等待时,新到达的读者请求会被阻塞,写者不会被新读者无限插队,饥饿问题已大幅缓解。
但写延迟在以下情况仍然存在:
- 长持有读者:写者必须等所有「已经在里面」的读者退出。若单个读锁持有数秒,写者就要等数秒。
- 读者流不间断:读者不断到达且每个都恰好排在「写者排队」之前,写者的等待被不断推迟。
- 升级锁误用 :
EnterUpgradeableReadLock本身排他(同时只允许一个),它不阻止普通读锁进入,但会堵住后续写者,形成夹逼。
7.3 三种工程解法
方案A:NoRecursion + 缩短读临界区
csharp
// 构造时显式指定不支持递归
private readonly ReaderWriterLockSlim _rwLock =
new(LockRecursionPolicy.NoRecursion);
注意:ReaderWriterLockSlim 没有显式的「公平模式」开关。它的排队行为接近 FIFO,但不做严格保证。需要严格公平时请用方案 B。
方案B:手动限流读者(推荐)
csharp
public class FairReadWriteLock
{
private readonly ReaderWriterLockSlim _rwLock = new();
private readonly SemaphoreSlim _readerGate = new(10, 10); // 最多 10 个并发读者
public void Read(Action action)
{
_readerGate.Wait(); // 读者过多时阻塞新读者,给写者留出窗口
try
{
_rwLock.EnterReadLock();
try { action(); }
finally { _rwLock.ExitReadLock(); }
}
finally { _readerGate.Release(); }
}
public void Write(Action action)
{
_rwLock.EnterWriteLock();
try { action(); }
finally { _rwLock.ExitWriteLock(); }
}
}
核心思想:限制并发读者数量,写者面对的不再是无限读者队列。容量按业务读写比例调整;异步场景把 Wait() 换成 WaitAsync()。
方案C:乐观读模式(读占比 95% 以上时)
.NET 没有 Java 的 StampedLock,但可以用版本号模拟乐观读。注意写者之间仍然必须互斥,版本号只负责免锁读:
csharp
public class OptimisticReadWriteCache<T>
{
private T _value = default!;
private int _version; // 偶数=稳定可读,奇数=写入中
private readonly object _writeLock = new(); // 写者之间互斥
public bool TryRead(out T value)
{
int v1 = Volatile.Read(ref _version);
if ((v1 & 1) != 0) { value = default!; return false; } // 写入中,本次读失败
T snapshot = Volatile.Read(ref _value); // 读取数据快照
int v2 = Volatile.Read(ref _version);
if (v1 != v2 || (v2 & 1) != 0)
{
value = default!;
return false; // 期间发生过写入,重试
}
value = snapshot;
return true;
}
public void Write(T newValue)
{
lock (_writeLock)
{
Interlocked.Increment(ref _version); // 变奇数:宣告写入中
Volatile.Write(ref _value, newValue);
Interlocked.Increment(ref _version); // 变偶数:宣告稳定
}
}
}
读路径完全无锁,仅在版本冲突时由调用方重试;写路径加锁保证串行化。
7.4 不可变对象 + 引用原子替换(Copy-on-Write)
当「写极少、读极多、读需要看到一组一致字段」时,读写锁仍是负担。更优模式是把整个状态做成不可变对象,写时构造新版本,引用原子替换:
7.4.1 原理:引用赋值是原子的
CLR 规范保证:托管对象引用的赋值是原子的(在 32/64 位机器上都是机器字一次写入)。这意味着读者只要拿到引用,就拿到了完整一致的对象快照,不存在「拿到半构造对象」的风险。
csharp
public sealed class ImmutableConfig
{
public string Endpoint { get; }
public int Timeout { get; }
public ImmutableConfig(string endpoint, int timeout)
{
Endpoint = endpoint;
Timeout = timeout;
}
}
public class ConfigStore
{
// volatile 保证写的可见性,引用赋值本身已原子
private volatile ImmutableConfig _current = new("default", 30);
public ImmutableConfig Current => _current;
public void Update(string endpoint, int timeout)
{
// 构造完整新对象,再原子替换引用
_current = new ImmutableConfig(endpoint, timeout);
}
}
读者直接 store.Current.Endpoint,零锁、零等待、零版本重试。写者构造新对象有成本,但写极少,摊销下来远比读写锁便宜。
7.4.2 与读写锁的对比
| 维度 | 读写锁 | 不可变 + COW |
|---|---|---|
| 读延迟 | 10~50ns(获取/释放读锁) | 0(直接读字段) |
| 写延迟 | 拿写锁的时间 | 构造新对象的时间 |
| 一致性 | 单字段一致 | 跨字段一致快照 |
| 内存 | 复用一个对象 | 每次写产生新对象(GC 压力) |
| 适用场景 | 读写比 10:1 ~ 100:1 | 写频率 < 1 次/秒,读极高频 |
7.4.3 注意事项
- 字段必须真的不可变 :所有字段
readonly、属性只有 getter,否则原子性假设崩塌。 - 大对象 COW 成本高 :每次写要拷贝整个结构,适合小配置对象,不适合大集合。大集合改用
ImmutableArray<T>/ImmutableDictionary<K,V>,它们用结构共享减少拷贝。 - 写并发仍需加锁 :多个写者同时替换引用不会破坏原子性,但会丢失更新。写路径加
lock或Interlocked.CompareExchange配合重试。
csharp
// 写者并发安全版:CAS 重试,避免丢更新
public void Update(string endpoint, int timeout)
{
var updated = new ImmutableConfig(endpoint, timeout);
var original = _current;
while (Interlocked.CompareExchange(ref _current, updated, original) != original)
{
original = _current; // 被别人抢先了,基于最新值重试
}
}
7.5 选型建议
| 场景 | 推荐方案 |
|---|---|
| 读写比 10:1 以下 | 直接用 lock,读写锁的开销不值得 |
| 读写比 10:1 ~ 100:1 | 限流读者(方案 B) |
| 读写比 > 100:1 | 乐观读(方案 C) |
| 写极少(<1/s)+ 读极高频 + 跨字段一致 | 不可变 + COW |
| 需要严格公平 | 限流读者 + 写者优先信号量 |
八、伪共享:缓存行级性能杀手
8.1 现象:两个独立计数器,多线程下慢得离谱
两个独立计数器 _countA 和 _countB,分别只被一个线程高频更新,逻辑上互不相关。单线程跑 1 亿次耗时 50ms,两个线程分别更新各自计数器,本以为应该和单线程差不多,结果耗时 500ms------慢了 10 倍。
8.2 原理:缓存行与 MESI
CPU 缓存的最小单位不是字节,而是缓存行(Cache Line) ,x86/x64/ARM 主流为 64 字节。当 CPU 读一个字节,会把整个 64 字节的块一起拉到 L1。
MESI 协议保证缓存一致性:当一个核心写某字节,整条缓存行在所有其他核心的 L1 里都被标记为 Invalid。于是:
text
字段布局:[ _countA (4B) | _countB (4B) | <padding> ] ← 同一 64 字节缓存行
核心1 高频写 _countA:
→ 整条缓存行在核心2 的 L1 失效
核心2 高频写 _countB:
→ 整条缓存行在核心1 的 L1 失效
→ 核心2 重新拉缓存行(含 _countA,虽然它根本不读)
→ 核心1 再写 _countA,核心2 又失效......
两个独立变量物理上相邻,就互相把对方的缓存踢来踢去,称为伪共享(False Sharing)。开销是缓存行级别的来回 invalidate/transfer,比 CAS 总线风暴更隐蔽------因为代码看起来毫无关联。
8.3 解决:缓存行对齐填充
把可能被不同核心高频写的字段隔开到不同缓存行。.NET 用 [StructLayout] + 显式 padding,或更直接的 Volatile/Interlocked + [StructLayout(LayoutKind.Explicit, Size = ...)]:
8.3.1 错误写法
csharp
// 危险:两个计数器紧挨着,落在同一缓存行
public class NaiveCounter
{
private int _countA;
private int _countB;
public void IncA() => Interlocked.Increment(ref _countA);
public void IncB() => Interlocked.Increment(ref _countB);
}
8.3.2 正确写法:结构体填充对齐
csharp
// 把计数器放进 struct,用 StructLayout 显式控制布局
[StructLayout(LayoutKind.Explicit, Size = 128)] // 占 2 个缓存行
internal struct PaddedCounter
{
[FieldOffset(0)] public int Value;
[FieldOffset(64)] public int Padding; // 仅占位,保证下一字段在新缓存行
}
public class ParallelCounter
{
private PaddedCounter _a;
private PaddedCounter _b; // _a 和 _b 各自独占缓存行
public void IncA() => Interlocked.Increment(ref _a.Value);
public void IncB() => Interlocked.Increment(ref _b.Value);
}
FieldOffset=64 让 Value 落在新缓存行起点;Size=128 防止后续字段回填进 _a 的缓存行。也可用 64 字节 padding 数组,但
StructLayout更省内存。
8.3.3 更简洁的 .NET 现代写法
.NET 没有内置 @Contended 注解,但 System.Runtime.CompilerServices 提供了 Unsafe 类辅助。社区常用技巧是把字段分散到不同对象,借助堆布局自然分离:
csharp
// 简化做法:每个计数器独立对象,靠 GC 堆自然分离(不保证但通常有效)
public sealed class PaddedCounter
{
// 56 字节 padding + 4 字节 value ≈ 一个缓存行
private readonly int[] _padding = new int[14];
private int _value;
public int Value => _value;
public void Increment() => Interlocked.Increment(ref _value);
}
8.4 注意事项
- 先测量再优化 :伪共享只在多核 + 高频写场景才有明显开销。用 BenchmarkDotNet 跑
[Params(1,2,4)]线程数对比,再决定是否填充。 - 填充有内存成本:每个变量膨胀 16 倍。计数器少无所谓,海量对象(百万级)要权衡。
ConcurrentQueue/Channel<T>内部已处理:框架级无锁数据结构早就做过缓存行对齐,业务层用现成的即可。[StructLayout]在 struct 上才最稳:class 字段布局受 GC 影响,对齐效果不如 struct 精确。
8.5 总结
- 多核高频写不同字段且字段相邻 → 必查伪共享。
- 测量优先:没有 BenchmarkDotNet 数据,别盲目加 padding。
- 业务层尽量用框架无锁结构(
Channel<T>、ConcurrentQueue),它们已规避伪共享。
九、CancellationToken 与可取消等待
9.1 为什么需要可取消等待
前面所有锁获取都是「等到拿到为止」。生产环境有个硬约束:关闭流程必须能在数秒内完成 。如果某线程卡在 Monitor.Enter 或 SemaphoreSlim.WaitAsync 上等一个被死锁或慢得离谱的锁,进程就关不掉,运维只能 kill -9,丢数据、丢状态。
CancellationToken(简称 CT)让等待可以被外部信号打断:「要么在 N 秒内拿到锁,要么放弃并报告原因」。这是健壮并发系统的必备能力。
9.2 可取消等待的 API 体系
| 原语 | 可取消 API | 失败时行为 |
|---|---|---|
Monitor |
TryEnter(obj, timeout) |
超时返回 false,不抛异常 |
SemaphoreSlim |
WaitAsync(ct) |
CT 触发抛 OperationCanceledException |
Semaphore |
WaitOne(timeout) |
超时返回 false |
Mutex |
WaitOne(timeout) |
超时返回 false |
ReaderWriterLockSlim |
TryEnterReadLock(timeout) 等 |
超时返回 false |
.NET 9 Lock |
Enter(scoped ref LockCookie, timeout) |
超时返回 false |
SpinWait |
无原生支持 | 手动检查 CT 或 Stopwatch |
注意两种失败语义的差异:
TryEnter系列返回 bool 不抛 (调用方自己判断),WaitAsync(ct)系列抛异常(更符合 async/await 的错误传播模型)。混用时要在心里分清。
9.3 标准用法
9.3.1 同步路径:TryEnter + 超时
csharp
public bool DoWorkWithTimeout(int timeoutMs)
{
if (!Monitor.TryEnter(_lock, timeoutMs))
return false; // 拿锁超时,调用方决定降级或重试
try
{
DoActualWork();
return true;
}
finally { Monitor.Exit(_lock); }
}
9.3.2 异步路径:WaitAsync(ct)
csharp
public async Task<bool> DoWorkAsync(CancellationToken ct)
{
try
{
await _semaphore.WaitAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return false; // 取消,优雅降级
}
try
{
await DoActualWorkAsync(ct).ConfigureAwait(false);
return true;
}
finally { _semaphore.Release(); }
}
9.3.3 组合超时与取消
实际项目里常把「超时」和「取消」组合:CT 用于外部主动取消(应用关闭、请求中止),超时用于防止对方死锁。两种独立失败路径:
csharp
public async Task<bool> DoWorkRobustAsync(CancellationToken externalCt)
{
// 外部 CT + 自带超时,组合成新的 CT
using var cts = CancellationTokenSource.CreateLinkedTokenSource(externalCt);
cts.CancelAfter(TimeSpan.FromSeconds(5));
try
{
await _semaphore.WaitAsync(cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (externalCt.IsCancellationRequested)
{
return false; // 外部取消
}
catch (OperationCanceledException)
{
throw new TimeoutException("等锁超过 5 秒,疑似死锁"); // 超时
}
try
{
await DoActualWorkAsync(cts.Token).ConfigureAwait(false);
return true;
}
finally { _semaphore.Release(); }
}
9.4 注意事项
| 陷阱 | 说明 |
|---|---|
| 取消后必须释放 | WaitAsync(ct) 抛 OperationCanceledException 时不会 占用信号量,不要在 catch 里再 Release(),否则计数错乱 |
| TryEnter 失败也别 Exit | Monitor.TryEnter 返回 false 表示没拿到锁,此时调用 Monitor.Exit 会抛 SynchronizationLockException |
| 持锁期间传播 CT | 临界区内调用的方法也应接收 CT,否则取消只能等临界区跑完 |
不要用 Thread.Abort |
.NET Core 起 Abort 抛 PlatformNotSupportedException,且 Abort 注入的异常会破坏不变式。取消的唯一正确方式是 CT |
| SpinWait 无原生取消 | 自旋循环里手动检查 ct.ThrowIfCancellationRequested(),或用 Stopwatch 测超时(见 2.4 正确2) |
9.5 总结
- 所有跨方法调用边界的等待都要支持 CT------尤其是类库。
- 关闭流程的 CT 要传到最底层,让所有持锁等待能被统一打断。
- 超时与取消分开建模:超时是「怀疑出问题」、取消是「外部主动中止」,语义不同,错误处理不同。
- TryEnter 失败 ≠ 异常 :返回
false是正常分支,不要当异常处理。
十、对照表
| 问题 | 根因关键词 | 快速诊断手段 | 首选解决方案 |
|---|---|---|---|
| lock 性能波动 | 锁膨胀 / Fast Path | PerfView Contention 事件 | 复用锁对象 + 工具确认状态 |
| CAS 高竞争退化 | 总线风暴 / 零退避 | dotTrace CPU 采样 | SpinWait 自适应退避 |
| 锁车队效应 | 持锁被抢占 / 公平锁 | 线程数↑吞吐反↓ | 分片锁 + 锁内禁阻塞 |
| 写饥饿 | 长持有读者 / 读者流 | 写操作 P99 延迟飙升 | 限流读者 / 乐观读 / COW |
| DCL 拿到未构造对象 | 发布重排 / 缺屏障 | ARM64 复现 / Code Review | volatile 字段 / Lazy<T> |
| 锁顺序死锁 | 反序获取 / 循环等待 | 调试器看多个线程卡 Monitor.Enter | 统一锁顺序 + TryEnter 超时 |
| 异步死锁 | SynchronizationContext | 调试器看挂起线程堆栈 | ConfigureAwait(false) / 全链路 async |
| 终结器死锁 / 锁对象异常 | Finalizer / LOH / GC 暂停 | ETW GC 事件 + Dump | 终结器禁锁 + 小型专用锁对象 |
| 线程池饥饿 | 同步阻塞 / 注入慢 | 可用线程接近 0 | 异步化 + 调高 MinThreads |
| 伪共享 | 字段相邻 / 缓存行 | BenchmarkDotNet 线程数对比 | StructLayout 缓存行填充 |
| 等待无法中止 | 缺 CT / 无超时 | 关闭流程挂死 | WaitAsync(ct) / TryEnter 超时 |
十一、总结
本篇系统梳理了 C# 锁机制最容易出问题的领域:
- 锁机制 :
lock不是单一开销操作,而是薄锁 → 厚锁状态机加 JIT 内联快速路径的组合体(注意 .NET 没有偏向锁);CAS 退避策略决定它在高竞争下的生死;锁车队是「持锁被抢占」放大效应,分片锁是主要解法。 - 内存模型 :
volatile不等于线程安全,但 volatile 字段的 DCL 在 .NET 中是安全的;Interlocked提供全屏障;显式MemoryBarrier只用于手写无锁发布协议。 - 失败模式:死锁分两种形态------锁顺序死锁(统一锁顺序 + TryEnter)和异步死锁(ConfigureAwait + 全链路 async);GC 暂停会延长锁持有时间,终结器禁锁是底线;线程池饥饿是同步阻塞的必然后果,根治是异步化。
- 高性能模式 :写饥饿靠限流读者、乐观读、不可变 COW 主动防御;不可变 + 引用原子替换在「写极少读极多」场景完胜读写锁;伪共享是缓存行级杀手,
StructLayout填充是解药。 - 可控等待 :
CancellationToken是取消的唯一正确方式,所有跨边界等待都应支持 CT;超时与取消分开建模,TryEnter 与 WaitAsync(ct) 配合使用。
创作权保护
本文由 leonkay 学习总结编写,水平有限,内容仅供参考,作为个人记录使用。若有疏漏,请不吝赐教。版权归作者所有,未经授权,禁止转载、摘编或以其他方式使用本文内容。如需合作或转载本文,请联系作者获得授权。