C# 锁机制——【2】深入原理


文章目录

    • [一、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 避坑要点

  1. 不要频繁创建短生命周期的锁对象:每个新对象都从无锁状态开始,无法受益于热路径优化。
  2. 避免在热路径混用不同同步原语:不同机制的等待队列不互通,还会打断 CPU 缓存的局部性。
  3. 用工具确认锁状态: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 总结

  1. 永远不要在 CAS 循环中使用空 body 或固定 Sleep(1)。
  2. SpinWait 是值类型,不要装箱,不要跨方法复制传递。
  3. 预期竞争极低时用 CAS + SpinWait;预期竞争持续偏高时直接用 lock------CAS 的优势只在低竞争区间。

三、锁车队效应与公平性取舍

3.1 现象:线程越多,吞吐反而越低

8 核机器上 4 线程跑 100 万次 lock 操作耗时 1 秒;加到 32 线程,耗时不是 1 秒而是 8 秒------线性扩展彻底失效。但临界区只有几行赋值,理论上不该这么慢。

3.2 原理:锁车队(Lock Convoy)怎么形成

当多个线程高频争抢同一把锁,它们会排成一条长队,一个挨一个通过临界区,这种现象叫锁车队。它带来三重叠加开销:

  1. 持锁线程被抢占:OS 调度把正在持锁的线程切走(时间片耗尽、中断、优先级抢占),整条车队都得等它被重新调度回来。锁的「实际持有时间」被拉长到时间片级别(毫秒),而临界区本该是纳秒级。
  2. 上下文切换雪崩:车队中每个线程拿到锁后立刻又被切走,CPU 大量时间花在切换而不是干活。
  3. 缓存行失效放大:锁对象所在缓存行在多个核心间反复漂移,每次传递都触发 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 总结

  1. 高频热锁 + 多线程 = 必然车队,先用分片锁拆热点。
  2. 锁内禁止任何阻塞调用(IO、Thread.Sleep、Task.Wait)。
  3. 不要追求「公平锁」除非业务真的需要,吞吐代价远超想象。

四、内存屏障体系: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 捕获

这不是「两个线程互相等待」的传统死锁,而是单线程上下文耗尽导致的逻辑死锁:

  1. UI / ASP.NET Classic 的 SynchronizationContext 是单线程的;
  2. await 默认捕获当前上下文,续体(continuation)被 Post 回该上下文执行;
  3. .Wait() 阻塞了这个唯一线程;
  4. 续体无法调度 → 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 以上。

缓解策略:

  1. 减少 LOH 分配,降低 GC 暂停的频率和时长;
  2. 关键临界区前可用 GC.TryStartNoGCRegion 临时抑制 GC(需谨慎,区域内分配过多会抛异常);
  3. 分析锁性能数据时,把 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 总结

  1. 终结器 / SafeHandle.ReleaseHandle 中禁止获取任何锁。
  2. 锁对象必须小型、专用、长生命周期。
  3. 不要在锁内分配大对象,不要在锁内做任何阻塞 IO。
  4. 同步阻塞代码上量前必须异步化,否则线程池饥饿只是时间问题。
  5. 关注 GC 暂停与线程池可用线程两项指标,把它们纳入锁性能分析的上下文。

七、读写锁写饥饿与不可变替代模式

读多写少场景下,读写锁看似理想,但写饥饿是个深坑。更激进的做法是干脆不上锁,用不可变对象 + 原子替换。

7.1 现象:写入永远排不上队

读多写少场景下使用读写锁,结果写入操作等待数秒甚至超时,而读取从未中断。

7.2 原理:写饥饿从哪来

先澄清两代读写锁的差异:

  • 老的 ReaderWriterLock(不要用) :真正的读者优先------只要有读者持锁,新读者就可以持续进入,写者可能被无限推迟,且性能差、递归语义混乱。新代码应一律使用 ReaderWriterLockSlim。
  • ReaderWriterLockSlim :当已有写者在队列中等待时,新到达的读者请求会被阻塞,写者不会被新读者无限插队,饥饿问题已大幅缓解。

但写延迟在以下情况仍然存在:

  1. 长持有读者:写者必须等所有「已经在里面」的读者退出。若单个读锁持有数秒,写者就要等数秒。
  2. 读者流不间断:读者不断到达且每个都恰好排在「写者排队」之前,写者的等待被不断推迟。
  3. 升级锁误用 :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 注意事项
  1. 字段必须真的不可变 :所有字段 readonly、属性只有 getter,否则原子性假设崩塌。
  2. 大对象 COW 成本高 :每次写要拷贝整个结构,适合小配置对象,不适合大集合。大集合改用 ImmutableArray<T> / ImmutableDictionary<K,V>,它们用结构共享减少拷贝。
  3. 写并发仍需加锁 :多个写者同时替换引用不会破坏原子性,但会丢失更新。写路径加 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 注意事项

  1. 先测量再优化 :伪共享只在多核 + 高频写场景才有明显开销。用 BenchmarkDotNet 跑 [Params(1,2,4)] 线程数对比,再决定是否填充。
  2. 填充有内存成本:每个变量膨胀 16 倍。计数器少无所谓,海量对象(百万级)要权衡。
  3. ConcurrentQueue / Channel<T> 内部已处理:框架级无锁数据结构早就做过缓存行对齐,业务层用现成的即可。
  4. [StructLayout] 在 struct 上才最稳:class 字段布局受 GC 影响,对齐效果不如 struct 精确。

8.5 总结

  1. 多核高频写不同字段且字段相邻 → 必查伪共享。
  2. 测量优先:没有 BenchmarkDotNet 数据,别盲目加 padding。
  3. 业务层尽量用框架无锁结构(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 总结

  1. 所有跨方法调用边界的等待都要支持 CT------尤其是类库。
  2. 关闭流程的 CT 要传到最底层,让所有持锁等待能被统一打断。
  3. 超时与取消分开建模:超时是「怀疑出问题」、取消是「外部主动中止」,语义不同,错误处理不同。
  4. 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# 锁机制最容易出问题的领域:

  1. 锁机制 :lock 不是单一开销操作,而是薄锁 → 厚锁状态机加 JIT 内联快速路径的组合体(注意 .NET 没有偏向锁);CAS 退避策略决定它在高竞争下的生死;锁车队是「持锁被抢占」放大效应,分片锁是主要解法。
  2. 内存模型 :volatile 不等于线程安全,但 volatile 字段的 DCL 在 .NET 中是安全的;Interlocked 提供全屏障;显式 MemoryBarrier 只用于手写无锁发布协议。
  3. 失败模式:死锁分两种形态------锁顺序死锁(统一锁顺序 + TryEnter)和异步死锁(ConfigureAwait + 全链路 async);GC 暂停会延长锁持有时间,终结器禁锁是底线;线程池饥饿是同步阻塞的必然后果,根治是异步化。
  4. 高性能模式 :写饥饿靠限流读者、乐观读、不可变 COW 主动防御;不可变 + 引用原子替换在「写极少读极多」场景完胜读写锁;伪共享是缓存行级杀手,StructLayout 填充是解药。
  5. 可控等待 :CancellationToken 是取消的唯一正确方式,所有跨边界等待都应支持 CT;超时与取消分开建模,TryEnter 与 WaitAsync(ct) 配合使用。

创作权保护

本文由 leonkay 学习总结编写,水平有限,内容仅供参考,作为个人记录使用。若有疏漏,请不吝赐教。版权归作者所有,未经授权,禁止转载、摘编或以其他方式使用本文内容。如需合作或转载本文,请联系作者获得授权。

相关推荐
子兮曰4 天前
jev-ultrafast 深度解析:7 秒订机票的浏览器 Agent 是如何炼成的
前端·后端·agent
子兮曰4 天前
Jev 爆发一周:7 秒 Agent 背后的 System One 生态与三场争议
前端·后端·ai编程
小羊没烦恼!4 天前
初探性能优化——2个月到4小时的性能提升
java·开发语言·windows·算法·c#
爱勇宝4 天前
ZCode 开源 24 小时:一份没有历史的账本,回答不了"有没有偷代码"
前端·后端·chatglm (智谱)
神仙别闹4 天前
基于C#+MySQL实现(WinForm)个人聊天室软件
c#
胡写代码4 天前
别再前后端各写一套表单校验了
java·后端
伞伞悦读4 天前
【第38期】Python 模块与包详解:import、from、模块搜索路径、包结构和 __init__
开发语言·python
大勇前进4 天前
原生 PHP 还是 Laravel?小项目到底要不要上框架
后端
yuzhi_liu4 天前
我用 LangGraph4j 实现 Multi-Agent Supervisor
后端
alsmile4 天前
Node-RED 之外,国产规则引擎的新方案:基于标准语法,Go 先行实现
后端·开源·go