【.NET并发编程 - 14】无锁编程与内存模子:不加锁,也能线程安全?

[复制链接]
发表于 2026-6-7 02:16:39 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×
14. 无锁编程与内存模子:不加锁,也能线程安全?

本章 GitHub 堆栈:csharp-concurrency-cookbook ⭐
欢迎 Star 和 Fork!全部代码示例都可以在堆栈中找到并运行。
🎯 本章导读

📌 本文目的:搞清楚无锁编程的核心工具——Interlocked、volatile、Thread.MemoryBarrier;明白 .NET 内存模子与 CPU 指令重排的根本概念;学会用 CAS(Compare-And-Swap)实现无锁数据布局;把握 Lazy 和 LazyInitializer 做线程安全耽误初始化;以及最关键的——知道什么时间该用、什么时间不应碰。
上一篇《ThreadLocal 与 AsyncLocal》我们聊的是"每个线程独占本身的数据"。本日我们来聊另一个维度的寻衅:多个线程确实要共享同一份数据,但我不想加锁——怎么做?
你大概会问:不加锁能包管线程安全吗?
答案是:可以,但代价是你必要更深入地相识 CPU 和内存是怎么工作的。
这篇文章,我会带你把这块硬骨头啃透。
🤔 先聊聊:为什么要"无锁"?

在第11篇《锁机制完全指南》里,我们详细先容了 lock、SemaphoreSlim、ReaderWriterLockSlim 这些锁工具。锁这么好用,为什么还要折腾"无锁"?
缘故原由重要有三个:
1. 锁有性能开销
每次 lock 都涉及利用体系的互斥量(Mutex)或 Monitor 对象,有线程切换、内核态切换的开销。在高频、高并发的场景(好比每秒百万次利用的计数器),锁的开销大概成为瓶颈。
2. 锁大概导致死锁
锁的序次一旦搞错,就大概出现经典的死锁。无锁算法没有"锁"这个概念,也就没有死锁。
3. 锁会造成线程饥饿
持有锁的线程被挂起,全部期待的线程都得干等着。无锁算法通常能包管"至少有一个线程在推进"(lock-free progress guarantee)。
固然,无锁不是银弹——代码更复杂、更难调试、更轻易出 bug。以是,本章末了我们也会聊什么时间该用、什么时间别碰。
⚡ 第一关:Interlocked —— 原子利用的瑞士军刀

System.Threading.Interlocked 类提供了一组原子利用。所谓原子,就是利用要么完全发生,要么完全没发生,不存在"一半"的状态。
先看标题:i++ 有多伤害?
  1. private static int _counter = 0;
  2. // 10 个线程各执行 100,000 次
  3. Parallel.For(0, 10, _ =>
  4. {
  5.         for (int i = 0; i < 100_000; i++)
  6.                 _counter++; // ⚠️ 这是 3 步操作!
  7. });
  8. Console.WriteLine(_counter); // 期望 1,000,000,实际上每次执行这段代码,得到的结果可能都不一样,但是通常远小于 1,000,000!
复制代码
_counter++ 看起来是一步,现实上是三步:

  • 读取:把 _counter 从内存读到寄存器
  • 加一:在寄存器里 +1
  • 写回:把寄存器的值写回内存
两个线程同时实行,就大概:

  • 线程A 读到 5
  • 线程B 读到 5(还没被 A 更新)
  • 线程A 写回 6
  • 线程B 写回 6(覆盖了 A 的效果!)
原来应该是 7,效果是 6,丢了一次更新。
Interlocked.Increment / Decrement / Add
  1. // ✅ 一行搞定,原子 +1
  2. Interlocked.Increment(ref _counter);
  3. // ✅ 原子 -1
  4. Interlocked.Decrement(ref _counter);
  5. // ✅ 原子 +n(返回加完后的新值)
  6. int newValue = Interlocked.Add(ref _counter, 5);
  7. // ✅ 64位原子读(在 32 位系统上,读 long 本身不是原子的!)
  8. long result = Interlocked.Read(ref _longCounter);
复制代码
这几个方法背后,在 x86 上是 LOCK XADD 或 LOCK INC 指令,一条指令完成,CPU 包管原子性。
🌟 CAS:无锁编程的基石

Interlocked.CompareExchange 就是台甫鼎鼎的 CAS(Compare-And-Swap),是整个无锁编程的底子。没有 CAS,就没有无锁算法——它是全部无锁数据布局的"原子弹"。
CAS 是什么?

它的语义只有一句话:
"只有当前值即是预期值时,才把它更换成新值,整个利用原子完成,返回旧值"
用伪代码表现:
  1. // 这三步是一个不可分割的原子操作(CPU 层面是 CMPXCHG 指令)
  2. int old = *location;
  3. if (old == comparand)
  4.         *location = newValue;
  5. return old;
  6. // .NET 的写法:
  7. int original = Interlocked.CompareExchange(ref location, newValue, comparand);
  8. // 判断是否成功:
  9. bool success = (original == comparand);
复制代码
留意返回值:不管有没有乐成,都返回利用前的旧值。你通过判定"旧值是否即是你的预期值"来知道 CAS 是否乐成。
为什么 CAS 能包管原子性?

在 x86 上,CAS 对应 CMPXCHG 指令,共同 LOCK 前缀(在多核 CPU 上会锁总线或使用缓存锁):
  1. LOCK CMPXCHG [mem], reg   ; 原子:比较 [mem] 与 EAX,相等则把 reg 写入 [mem]
复制代码
这是 CPU 在硬件层面包管的原子性,不是靠软件锁来实现的——这就是为什么它比 lock 语句快得多。
CAS 自旋:无锁算法的尺度骨架

光有一次 CAS 是不敷的,由于 CAS 大概失败(有其他线程争先了)。办理方案是:失败了就重试,这就是 CAS 自旋(CAS Spin Loop):
  1. while (true)
  2. {
  3.         int current = Volatile.Read(ref _field); // ① 读取当前值
  4.         int newValue = Compute(current);          // ② 基于当前值算出新值
  5.         int original = Interlocked.CompareExchange(ref _field, newValue, current);
  6.         if (original == current) return;          // ③ CAS 成功,退出
  7.         // ④ CAS 失败(original != current):有人抢先修改了,重新来过
  8. }
复制代码
这四步就是整个无锁算法的骨架,背下来,任何无锁算法都是在这个框架上扩展的。
实战1:无锁最大值更新(经典口试题)
  1. private static int _max = 0;
  2. private static void TryUpdateMax(int newValue)
  3. {
  4.         while (true)
  5.         {
  6.                 int current = _max;
  7.                 if (newValue <= current) return; // 新值不比当前大,直接不用更新
  8.                 // CAS:如果 _max 还是 current,就把它换成 newValue
  9.                 int original = Interlocked.CompareExchange(ref _max, newValue, current);
  10.                 if (original == current) return; // 成功!
  11.                 // 失败:说明有别的线程在我读取 current 和执行 CAS 之间,抢先改了 _max
  12.                 // 重新读取最新值,再判断一次
  13.         }
  14. }
复制代码
这就是无锁链表(和反面无锁栈)的核心原理。
实战3:用 CAS 实现自旋锁(明白原理用,生产别用)

这个例子的代价不在于"让你去用",而是让你看清楚 lock 的底层本质:
  1. // 原子地把一个链表头换成新节点
  2. private Node? _head;
  3. public void PrependNode(int value)
  4. {
  5.         var newNode = new Node(value);
  6.         while (true)
  7.         {
  8.                 newNode.Next = _head;                    // 让新节点的 Next 指向当前头
  9.                 // CAS:如果 _head 还是 newNode.Next,就换成 newNode
  10.                 var original = Interlocked.CompareExchange(ref _head, newNode, newNode.Next);
  11.                 if (original == newNode.Next) return;    // 成功
  12.                 // 失败:_head 被别人改了,重新设置 newNode.Next 再试
  13.         }
  14. }
复制代码
看到了吗?lock 关键字在最底层,就是这个 CAS 循环的变体(.NET 的 Monitor 比这复杂得多,加入了期待队列、自顺应自旋等优化,但核心思绪雷同)。
💡 .NET 内置了 System.Threading.SpinLock 布局体,是生产级的自旋锁实现,性能比上面这个简朴版本好得多。短临界区且锁竞争不猛烈时可以用它取代 lock。
CAS 的天敌:ABA 标题

CAS 有一个著名的缺陷,叫 ABA 标题,你必须相识它。
场景: 假设 _value = A

  • 线程1 读到 current = A,预备把它 CAS 换成 C
  • 线程1 被挂起
  • 线程2 把 _value 从 A 改成 B
  • 线程3 把 _value 从 B 改回 A
  • 线程1 规复,实行 CAS:当前值是 A,预期是 A,CAS 乐成了!
但标题是:这个 A 已经不是原来谁人 A 了。中央发生了 A→B→A 的厘革,CAS 没有察觉。
在简朴的整数计数场景,ABA 标题通常无害(值雷同就够了)。但在无锁链表/队列/栈中,ABA 大概导致悬空指针、数据粉碎。
办理方案:带版本号的 CAS(Tagged Pointer / Stamped Reference)
  1. public class SpinLockSimple
  2. {
  3.         private volatile int _locked = 0; // 0 = 未锁,1 = 已锁
  4.         public void Enter()
  5.         {
  6.                 // 不断尝试把 0 换成 1,直到成功
  7.                 while (Interlocked.CompareExchange(ref _locked, 1, 0) != 0)
  8.                 {
  9.                         // 锁被占用,自旋等待
  10.                         Thread.SpinWait(1); // 让出 CPU 时间片的一小部分,避免完全占满 CPU
  11.                 }
  12.                 // 走到这里,说明我们成功把 _locked 从 0 换成了 1,拿到了锁
  13.         }
  14.         public void Exit()
  15.         {
  16.                 Volatile.Write(ref _locked, 0); // 释放锁
  17.         }
  18. }
  19. // 使用方式
  20. var spinLock = new SpinLockSimple();
  21. spinLock.Enter();
  22. try
  23. {
  24.         // 临界区
  25. }
  26. finally
  27. {
  28.         spinLock.Exit();
  29. }
复制代码
在 Release 模式 + 编译器优化 + 某些 CPU 架构(如 ARM) 的情况下,worker 线程大概永久看不到 _stop = true!
为什么?由于:

  • 编译器优化:编译器发现 worker 线程没有写 _stop,大概把 _stop 缓存到寄存器里("你反正不会改它,每次都读内存太浪费了")
  • CPU 缓存:每个 CPU 核心都有本身的 L1/L2 缓存,核心A 写了 _stop,核心B 的缓存里大概还是旧值
volatile 做了什么
  1. // 解决方案:把值和版本号打包在一起,版本号每次修改都递增
  2. // 这样即使值变回 A,(A, version=3) != (A, version=1),CAS 就不会误判
  3. // .NET 中可以把值和版本打包到 long 里(高32位版本,低32位值):
  4. private long _valueWithVersion = 0;
  5. private static (int value, int version) Unpack(long raw)
  6.         => ((int)(raw & 0xFFFFFFFFL), (int)(raw >> 32));
  7. private static long Pack(int value, int version)
  8.         => ((long)version << 32) | (uint)value;
  9. public bool TrySetWithVersion(int expected, int newValue)
  10. {
  11.         while (true)
  12.         {
  13.                 long raw = Interlocked.Read(ref _valueWithVersion);
  14.                 var (currentVal, currentVer) = Unpack(raw);
  15.                 if (currentVal != expected) return false;
  16.                 long newRaw = Pack(newValue, currentVer + 1); // 版本号 +1
  17.                 long original = Interlocked.CompareExchange(ref _valueWithVersion, newRaw, raw);
  18.                 if (original == raw) return true; // 成功(值和版本都匹配)
  19.         }
  20. }
复制代码
volatile 告诉编译器和 CPU 两件事:

  • 克制缓存:每次读取都必须从主内存(或 CPU 缓存划一性协议包管的最新值)读,每次写入都必须立即对其他线程可见
  • 克制重排:不允许把对这个字段的读写利用重排到其他利用的前面或反面(在 .NET 里,volatile 读含有 Acquire 语义,volatile 写含有 Release 语义)
Volatile.Read / Volatile.Write:正确控制

偶然间字段不方便声明为 volatile(好比它在布局体里,大概你想精致控制),可以用:
  1. // 把 sharedFlag 设为 1,返回旧值
  2. int old = Interlocked.Exchange(ref sharedFlag, 1);
  3. // 常用于"抢令牌":谁把 0 换成 1,谁就成功
  4. if (old == 0)
  5.         Console.WriteLine("我抢到了令牌!");
  6. else
  7.         Console.WriteLine("令牌已被占用");
复制代码
这两个方法非常有效,可以在"平常字段"的局部特定访问点加上内存屏蔽语义,而不消把整个字段声明为 volatile。
⚠️ volatile 的范围性

许多人以为 volatile 能包管线程安全,实在它只包管可见性,不包管原子性:
  1. private static bool _stop = false;
  2. var worker = new Thread(() =>
  3. {
  4.         while (!_stop) // ← 这个循环可能永远不会退出!
  5.         {
  6.                 // 做一些工作
  7.         }
  8. });
  9. worker.Start();
  10. Thread.Sleep(1000);
  11. _stop = true; // 主线程设置停止标志
复制代码
记取:

  • 必要原子性 → 用 Interlocked
  • 必要可见性 → 用 volatile
  • 两个都必要 → 用 Interlocked(它隐含了可见性)
🧱 第三关:内存模子 —— CPU 头脑里在想啥?

这一节是本章最烧脑的部分,但也是区分"会写并发代码"和"真正明白并发"的分水岭。许多并发 bug 的根源,就藏在这里。我们一层一层地剥开来讲。
第一层:你写的代码,颠末了几道"变更"才气实行?

大多数人以为:我写的代码 = CPU 实行的代码。现实上,在你的源代码到达 CPU 实行之前,要颠末三道变更:
  1. private static volatile bool _stop = false; // ← 加上 volatile
复制代码
每一道变更都大概重排你的利用序次,只要在单线程视角下"看起来效果一样",它们就以为重排是合法的。
但在多线程下,"另一个线程的视角"这件事,这三层优化都不会主动思量——这就是为什么并发代码必要额外的同步本事。
第二层:CPU 缓存架构 —— 为什么一个核心的写入,另一个核心看不到?

今世 CPU 的存储条理布局(以 8 核 CPU 为例):
  1. // 等价于读一个 volatile 字段
  2. int flag = Volatile.Read(ref _flag);
  3. // 等价于写一个 volatile 字段
  4. Volatile.Write(ref _data, 42);
复制代码
当核心0 写入一个变量时,这个写入起首发生在核心0 的 L1 缓存里,不肯定立即同步到 L3 或主内存。核心1 假如读这个变量,大概读到的是它本身 L1 缓存里的旧值。
以是"线程A 写了一个值,线程B 却看不到"——不是由于写入没发生,而是写入还躺在核心A 的私有缓存里,没有广播给其他核心。
MESI 协议:CPU 怎样包管缓存划一性

别以为多核 CPU 对缓存不划逐一无所知——它有 MESI 缓存划一性协议来维护缓存状态:
状态寄义Modified本核独有,已修改,与主内存差异,其他核必须先来问我要最新值Exclusive本核独有,未修改,与主内存划一Shared多个核共享,只读,与主内存划一Invalid缓存行已失效,数据不可用,必须重新从更高层级获取当核心0 修改一个处于 S(Shared) 状态的缓存行时,会发出 RFO(Request For Ownership) 信号,让其他全部持有该缓存行的核心将其标志为 I(Invalid),然后核心0 的状态变为 M(Modified)。
听起来很优美——全部核心都会"失效旧缓存,重新获取"。但关键标题在于:这个失效关照什么时间送达,什么时间被处置惩罚,是不确定的。
💡 顺带一提:False Sharing(伪共享)
既然聊到了缓存行,这里有须要提一个与缓存行机制密切干系的性能杀手——False Sharing(伪共享)。
CPU 读写内存的最小粒度不是一个字节、也不是一个 int,而是一整条缓存行(Cache Line,通常 64 字节)。假如两个线程各自修改的是逻辑上完全无关的两个变量,但这两个变量恰好落在同一条缓存行里,那么每次修改都会触发 MESI 协议的 RFO 争抢,导致缓存行在两个核心之间来回"乒乓"失效——显着没有数据竞争,性能却惨不忍睹。
这个标题我们在这个系列第03篇《并发的底层:Thread、ThreadPool 与 Task》的「1.2 Thread 的上下文切换资本」章节中有详细的原理分析和性能实行,感爱好的同砚可以翻归去复习一下。这里只做一个简朴的提示:在操持无锁数据布局时,高频写入的字段肯定要留意缓存行对齐,须要时用 [StructLayout(LayoutKind.Explicit)] 或 [FieldOffset] 手动添补,把差异线程写入的字段分隔到差异缓存行中。
第三层:Store Buffer 与 Invalidate Queue —— "显着写了,为什么看不到"的真正缘故原由

这是内存模子最精华也最难明白的部分。
Store Buffer(写缓冲区)

核心写入一个变量,必要先查抄缓存行状态。假如缓存行不在 M 状态,必要先发 RFO 期待其他核心失效确认,才气写入。期待期间,CPU 不能傻站着——它会把写利用暂存到 Store Buffer,然后继承实行反面的指令。
  1. private static volatile int _counter = 0;
  2. // ❌ volatile 救不了这个!
  3. // i++ 是三步,volatile 只保证每步读写是"新鲜的",不保证三步合一
  4. Parallel.For(0, 10, _ =>
  5. {
  6.         for (int i = 0; i < 100_000; i++)
  7.                 _counter++; // 仍然会丢数据!
  8. });
复制代码
标题:核心1 在读 _flag 时,大概已经看到 _flag = 1(从 L3 或核心0 的缓存广播过来了),但 _data = 42 还躺在核心0 的 Store Buffer 里,核心1 读到的 _data 还是旧值!
这就是 Store-Load 重排:从其他核心看来,_flag = 1 的写入,比 _data = 42 的写入先"见效"了。纵然在代码里 _data = 42 写在 _flag = 1 之前!
Invalidate Queue(失效队列)

当核心1 收到 "core0 修改了某个缓存行,请失效你的副本" 的消息时,它不会立即处置惩罚(处置惩罚也必要时间)——它把这个失效哀求放到 Invalidate Queue 里,先去干别的事。
标题:核心1 要读 _data 时,缓存里另有旧的副本,Invalidate Queue 里有一条"这条缓存行已失效"的消息,但还没处置惩罚——核心1 还是读到旧值!
两者组合:经典的"生产者-消耗者"可见性标题
  1. 你写的 C# 代码
  2.     ↓ [Roslyn 编译器优化]
  3. IL 中间代码
  4.     ↓ [JIT / AOT 编译器优化]
  5. 本机机器码
  6.     ↓ [CPU 乱序执行 + 缓存子系统]
  7. 实际执行顺序
复制代码
核心0 的视角:先写了 data,再写了 flag,齐备正常。
核心1 的视角:大概先看到 flag=1,但 data=42 还在核心0 的 Store Buffer 里,以是读到的 data 是旧值 0。
这不是 Bug,这是 CPU 的"合法举动"。 内存模子就是规定了"在什么情况下,CPU 允许如许做,在什么情况下不允许"。
第四层:内存屏蔽 —— 告诉 CPU "这里不许乱"

内存屏蔽(Memory Barrier / Memory Fence)是办理上述标题的武器。屏蔽告诉 CPU:

  • Store Barrier(写屏蔽 / Release):把 Store Buffer 里全部暂存的写利用,全部冲刷(flush)到缓存,然后再实行屏蔽之后的指令。包管:屏蔽前的全部写入,在屏蔽后的写入之前,对其他核心可见。
  • Load Barrier(读屏蔽 / Acquire):处置惩罚 Invalidate Queue 里全部积存的失效哀求,使之后的读利用能看到最新值。包管:屏蔽后的全部读取,不会读到屏蔽前"被缓存但已失效"的旧值。
  • Full Barrier:Store Barrier + Load Barrier,双向都不允许重排。
  1. 核心0    核心1    核心2    ...    核心7
  2.   │        │        │               │
  3. L1缓存  L1缓存  L1缓存  ...  L1缓存   ← 每个核心独有,约 32~64 KB,极快
  4.   │        │        │               │
  5. L2缓存  L2缓存  L2缓存  ...  L2缓存   ← 每个核心独有或2核共享,约 256 KB~1 MB
  6.   └────────┴────────┴───────────────┘
  7.                     │
  8.                 L3 缓存(所有核心共享,约 8~64 MB)
  9.                     │
  10.               主内存(RAM,几十 GB)
复制代码
x86 vs ARM:谁帮你做了屏蔽?

这里是明白"跨平台并发 bug"的关键:
重排范例阐明x86/x64ARM/ARM64Load-Load两个读利用互换序次❌不会✅大概Store-Store两个写利用互换序次❌不会✅大概Load-Store先读后写,被改成先写后读❌不会✅大概Store-Load先写后读,被改成先读后写✅会!✅大概x86 是强内存模子(TSO,Total Store Order),硬件主动克制了除 Store-Load 以外的全部重排。这就是为什么许多"无锁代码在 x86 上跑十年没标题,移植到 ARM 就崩了"。
ARM 是弱内存模子,险些全部重排都大概发生,必要步调员显式插入屏蔽指令(DMB、DSB)。
.NET 运行时在天生呆板码时,会根据目的 CPU 架构主动插入符合的屏蔽指令。以是只要你正确使用了 .NET 提供的同步原语(lock、Interlocked、volatile),就不必要关心底层是 x86 还是 ARM。手动写 Thread.MemoryBarrier() 是末了一招。
第五层:Acquire/Release 语义 —— 内存模子的高层抽象

底层的"Store Buffer / Invalidate Queue / 屏蔽指令"太底层了,难以直接推理。以是内存模子用获取(Acquire)/开释(Release)语义来形貌束缚:
Release 语义(用于写利用):
在这个写利用之前的全部读/写,不能被重排到这个写利用之后。
(相当于在写利用之前插入 Store Barrier)
Acquire 语义(用于读利用):
在这个读利用之后的全部读/写,不能被重排到这个读利用之前。
(相当于在读利用之后插入 Load Barrier)
这两个语义组合使用,构成了经典的"happens-before"关系:
  1. 核心0 执行:
  2. _data = 42;     → 写入 Store Buffer(还没提交到缓存)
  3. _flag = 1;      → 写入 Store Buffer(还没提交到缓存)
  4. ↓
  5. ... 一段时间后,RFO 完成 ...
  6. ↓
  7. Store Buffer 中的写操作按顺序(或可能乱序!)提交到缓存
复制代码
一旦线程B 的 Acquire 读看到了线程A 的 Release 写的效果,就创建了 happens-before 关系:线程A 在 Release 写之前的全部利用,对线程B 在 Acquire 读之后的全部利用都是可见的。
.NET 中各个工具的 Acquire/Release 语义

利用Acquire 语义Release 语义lock 进入✅lock 退出✅volatile 读✅volatile 写✅Volatile.Read✅Volatile.Write✅Interlocked 全部利用✅✅(Full Barrier)Thread.MemoryBarrier()✅✅(Full Barrier)Task.Run / await 的调理✅(继承实行时)✅(发布使命时)有了这张表,你就能判定任何并发代码是否正确:写方用 Release 语义写标志,读方用 Acquire 语义读标志,happens-before 创建,可见性包管。
第六层:实战 —— 亲眼见证内存可见性标题

纸上谈兵不敷,来看一个真实可复现的例子:
  1. 核心0(生产者):          核心1(消费者):
  2. _data = 42;               while (_flag == 0) {}
  3. _flag = 1;                Console.WriteLine(_data); // 可能打印 0!
复制代码
正确写法1:用 volatile
  1. // 正确的"生产者-消费者"模式:
  2. // 生产者:
  3. _data = 42;
  4. Thread.MemoryBarrier(); // ← Store Barrier:把 _data=42 冲刷出 Store Buffer
  5. _flag = 1;
  6. // 消费者:
  7. while (_flag == 0) { }
  8. Thread.MemoryBarrier(); // ← Load Barrier:处理 Invalidate Queue,确保后续读到最新值
  9. Console.WriteLine(_data); // ✅ 现在保证能看到 42
复制代码
正确写法2:用 Interlocked(更明白)
  1. 线程A(Release 写)               线程B(Acquire 读)
  2. _data = 42;    ←──────────────── 所有这些写入        │
  3. _message = "hello"; ◄────────────────────────────── │ happens-before
  4. Volatile.Write(ref _flag, 1); ─────────────────────► Volatile.Read(ref _flag) == 1
  5.                                                      │ ↓
  6.                                                      _data;    // 保证能看到 42
  7.                                                      _message; // 保证能看到 "hello"
复制代码
正确写法3:用 Thread.MemoryBarrier(最显式)
  1. // 没有任何同步的标志位
  2. private static bool _ready = false;
  3. private static int _value = 0;
  4. // 线程A(生产者)
  5. var producer = new Thread(() =>
  6. {
  7.     _value = 42;  // 先写数据
  8.     _ready = true; // 再设置标志
  9. });
  10. // 线程B(消费者)
  11. var consumer = new Thread(() =>
  12. {
  13.     while (!_ready) { } // 等待标志
  14.     // ⚠️ 在 Release 模式 + ARM 上,可能打印 0!
  15.     // ⚠️ 在 Debug 模式 + x86 上,通常正常(因为 Debug 模式优化少)
  16.     Console.WriteLine(_value);
  17. });
复制代码
Thread.MemoryBarrier():显式内存屏蔽的使用场景

既然 volatile 和 Interlocked 已经够用,什么时间才真的必要手写 Thread.MemoryBarrier()?
场景1:对平常数组/布局体字段的批量写入,然后发布一个标志
  1. private static volatile bool _ready = false; // volatile 写 = Release,volatile 读 = Acquire
  2. private static int _value = 0;
  3. // 生产者:
  4. _value = 42;
  5. _ready = true; // volatile 写,Release 语义,_value=42 的写入不会被重排到这句之后
  6. // 消费者:
  7. while (!_ready) { } // volatile 读,Acquire 语义,之后的读取能看到生产者的所有写入
  8. Console.WriteLine(_value); // ✅ 保证打印 42
复制代码
场景2:精致控制屏蔽位置,制止不须要的 Full Barrier 开销
Interlocked 每次都是 Full Barrier,资本相对较高。偶然只必要单向屏蔽:
  1. private static int _ready = 0;
  2. private static int _value = 0;
  3. // 生产者:
  4. _value = 42;
  5. Interlocked.Exchange(ref _ready, 1); // Full Barrier,比 volatile 更强
  6. // 消费者:
  7. while (Interlocked.CompareExchange(ref _ready, 0, 0) == 0) { } // Full Barrier
  8. Console.WriteLine(_value); // ✅ 保证打印 42
复制代码
⚠️ 这是纳秒级的极限优化,99.9% 的代码不必要。除非你在写运行时或框架底层代码,否则看到必要手写 MemoryBarrier 的场景,先质疑本身的代码布局是否有标题。
常见误区汇总

误区1:"我在 x86 上测试没标题,就肯定没标题"
x86 的强内存模子帮你屏蔽了大多数重排,但假如你的代码摆设到 ARM 服务器(AWS Graviton、Azure Ampere、Apple Silicon)或 Android/iOS 装备,就大概出 bug。
误区2:"用了 volatile 就线程安全了"
volatile 只办理可见性标题。_counter++ 写成 volatile _counter++ 仍旧不是原子的。
误区3:"只要不重排,就没标题"
纵然没有指令重排,仅仅是 CPU 缓存的耽误同步(Store Buffer 未 flush),就大概导致可见性标题。
误区4:"lock 只是互斥,不涉及内存"
错。lock 进入时有 Acquire 屏蔽,退出时有 Release 屏蔽,这正是 lock 能包管临界区内读写对外正确可见的缘故原由。
用一张图串起来
  1. // 生产者:
  2. _value = 42;
  3. Thread.MemoryBarrier(); // 冲刷 Store Buffer,_value=42 对外可见
  4. _ready = true;
  5. // 消费者:
  6. while (!_ready) { }
  7. Thread.MemoryBarrier(); // 处理 Invalidate Queue
  8. Console.WriteLine(_value); // ✅ 保证打印 42
复制代码
只要你正确使用了 .NET 提供的同步原语,这个链条就是完备的,可见性和序次性都有包管。
🏗️ 第四关:用 CAS 搭一个无锁栈

理论讲完了,来个实战。我们用 CAS 实现一个无锁栈,这个算法叫 Treiber Stack(1986 年提出,至今还是经典)。
  1. // 初始化一个大对象的多个字段,然后发布它
  2. _buffer[0] = 1;
  3. _buffer[1] = 2;
  4. _buffer[2] = 3;
  5. // ... 写了很多字段 ...
  6. Thread.MemoryBarrier(); // ← 保证上面所有写入对外可见,然后才发布
  7. Volatile.Write(ref _isReady, true);
复制代码
Push 的逻辑:

  • 创建新节点 newNode
  • 让 newNode.Next 指向当前的 _head
  • CAS:把 _head 从 newNode.Next 换成 newNode
  • 假如这一刹时 _head 已经被别的线程改了,重读重试
Pop 的逻辑:

  • 读取当前头节点 head
  • CAS:把 _head 从 head 换成 head.Next
  • 假如被争先,重读重试
这个实现是线程安全的,而且没有锁!
💡 留意:现实生产代码里,System.Collections.Concurrent.ConcurrentStack 内部就是类似的无锁实现,不必要本身写。本身写只是为了明白原理。
😴 第五关:Lazy —— 线程安全的懒加载

耽误初始化(Lazy Initialization)是一个很常见的需求:某个对象创建开销很大,我盼望到真正用的时间再创建。
老的写法(double-check locking)既繁琐又轻易堕落:
  1. // 只需要 Release 语义(只需要 Store Barrier)
  2. Thread.MemoryBarrier(); // 手动插入,比 Interlocked.Exchange 开销小
  3. _flag = 1;
复制代码
今世写法,用 Lazy:
  1. 你的代码(happens-before 的约束)
  2.           ↕
  3. .NET 内存模型(volatile/Interlocked/lock 的语义规范)
  4.           ↕
  5. JIT 编译器(按内存模型规范生成屏障指令)
  6.           ↕
  7. CPU 硬件(MFENCE/DMB 指令 + MESI 协议保证执行正确性)
  8.           ↕
  9. 其他线程实际看到的内存状态
复制代码
LazyThreadSafetyMode:三种模式

Lazy 有三种线程安全模式,通过 LazyThreadSafetyMode 摆列控制:
模式阐明实用场景ExecutionAndPublication(默认)加锁,工厂函数只实行一次大多数场景PublicationOnly不加锁,多个线程大概各自实行工厂函数,但只有一个效果会被发布(CAS 竞争)工厂函数无副作用、开销极小None完全不线程安全确定单线程使用时
  1. public class LockFreeStack<T>
  2. {
  3.         private sealed class Node(T value)
  4.         {
  5.                 public readonly T Value = value;
  6.                 public Node? Next;
  7.         }
  8.         private volatile Node? _head;
  9.         // 无锁 Push
  10.         public void Push(T value)
  11.         {
  12.                 var newNode = new Node(value);
  13.                 while (true)
  14.                 {
  15.                         newNode.Next = _head;    // 新节点指向当前头
  16.                         // CAS:如果 _head 还是 newNode.Next,就换成 newNode
  17.                         if (Interlocked.CompareExchange(ref _head, newNode, newNode.Next) == newNode.Next)
  18.                                 return; // 成功!
  19.                         // 失败了,说明有人抢先改了 _head,重新尝试
  20.                 }
  21.         }
  22.         // 无锁 Pop
  23.         public bool TryPop(out T? value)
  24.         {
  25.                 while (true)
  26.                 {
  27.                         Node? head = _head;
  28.                         if (head is null) { value = default; return false; }
  29.                         // CAS:如果 _head 还是 head,就换成 head.Next
  30.                         if (Interlocked.CompareExchange(ref _head, head.Next, head) == head)
  31.                         {
  32.                                 value = head.Value;
  33.                                 return true; // 成功!
  34.                         }
  35.                         // 失败了,重新尝试
  36.                 }
  37.         }
  38. }
复制代码
LazyInitializer.EnsureInitialized

假如你不想用 Lazy 包装类,可以用 LazyInitializer.EnsureInitialized 直接初始化字段:
  1. // ❌ 老写法:容易写错
  2. private static volatile MyService? _instance;
  3. private static readonly object _lock = new();
  4. public static MyService Instance
  5. {
  6.         get
  7.         {
  8.                 if (_instance == null)
  9.                 {
  10.                         lock (_lock)
  11.                         {
  12.                                 if (_instance == null)
  13.                                         _instance = new MyService();
  14.                         }
  15.                 }
  16.                 return _instance;
  17.         }
  18. }
复制代码
它的语义是 PublicationOnly:允许多个线程竞争实行工厂函数,CAS 包管只有一个效果被发布。
🚦 什么时间用无锁?什么时间别碰?

讲了这么多,最关键的标题来了:我应该什么时间用这些东西?
✅ 适适用无锁的场景

场景保举工具简朴计数器(高频递增/递减)Interlocked.Increment/Decrement简朴标志位(一写多读)volatile bool单例/耽误初始化Lazy读-改-写的原子利用Interlocked.CompareExchange性能极其敏感的并发数据布局CAS + 自旋,或直接用 .NET 内置的并发聚集❌ 不得当/不必要无锁的场景


  • 业务逻辑复杂的临界区:用 lock 或 SemaphoreSlim,正确性比性能更告急
  • 没有性能瓶颈的地方:过早优化是万恶之源,先写对,再优化
  • 你对内存模子不敷相识的场景:无锁代码的 bug 极难复现和调试,宁肯多加一个锁
一个实用的决定流程:
  1. // ✅ 新写法:一行搞定,默认线程安全
  2. private static readonly Lazy<MyService> _instance =
  3.         new(() => new MyService());
  4. public static MyService Instance => _instance.Value;
复制代码
🔬 .NET 10 新特性:System.Threading.Lock

顺带一提,.NET 9/10 引入了新的 System.Threading.Lock 范例,比 lock(object) 更明白、性能更好:
  1. // PublicationOnly 模式:性能略好,但工厂函数可能被调用多次
  2. var lazy = new Lazy<MyService>(
  3.         () => new MyService(),
  4.         LazyThreadSafetyMode.PublicationOnly
  5. );
复制代码
固然这不是"无锁",但在不必要无锁的场景里,这是更今世的选择。
📋 本章小结

工具办理的标题留意事项Interlocked.Increment/Decrement原子计数只得当简朴加减Interlocked.CompareExchange (CAS)无锁的条件更新必要共同自旋循环Interlocked.Exchange原子读改写无条件更换volatile包管字段的可见性不包管复合利用原子性!Volatile.Read/Write对非 volatile 字段加正确屏蔽用于性能敏感的精致控制Thread.MemoryBarrier显式内存屏蔽高级用法,大多数时间不必要Lazy线程安全耽误初始化默认模式得当绝大多数场景LazyInitializer轻量级耽误初始化PublicationOnly 语义核心原则:

  • 能用高层抽象,就不要手搓底层:.NET 的并发聚集(ConcurrentDictionary、ConcurrentStack 等)内部已经实现了无锁或精致锁,直接用
  • volatile ≠ Interlocked:搞清楚可见性和原子性的区别
  • CAS 自旋是无锁算法的尺度模式:读→算→CAS→失败重试
  • 内存模子的标题在 ARM 上更轻易触发:不要只在 x86 上测试
🔗 参考资料


  • Interlocked Class - Microsoft Docs
  • volatile (C# Reference) - Microsoft Docs
  • Memory Barriers and Interlocked Operations - Microsoft Docs
  • Lazy Class - Microsoft Docs
  • Treiber Stack - Wikipedia
  • .NET Memory Model - Jon Skeet's C# in Depth
回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表