马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?立即注册
×
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++ 有多伤害?
- private static int _counter = 0;
- // 10 个线程各执行 100,000 次
- Parallel.For(0, 10, _ =>
- {
- for (int i = 0; i < 100_000; i++)
- _counter++; // ⚠️ 这是 3 步操作!
- });
- 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
- Interlocked.Increment(ref _counter);
- // ✅ 原子 -1
- Interlocked.Decrement(ref _counter);
- // ✅ 原子 +n(返回加完后的新值)
- int newValue = Interlocked.Add(ref _counter, 5);
- // ✅ 64位原子读(在 32 位系统上,读 long 本身不是原子的!)
- long result = Interlocked.Read(ref _longCounter);
复制代码 这几个方法背后,在 x86 上是 LOCK XADD 或 LOCK INC 指令,一条指令完成,CPU 包管原子性。
🌟 CAS:无锁编程的基石
Interlocked.CompareExchange 就是台甫鼎鼎的 CAS(Compare-And-Swap),是整个无锁编程的底子。没有 CAS,就没有无锁算法——它是全部无锁数据布局的"原子弹"。
CAS 是什么?
它的语义只有一句话:
"只有当前值即是预期值时,才把它更换成新值,整个利用原子完成,返回旧值"
用伪代码表现:- // 这三步是一个不可分割的原子操作(CPU 层面是 CMPXCHG 指令)
- int old = *location;
- if (old == comparand)
- *location = newValue;
- return old;
- // .NET 的写法:
- int original = Interlocked.CompareExchange(ref location, newValue, comparand);
- // 判断是否成功:
- bool success = (original == comparand);
复制代码 留意返回值:不管有没有乐成,都返回利用前的旧值。你通过判定"旧值是否即是你的预期值"来知道 CAS 是否乐成。
为什么 CAS 能包管原子性?
在 x86 上,CAS 对应 CMPXCHG 指令,共同 LOCK 前缀(在多核 CPU 上会锁总线或使用缓存锁):- LOCK CMPXCHG [mem], reg ; 原子:比较 [mem] 与 EAX,相等则把 reg 写入 [mem]
复制代码 这是 CPU 在硬件层面包管的原子性,不是靠软件锁来实现的——这就是为什么它比 lock 语句快得多。
CAS 自旋:无锁算法的尺度骨架
光有一次 CAS 是不敷的,由于 CAS 大概失败(有其他线程争先了)。办理方案是:失败了就重试,这就是 CAS 自旋(CAS Spin Loop):- while (true)
- {
- int current = Volatile.Read(ref _field); // ① 读取当前值
- int newValue = Compute(current); // ② 基于当前值算出新值
- int original = Interlocked.CompareExchange(ref _field, newValue, current);
- if (original == current) return; // ③ CAS 成功,退出
- // ④ CAS 失败(original != current):有人抢先修改了,重新来过
- }
复制代码 这四步就是整个无锁算法的骨架,背下来,任何无锁算法都是在这个框架上扩展的。
实战1:无锁最大值更新(经典口试题)
- private static int _max = 0;
- private static void TryUpdateMax(int newValue)
- {
- while (true)
- {
- int current = _max;
- if (newValue <= current) return; // 新值不比当前大,直接不用更新
- // CAS:如果 _max 还是 current,就把它换成 newValue
- int original = Interlocked.CompareExchange(ref _max, newValue, current);
- if (original == current) return; // 成功!
- // 失败:说明有别的线程在我读取 current 和执行 CAS 之间,抢先改了 _max
- // 重新读取最新值,再判断一次
- }
- }
复制代码 这就是无锁链表(和反面无锁栈)的核心原理。
实战3:用 CAS 实现自旋锁(明白原理用,生产别用)
这个例子的代价不在于"让你去用",而是让你看清楚 lock 的底层本质:- // 原子地把一个链表头换成新节点
- private Node? _head;
- public void PrependNode(int value)
- {
- var newNode = new Node(value);
- while (true)
- {
- newNode.Next = _head; // 让新节点的 Next 指向当前头
- // CAS:如果 _head 还是 newNode.Next,就换成 newNode
- var original = Interlocked.CompareExchange(ref _head, newNode, newNode.Next);
- if (original == newNode.Next) return; // 成功
- // 失败:_head 被别人改了,重新设置 newNode.Next 再试
- }
- }
复制代码 看到了吗?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)- public class SpinLockSimple
- {
- private volatile int _locked = 0; // 0 = 未锁,1 = 已锁
- public void Enter()
- {
- // 不断尝试把 0 换成 1,直到成功
- while (Interlocked.CompareExchange(ref _locked, 1, 0) != 0)
- {
- // 锁被占用,自旋等待
- Thread.SpinWait(1); // 让出 CPU 时间片的一小部分,避免完全占满 CPU
- }
- // 走到这里,说明我们成功把 _locked 从 0 换成了 1,拿到了锁
- }
- public void Exit()
- {
- Volatile.Write(ref _locked, 0); // 释放锁
- }
- }
- // 使用方式
- var spinLock = new SpinLockSimple();
- spinLock.Enter();
- try
- {
- // 临界区
- }
- finally
- {
- spinLock.Exit();
- }
复制代码 在 Release 模式 + 编译器优化 + 某些 CPU 架构(如 ARM) 的情况下,worker 线程大概永久看不到 _stop = true!
为什么?由于:
- 编译器优化:编译器发现 worker 线程没有写 _stop,大概把 _stop 缓存到寄存器里("你反正不会改它,每次都读内存太浪费了")
- CPU 缓存:每个 CPU 核心都有本身的 L1/L2 缓存,核心A 写了 _stop,核心B 的缓存里大概还是旧值
volatile 做了什么
- // 解决方案:把值和版本号打包在一起,版本号每次修改都递增
- // 这样即使值变回 A,(A, version=3) != (A, version=1),CAS 就不会误判
- // .NET 中可以把值和版本打包到 long 里(高32位版本,低32位值):
- private long _valueWithVersion = 0;
- private static (int value, int version) Unpack(long raw)
- => ((int)(raw & 0xFFFFFFFFL), (int)(raw >> 32));
- private static long Pack(int value, int version)
- => ((long)version << 32) | (uint)value;
- public bool TrySetWithVersion(int expected, int newValue)
- {
- while (true)
- {
- long raw = Interlocked.Read(ref _valueWithVersion);
- var (currentVal, currentVer) = Unpack(raw);
- if (currentVal != expected) return false;
- long newRaw = Pack(newValue, currentVer + 1); // 版本号 +1
- long original = Interlocked.CompareExchange(ref _valueWithVersion, newRaw, raw);
- if (original == raw) return true; // 成功(值和版本都匹配)
- }
- }
复制代码 volatile 告诉编译器和 CPU 两件事:
- 克制缓存:每次读取都必须从主内存(或 CPU 缓存划一性协议包管的最新值)读,每次写入都必须立即对其他线程可见
- 克制重排:不允许把对这个字段的读写利用重排到其他利用的前面或反面(在 .NET 里,volatile 读含有 Acquire 语义,volatile 写含有 Release 语义)
Volatile.Read / Volatile.Write:正确控制
偶然间字段不方便声明为 volatile(好比它在布局体里,大概你想精致控制),可以用:- // 把 sharedFlag 设为 1,返回旧值
- int old = Interlocked.Exchange(ref sharedFlag, 1);
- // 常用于"抢令牌":谁把 0 换成 1,谁就成功
- if (old == 0)
- Console.WriteLine("我抢到了令牌!");
- else
- Console.WriteLine("令牌已被占用");
复制代码 这两个方法非常有效,可以在"平常字段"的局部特定访问点加上内存屏蔽语义,而不消把整个字段声明为 volatile。
⚠️ volatile 的范围性
许多人以为 volatile 能包管线程安全,实在它只包管可见性,不包管原子性:- private static bool _stop = false;
- var worker = new Thread(() =>
- {
- while (!_stop) // ← 这个循环可能永远不会退出!
- {
- // 做一些工作
- }
- });
- worker.Start();
- Thread.Sleep(1000);
- _stop = true; // 主线程设置停止标志
复制代码 记取:
- 必要原子性 → 用 Interlocked
- 必要可见性 → 用 volatile
- 两个都必要 → 用 Interlocked(它隐含了可见性)
🧱 第三关:内存模子 —— CPU 头脑里在想啥?
这一节是本章最烧脑的部分,但也是区分"会写并发代码"和"真正明白并发"的分水岭。许多并发 bug 的根源,就藏在这里。我们一层一层地剥开来讲。
第一层:你写的代码,颠末了几道"变更"才气实行?
大多数人以为:我写的代码 = CPU 实行的代码。现实上,在你的源代码到达 CPU 实行之前,要颠末三道变更:- private static volatile bool _stop = false; // ← 加上 volatile
复制代码 每一道变更都大概重排你的利用序次,只要在单线程视角下"看起来效果一样",它们就以为重排是合法的。
但在多线程下,"另一个线程的视角"这件事,这三层优化都不会主动思量——这就是为什么并发代码必要额外的同步本事。
第二层:CPU 缓存架构 —— 为什么一个核心的写入,另一个核心看不到?
今世 CPU 的存储条理布局(以 8 核 CPU 为例):- // 等价于读一个 volatile 字段
- int flag = Volatile.Read(ref _flag);
- // 等价于写一个 volatile 字段
- 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,然后继承实行反面的指令。- private static volatile int _counter = 0;
- // ❌ volatile 救不了这个!
- // i++ 是三步,volatile 只保证每步读写是"新鲜的",不保证三步合一
- Parallel.For(0, 10, _ =>
- {
- for (int i = 0; i < 100_000; i++)
- _counter++; // 仍然会丢数据!
- });
复制代码 标题:核心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 还是读到旧值!
两者组合:经典的"生产者-消耗者"可见性标题
- 你写的 C# 代码
- ↓ [Roslyn 编译器优化]
- IL 中间代码
- ↓ [JIT / AOT 编译器优化]
- 本机机器码
- ↓ [CPU 乱序执行 + 缓存子系统]
- 实际执行顺序
复制代码 核心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,双向都不允许重排。
- 核心0 核心1 核心2 ... 核心7
- │ │ │ │
- L1缓存 L1缓存 L1缓存 ... L1缓存 ← 每个核心独有,约 32~64 KB,极快
- │ │ │ │
- L2缓存 L2缓存 L2缓存 ... L2缓存 ← 每个核心独有或2核共享,约 256 KB~1 MB
- └────────┴────────┴───────────────┘
- │
- L3 缓存(所有核心共享,约 8~64 MB)
- │
- 主内存(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"关系:- 核心0 执行:
- _data = 42; → 写入 Store Buffer(还没提交到缓存)
- _flag = 1; → 写入 Store Buffer(还没提交到缓存)
- ↓
- ... 一段时间后,RFO 完成 ...
- ↓
- 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 创建,可见性包管。
第六层:实战 —— 亲眼见证内存可见性标题
纸上谈兵不敷,来看一个真实可复现的例子:- 核心0(生产者): 核心1(消费者):
- _data = 42; while (_flag == 0) {}
- _flag = 1; Console.WriteLine(_data); // 可能打印 0!
复制代码 正确写法1:用 volatile- // 正确的"生产者-消费者"模式:
- // 生产者:
- _data = 42;
- Thread.MemoryBarrier(); // ← Store Barrier:把 _data=42 冲刷出 Store Buffer
- _flag = 1;
- // 消费者:
- while (_flag == 0) { }
- Thread.MemoryBarrier(); // ← Load Barrier:处理 Invalidate Queue,确保后续读到最新值
- Console.WriteLine(_data); // ✅ 现在保证能看到 42
复制代码 正确写法2:用 Interlocked(更明白)- 线程A(Release 写) 线程B(Acquire 读)
- _data = 42; ←──────────────── 所有这些写入 │
- _message = "hello"; ◄────────────────────────────── │ happens-before
- Volatile.Write(ref _flag, 1); ─────────────────────► Volatile.Read(ref _flag) == 1
- │ ↓
- _data; // 保证能看到 42
- _message; // 保证能看到 "hello"
复制代码 正确写法3:用 Thread.MemoryBarrier(最显式)- // 没有任何同步的标志位
- private static bool _ready = false;
- private static int _value = 0;
- // 线程A(生产者)
- var producer = new Thread(() =>
- {
- _value = 42; // 先写数据
- _ready = true; // 再设置标志
- });
- // 线程B(消费者)
- var consumer = new Thread(() =>
- {
- while (!_ready) { } // 等待标志
- // ⚠️ 在 Release 模式 + ARM 上,可能打印 0!
- // ⚠️ 在 Debug 模式 + x86 上,通常正常(因为 Debug 模式优化少)
- Console.WriteLine(_value);
- });
复制代码 Thread.MemoryBarrier():显式内存屏蔽的使用场景
既然 volatile 和 Interlocked 已经够用,什么时间才真的必要手写 Thread.MemoryBarrier()?
场景1:对平常数组/布局体字段的批量写入,然后发布一个标志- private static volatile bool _ready = false; // volatile 写 = Release,volatile 读 = Acquire
- private static int _value = 0;
- // 生产者:
- _value = 42;
- _ready = true; // volatile 写,Release 语义,_value=42 的写入不会被重排到这句之后
- // 消费者:
- while (!_ready) { } // volatile 读,Acquire 语义,之后的读取能看到生产者的所有写入
- Console.WriteLine(_value); // ✅ 保证打印 42
复制代码 场景2:精致控制屏蔽位置,制止不须要的 Full Barrier 开销
Interlocked 每次都是 Full Barrier,资本相对较高。偶然只必要单向屏蔽:- private static int _ready = 0;
- private static int _value = 0;
- // 生产者:
- _value = 42;
- Interlocked.Exchange(ref _ready, 1); // Full Barrier,比 volatile 更强
- // 消费者:
- while (Interlocked.CompareExchange(ref _ready, 0, 0) == 0) { } // Full Barrier
- 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 能包管临界区内读写对外正确可见的缘故原由。
用一张图串起来
- // 生产者:
- _value = 42;
- Thread.MemoryBarrier(); // 冲刷 Store Buffer,_value=42 对外可见
- _ready = true;
- // 消费者:
- while (!_ready) { }
- Thread.MemoryBarrier(); // 处理 Invalidate Queue
- Console.WriteLine(_value); // ✅ 保证打印 42
复制代码 只要你正确使用了 .NET 提供的同步原语,这个链条就是完备的,可见性和序次性都有包管。
🏗️ 第四关:用 CAS 搭一个无锁栈
理论讲完了,来个实战。我们用 CAS 实现一个无锁栈,这个算法叫 Treiber Stack(1986 年提出,至今还是经典)。- // 初始化一个大对象的多个字段,然后发布它
- _buffer[0] = 1;
- _buffer[1] = 2;
- _buffer[2] = 3;
- // ... 写了很多字段 ...
- Thread.MemoryBarrier(); // ← 保证上面所有写入对外可见,然后才发布
- 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)既繁琐又轻易堕落:- // 只需要 Release 语义(只需要 Store Barrier)
- Thread.MemoryBarrier(); // 手动插入,比 Interlocked.Exchange 开销小
- _flag = 1;
复制代码 今世写法,用 Lazy:- 你的代码(happens-before 的约束)
- ↕
- .NET 内存模型(volatile/Interlocked/lock 的语义规范)
- ↕
- JIT 编译器(按内存模型规范生成屏障指令)
- ↕
- CPU 硬件(MFENCE/DMB 指令 + MESI 协议保证执行正确性)
- ↕
- 其他线程实际看到的内存状态
复制代码 LazyThreadSafetyMode:三种模式
Lazy 有三种线程安全模式,通过 LazyThreadSafetyMode 摆列控制:
模式阐明实用场景ExecutionAndPublication(默认)加锁,工厂函数只实行一次大多数场景PublicationOnly不加锁,多个线程大概各自实行工厂函数,但只有一个效果会被发布(CAS 竞争)工厂函数无副作用、开销极小None完全不线程安全确定单线程使用时- public class LockFreeStack<T>
- {
- private sealed class Node(T value)
- {
- public readonly T Value = value;
- public Node? Next;
- }
- private volatile Node? _head;
- // 无锁 Push
- public void Push(T value)
- {
- var newNode = new Node(value);
- while (true)
- {
- newNode.Next = _head; // 新节点指向当前头
- // CAS:如果 _head 还是 newNode.Next,就换成 newNode
- if (Interlocked.CompareExchange(ref _head, newNode, newNode.Next) == newNode.Next)
- return; // 成功!
- // 失败了,说明有人抢先改了 _head,重新尝试
- }
- }
- // 无锁 Pop
- public bool TryPop(out T? value)
- {
- while (true)
- {
- Node? head = _head;
- if (head is null) { value = default; return false; }
- // CAS:如果 _head 还是 head,就换成 head.Next
- if (Interlocked.CompareExchange(ref _head, head.Next, head) == head)
- {
- value = head.Value;
- return true; // 成功!
- }
- // 失败了,重新尝试
- }
- }
- }
复制代码 LazyInitializer.EnsureInitialized
假如你不想用 Lazy 包装类,可以用 LazyInitializer.EnsureInitialized 直接初始化字段:- // ❌ 老写法:容易写错
- private static volatile MyService? _instance;
- private static readonly object _lock = new();
- public static MyService Instance
- {
- get
- {
- if (_instance == null)
- {
- lock (_lock)
- {
- if (_instance == null)
- _instance = new MyService();
- }
- }
- return _instance;
- }
- }
复制代码 它的语义是 PublicationOnly:允许多个线程竞争实行工厂函数,CAS 包管只有一个效果被发布。
🚦 什么时间用无锁?什么时间别碰?
讲了这么多,最关键的标题来了:我应该什么时间用这些东西?
✅ 适适用无锁的场景
场景保举工具简朴计数器(高频递增/递减)Interlocked.Increment/Decrement简朴标志位(一写多读)volatile bool单例/耽误初始化Lazy读-改-写的原子利用Interlocked.CompareExchange性能极其敏感的并发数据布局CAS + 自旋,或直接用 .NET 内置的并发聚集❌ 不得当/不必要无锁的场景
- 业务逻辑复杂的临界区:用 lock 或 SemaphoreSlim,正确性比性能更告急
- 没有性能瓶颈的地方:过早优化是万恶之源,先写对,再优化
- 你对内存模子不敷相识的场景:无锁代码的 bug 极难复现和调试,宁肯多加一个锁
一个实用的决定流程:- // ✅ 新写法:一行搞定,默认线程安全
- private static readonly Lazy<MyService> _instance =
- new(() => new MyService());
- public static MyService Instance => _instance.Value;
复制代码 🔬 .NET 10 新特性:System.Threading.Lock
顺带一提,.NET 9/10 引入了新的 System.Threading.Lock 范例,比 lock(object) 更明白、性能更好:- // PublicationOnly 模式:性能略好,但工厂函数可能被调用多次
- var lazy = new Lazy<MyService>(
- () => new MyService(),
- LazyThreadSafetyMode.PublicationOnly
- );
复制代码 固然这不是"无锁",但在不必要无锁的场景里,这是更今世的选择。
📋 本章小结
工具办理的标题留意事项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
|