一、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 做了三级状态机优化,不同竞争程度下性能差异巨大:
无锁 (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 的原因:
// 无竞争时,底层大致等价于:
// 一条 cas 指令操作对象头 → 成功就直接进入临界区
lock (_lock)
{
_x = 1;
}
② 锁膨胀的自适应自旋
clr 根据自旋是否等到锁、当前核心数、是否发生上下文切换等动态决定自旋次数,而不是写死的固定循环。多核短临界区下自旋收益高,单核心或长临界区下更快挂起。
③ .net 9 专用 lock 类型
.net 9 / c# 13 新增了 system.threading.lock 类型,专为 lock 语句优化,对象头路径更短:
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,而是一个三阶段自适应退避器(以下为简化示意):
// 简化版内部逻辑
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 正确退避
// 错误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 缓解策略
① 缩短临界区,锁内不阻塞
// 错误:锁内做 io,持锁时间被拉到秒级
lock (_lock)
{
var data = file.readalltext(_path); // 千万别
_cache = data;
}
// 正确:锁外读,锁内只赋值
var data = file.readalltext(_path);
lock (_lock) { _cache = data; }② 分片锁(striped lock)分散热点
把一把大锁拆成 n 把小锁,按 key 哈希分流,车队就被拆成 n 条短队:
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)在弱内存模型架构上不安全:
// 错误示范:_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 三种正确写法
// 方案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 并非无用,它适合标志位通信这类不需要全屏障的场景:
private volatile bool _stoprequested;
// 生产者/控制线程
_stoprequested = true; // volatile 写:保证之前的数据写入对其他线程可见
// 消费者工作线程
while (!_stoprequested) // volatile 读:保证每次读到最新值
{
processitem();
}4.6 显式内存屏障 api
除 volatile 和 interlocked,.net 还提供两个显式屏障方法,了解它们有助于读懂框架源码,但日常业务几乎用不到:
// 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 现象:两个线程都拿不到对方的锁
// 线程 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 解决方案一:统一锁顺序(打破循环等待)
最经典也最有效。给所有锁定一个全局顺序,所有线程都按同一顺序获取:
// 约定:所有地方都先拿 _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 加超时,到点放弃已持有的锁重试:
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 方法调用后永久挂起
// 类库代码
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)(类库代码必选)
await _semaphore.waitasync().configureawait(false); var data = await fetchfromdbasync().configureawait(false);
不捕获 sc,续体在线程池线程上执行,不受调用方上下文影响。
方案2:全链路 async(应用代码首选)
// 调用方也改为 async,不阻塞任何线程
private async void button_click(...)
{
await loaddataasync();
}方案3:task.run 隔离(遗留代码应急)
// 丢到线程池执行,脱离当前 sc await task.run(() => loaddataasync()).configureawait(false);
方案4:用 channel 替代异步锁做串行化
需要的不是「互斥」而是「串行处理」时,直接用通道队列:
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(需要互斥语义时的标准模式)
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 问题一:终结器线程死锁
// 危险:在析构函数(终结器)中获取锁
~myresource()
{
lock (_globallock) // 终结器线程尝试获取锁
{
cleanup();
}
}原理:gc 的终结器线程是单线程的。如果某个对象的 finalize 阻塞在锁上,而持锁线程又在等待 gc 完成(触发了 gc.collect 或 loh 压缩),就形成终结器线程与工作线程的死锁。终结器一卡,所有待终结对象都无法释放,最终内存泄漏。
正确做法:
// 1. 终结器中绝不获取任何锁,只做非托管资源释放,且不调用任何可能阻塞的方法
~myresource()
{
nativemethods.free(_handle);
}
// 2. 优先用 safehandle 替代手写 finalize,框架已处理好终结安全
private readonly safefilehandle _handle;6.1.3 问题二:锁对象本身被移动或回收
锁对象是普通托管对象,同样受 gc 影响:
- 永远不要用可能被外部持有的对象当锁(
this、typeof(t)、字符串),第一篇已讲; - 锁对象应该是小的、专用的、长生命周期的对象,永远在小对象堆(soh)上,不涉及大对象堆(loh)碎片问题;
- .net 9 可用专用
lock类型,为锁场景精简了布局:
// 推荐:小对象 + 专用 + 长生命周期 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 暂停期间所有托管线程都会被冻结,包括正在持有锁的线程:
锁的实际持有时间 = 临界区代码执行时间 + 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),线程被卡住,新请求排队,而线程池又不肯快速扩容,雪崩就开始:
请求qps↑ → 线程被同步调用卡住 → 队列堆积 → 线程池每秒只加1线程 → 堆积速度远超扩容 → 饥饿
与锁的关联:锁本身不阻塞时极快(2ns),但锁内做阻塞 io 会把宝贵的线程池线程钉死在 monitor.enter 的等待队列里——这正是「锁车队 + 线程池饥饿」联手的灾难现场:
// 危险组合:锁内同步阻塞 io,线程池线程被钉死
public string getdata(int id)
{
lock (_lock)
{
return _httpclient.getstringasync($"/api/{id}").result; // 双重罪
}
}线程池线程 a 拿到锁,开始同步等 http;线程 b~n 想拿锁,全部在 monitor.enter 上排队阻塞;线程池想加线程来处理新请求,但每秒只加 1 个,根本追不上。
6.2.3 缓解与解决
① 根治:异步化,线程池线程不阻塞
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 移出锁外,锁内只做赋值/缓存更新(纳秒级),即使排队也排得快。
③ 临时调高线程池下限(应急,非根治)
// 启动时配置,让线程池敢于更快扩容 threadpool.getminthreads(out int wt, out int _); threadpool.setminthreads(math.max(wt, 200), math.max(wt, 200));
注意:这只是给「同步阻塞代码」买时间,根因还是要异步化。调太高会让上下文切换成本反噬吞吐。
④ 监控线程池健康
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 + 缩短读临界区
// 构造时显式指定不支持递归
private readonly readerwriterlockslim _rwlock =
new(lockrecursionpolicy.norecursion);
注意:readerwriterlockslim 没有显式的「公平模式」开关。它的排队行为接近 fifo,但不做严格保证。需要严格公平时请用方案 b。
方案b:手动限流读者(推荐)
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,但可以用版本号模拟乐观读。注意写者之间仍然必须互斥,版本号只负责免锁读:
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 位机器上都是机器字一次写入)。这意味着读者只要拿到引用,就拿到了完整一致的对象快照,不存在「拿到半构造对象」的风险。
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配合重试。
// 写者并发安全版: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。于是:
字段布局:[ _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 错误写法
// 危险:两个计数器紧挨着,落在同一缓存行
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 正确写法:结构体填充对齐
// 把计数器放进 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 类辅助。社区常用技巧是把字段分散到不同对象,借助堆布局自然分离:
// 简化做法:每个计数器独立对象,靠 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 + 超时
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)
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 用于外部主动取消(应用关闭、请求中止),超时用于防止对方死锁。两种独立失败路径:
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) 配合使用。
避坑要点
- 不要频繁创建短生命周期锁对象:每个新对象都从无锁状态开始,无法受益于热路径优化。
- 缩短临界区:锁内不做 io、不
thread.sleep、不task.wait。 - 用私有引用类型对象作锁:值类型会被装箱导致锁失效,字符串字面量因驻留机制更危险。
- 用 perfview 的 contention 事件确认锁状态:观察实际竞争次数和线程挂起时长。
到此这篇关于c# 锁机制原理深入解析的文章就介绍到这了,更多相关c# 锁机制原理内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论