一句话:为了快,编译器会调换代码顺序,CPU 会乱序执行,写操作会先进"发件箱"而不是立刻送达。单线程看不出区别,多线程之间就会出现"旗子已经举起来,数据还没到"的怪事。内存屏障就是一道"在此之前的事,必须先办完"的规矩。它不会让数据更快送达,只保证别人看到的先后顺序和你想要的一致。
前两篇讲无锁结构和原子操作时,多次提到 acquire、release 和"屏障"。这一篇专门讲清楚三件事:为什么会乱序、屏障到底做了什么、什么时候必须用它。
一、先打个比方:快递员和公告栏
你是公司的行政,要通知全员"新的排班表已经贴在 3 号柜里"。
你的操作顺序是:
- 把排班表放进 3 号柜
- 在群里发消息:“排班表已更新,去 3 号柜取”
但你的快递员很"高效":
- 送 3 号柜要绕远路,群消息顺手就发了,于是先发了群消息
- 同事小李看到消息就去开柜子,里面还是旧排班表
小李那边也有问题:
- 他的桌上堆着一摞"作废通知",没来得及看
- 柜子里其实已经换了,可他手里拿的还是之前抄下来的旧副本
| 办公室 | 计算机 |
|---|---|
| 你写下的步骤顺序 | 程序代码的顺序 |
| 快递员为了效率调换送件顺序 | 编译器重排、CPU 乱序执行、写缓冲 |
| 小李桌上没看的作废通知 | 失效队列(Invalidate Queue) |
| 小李手里的旧副本 | 缓存中过期的数据 |
| 规定"柜子没放好,不准发群消息" | 写屏障 / release |
| 规定"看到群消息后,先处理完作废通知再开柜子" | 读屏障 / acquire |
内存屏障不是"加速器",而是"顺序规矩"。它告诉编译器和 CPU:这条线前后的事情,不许随意调换。
二、谁在"打乱顺序":三个嫌疑人
嫌疑人一:编译器
编译器只保证单线程下结果不变,它完全可以调换、合并、甚至删除内存访问。
最常见、也最吓人的例子:
classWorker{boolstop;// 普通字段publicvoidRun(){while(!stop)// Release 构建下可能变成:if (!stop) while (true) {}{DoWork();// 若 DoWork 很简单、能被内联,编译器更容易这样优化}}publicvoidStop()=>stop=true;// 另一个线程调用}JIT 觉得"循环里没人改stop",就把读取提到循环外面,结果另一个线程怎么改都停不下来。Debug 下正常,Release 下死循环,这是经典的"只在正式包里出现的 Bug"。
volatileboolstop;// 方法一:volatile 字段while(!Volatile.Read(refstop)){…}// 方法二:每次都用 Volatile.Read嫌疑人二:CPU 乱序执行
现代 CPU 像一个高效的后厨:前一道菜要等炖肉,就先去炒后面的青菜。只要本线程看到的结果一致,指令就可以不按顺序执行。
嫌疑人三:写缓冲和失效队列(真正的硬件根源)
这一点要结合伪共享那篇讲过的 MESI 协议来看。
写缓冲(Store Buffer):桌上的"发件篮"
核心 1 要写变量 x: x 所在缓存行在核心 2 那里 → 要先发请求,等对方让出独占权(几十到上百个周期) CPU 不想干等 → 先把"x = 1"放进写缓冲,继续往下执行 核心 1 自己读 x → 能从写缓冲里读到 1(存储转发) 核心 2 读 x → 写缓冲里的东西它看不到,读到的还是旧值失效队列(Invalidate Queue):没拆的"作废通知"
核心 1 通知核心 2:"你那份 x 作废了" 核心 2 很忙 → 先回复"收到",把通知放进失效队列,稍后再处理 在处理之前 → 核心 2 读 x,读到的还是自己缓存里的旧副本| 硬件部件 | 好处 | 带来的乱序 |
|---|---|---|
| 写缓冲 | 写操作不用等缓存行到手 | 写比后面的读"晚到"(StoreLoad) |
| 非先进先出的写缓冲(ARM 等) | 先拿到缓存行的先写 | 写和写之间顺序颠倒(StoreStore) |
| 失效队列 | 不用立刻处理作废通知 | 读到过期数据(LoadLoad) |
| 乱序执行、推测执行 | 不让流水线空转 | 各种读写交错 |
乱序不是 Bug,而是性能优化的副作用。如果每次读写都老老实实排队,CPU 的速度可能会掉到几分之一。所以硬件选择"默认乱序,需要时你来喊停",喊停的方式就是内存屏障。
三、四种重排
两个内存操作之间,按"读(Load)"和"写(Store)"组合,有四种可能被调换的情况:
| 重排类型 | 含义 | x86 / x64 | ARM / ARM64 |
|---|---|---|---|
| LoadLoad | 后面的读跑到前面的读之前 | ❌ 不会 | ✅ 会 |
| LoadStore | 后面的写跑到前面的读之前 | ❌ 不会 | ✅ 会 |
| StoreStore | 后面的写跑到前面的写之前 | ❌ 不会 | ✅ 会 |
| StoreLoad | 后面的读跑到前面的写之前 | ✅会 | ✅ 会 |
x86(TSO,较强):只允许 StoreLoad 重排 → 写缓冲导致 ARM(较弱): 四种都允许 → 写缓冲 + 失效队列 + 更激进的乱序这就是为什么同一份无锁代码,在 PC 上跑几个月都没事,一到手机上就偶发出错。PC 只是"不容易出错",不是"写对了"。
四、两个经典实验:亲眼看到乱序
实验一:发布数据(StoreStore + LoadLoad)
intdata=0;boolready=false;// 线程 A(生产者)data=42;ready=true;// 线程 B(消费者)if(ready)Console.WriteLine(data);// 可能打印 0| 平台 | 会不会出错 | 原因 |
|---|---|---|
| x86 | 硬件层面不会,但编译器仍可能重排 | x86 不做 StoreStore、LoadLoad 重排 |
| ARM | 会 | ready可能比data先送达;或者 B 先读了data的旧缓存 |
实验二:互相打招呼(StoreLoad)——连 x86 都会中招
intx=0,y=0;intr1,r2;// 线程 A // 线程 Bx=1;y=1;r1=y;r2=x;直觉上,至少有一个线程能看到对方的写入,不可能出现r1 == 0 && r2 == 0。
但在x86 上也会出现:
线程 A:x = 1 放进写缓冲 …… 读 y,得到 0 线程 B:y = 1 放进写缓冲 …… 读 x,得到 0 两个写缓冲随后才清空,可两人都已经读完了就像两个人同时写信给对方说"我到了",又同时去查信箱,信都还在邮递员手里,两人都以为对方没到。
必须在写和读之间加全屏障:
x=1;Thread.MemoryBarrier();// 先把写缓冲清空,再往下读r1=y;这个模式出现在 Dekker、Peterson 互斥算法,以及"先举手再看别人有没有举手"的各种协议里。StoreLoad 是唯一需要"重型屏障"的情况。
五、屏障的种类:几种不同的"关卡"
按约束方向分
| 屏障 | 约束 | 比喻 |
|---|---|---|
| 全屏障(Full Fence) | 前面的所有读写,必须在后面的所有读写之前完成 | 全员集合点:之前的事全部办完,才能出发 |
| 写屏障(Store Fence) | 前面的写,必须在后面的写之前可见 | 先寄的信先到 |
| 读屏障(Load Fence) | 前面的读,必须在后面的读之前完成 | 先拆完作废通知,再看资料 |
| 获取(Acquire) | 这次读之后的读写,不能跑到它之前 | 进场闸机:进了门才能逛 |
| 释放(Release) | 这次写之前的读写,不能跑到它之后 | 出场闸机:东西都收好才能出门 |
Acquire 和 Release:两道单向门
前面的操作可以往后"漏"进来 ↓ ─────────── acquire 读 ─────────── ← 后面的操作不许往前跑 ↑ 后面的操作被挡住 前面的操作被挡住 ↓ ─────────── release 写 ─────────── ← 前面的操作不许往后跑 ↑ 后面的操作可以往前"漏"进来单向是关键:它们只挡一个方向,比全屏障便宜很多,而且成对使用刚好够用:
线程 A: 线程 B: 写 data 写 data2 release 写 ready = true ── 同步 ──→ acquire 读 ready == true 读 data ← 一定能看到 42 读 data2记住这个画面:release 是"打包封箱",acquire 是"签收开箱"。一方封箱、另一方签收,箱子里的东西就一定完整。
按作用对象分
| 类型 | 作用 | 例子 |
|---|---|---|
| 编译器屏障 | 只阻止编译器重排,不生成 CPU 指令 | C++std::atomic_signal_fence、GCCasm volatile("" ::: "memory") |
| CPU 内存屏障 | 同时约束编译器和 CPU | mfence、dmb ish |
| 设备 / DMA 屏障 | 约束 CPU 和外设之间的顺序 | 驱动程序中写设备寄存器前的dsb |
| 指令屏障 | 清空流水线,让新写入的代码生效 | ARM 的isb,JIT 生成代码后需要 |
六、屏障在硬件上到底做了什么
屏障不是"把缓存刷到内存",而是处理那两个导致乱序的部件:
| 屏障 | 硬件动作 | 比喻 |
|---|---|---|
| 写屏障 | 后面的写要等写缓冲中前面的写完成(或打上标记,按顺序提交) | 发件篮里的信寄完,新信才能寄 |
| 读屏障 | 先处理完失效队列,再执行后面的读 | 先看完作废通知,再翻资料 |
| 全屏障 | 清空写缓冲 + 处理失效队列 + 阻止后续读写提前 | 发件篮清空、通知看完、才继续工作 |
没有屏障: 核心 1:写 x → [写缓冲:x=1] → 读 y(直接执行,不等) 有全屏障: 核心 1:写 x → [写缓冲:x=1] → ███ 等待写缓冲清空 ███ → 读 y不同平台上的具体指令
| 语义 | x86 / x64 | ARM64 |
|---|---|---|
| 全屏障 | mfence,或带LOCK前缀的指令(常用lock or [rsp], 0) | dmb ish |
| 写-写屏障 | 通常不需要(普通写本来就有序) | dmb ishst |
| 读屏障 | 通常不需要 | dmb ishld |
| acquire 读 | 普通mov | ldar(或ldapr) |
| release 写 | 普通mov | stlr |
| 原子读-改-写 | lock xadd、lock cmpxchg(自带全屏障) | ldaddal、casal,或 LL/SC 配合 |
两个值得注意的现象:
- x86 上 acquire 和 release 几乎免费,只需要阻止编译器重排,所以很多代码在 PC 上"不加也能跑";
- x86 上
lfence/sfence主要用于特殊场景(非临时写、推测执行防护等),普通多线程同步基本不用。
七、代价:屏障也不是免费的
| 屏障 | 大致代价 |
|---|---|
| 编译器屏障 | 不生成指令,但可能阻止一些优化 |
| x86 acquire / release | 几乎为零 |
ARMldar/stlr | 比普通读写稍慢 |
全屏障(mfence、dmb ish) | 几十到上百个周期,写缓冲越满越慢 |
| 带锁的原子指令 | 同上,并且竞争时还有缓存行传递成本 |
所以原则是:够用就好。
需要"发布 / 订阅" → release 写 + acquire 读 ← 大多数情况 需要"先写再读别人" → 全屏障 ← 少数情况(StoreLoad) 不确定 → 用锁或 Interlocked ← 慢一点,但不会错八、具体作用:屏障在哪些地方默默工作
作用一:安全地发布数据
usingSystem.Threading;classSnapshot{intdata;boolready;publicvoidPublish(intvalue){data=value;Volatile.Write(refready,true);// release:data 先于 ready 可见}publicboolTryRead(outintvalue){if(Volatile.Read(refready))// acquire:看到 ready 就一定能看到 data{value=data;returntrue;}value=0;returnfalse;}}无锁那篇的 SPSC 环形队列、"原子替换配置引用"的热更新,都是这个模式。
作用二:锁本身就包含屏障
很多人不知道:锁之所以能保护数据,不只是因为互斥,还因为它自带屏障。
lock(obj) { ← 加锁 = acquire:之后读到的一定是上一个持锁者写的最新值 count++; total += x; } ← 解锁 = release:这里面的所有写,一定在下一个人进来前可见如果锁只做到"互斥"而没有屏障,下一个线程进来时,可能看到的还是旧的count。所以用锁的代码不需要再手动加屏障。
作用三:双重检查锁定(单例)
classAssetCache{staticvolatileAssetCacheinstance;// 关键在这个 volatilestaticreadonlyobjectgate=newobject();publicstaticAssetCacheInstance{get{varinst=instance;// acquire 读if(inst!=null)returninst;lock(gate){if(instance==null)instance=newAssetCache();// release 写returninstance;}}}readonlyDictionary<string,object>table=new();}如果没有屏障,instance = new AssetCache()可能被拆成:
① 分配内存 ② 把地址写进 instance ← 被提前了! ③ 执行构造函数,初始化 table另一个线程在 ② 和 ③ 之间看到instance != null,拿到一个还没构造完的对象,访问table时直接出错。
实际项目中更推荐直接使用
Lazy<T>或静态只读字段初始化,让运行时替你处理。
作用四:互相等待的协议(必须用全屏障)
// 两个线程都想进入临界区,先"举手"再看对方有没有举手volatileboolwantA,wantB;// 线程 AwantA=true;Thread.MemoryBarrier();// 必须是全屏障:防止"读 wantB"跑到"写 wantA"之前if(!wantB){/* 进入 */}这里 acquire / release 不够用,因为要阻止的正是StoreLoad重排。
作用五:Unity 中的隐式屏障
| 场景 | 屏障在哪里 |
|---|---|
JobHandle.Complete()后主线程读取NativeArray | 调度器内部的同步已经包含屏障 |
| Job 之间通过依赖链传递数据 | 前一个 Job 结束、后一个开始时自动同步 |
| 主线程 → 音频线程的命令队列 | 需要自己处理,用Volatile或Interlocked |
| 后台加载线程 → 主线程的完成标志 | 需要自己处理 |
ConcurrentQueue等并发容器 | 容器内部已处理 |
一个好的习惯:线程之间只通过"自带屏障"的东西交换数据,比如锁、并发容器、Job 依赖、
Volatile/Interlocked。普通字段一律不跨线程共享。
作用六:CPU 与外部设备之间
屏障的思想不止存在于 CPU 核心之间:
| 场景 | 屏障的作用 |
|---|---|
| 驱动程序写设备寄存器 | 先把数据写入共享内存,再"按门铃"通知设备 |
| CPU 与 GPU 共享缓冲区 | 先写完数据,再提交命令 |
| GPU 内部(Vulkan / D3D12 的 Barrier) | 先写完纹理,再允许下一个阶段读取 |
| JIT 生成机器码 | 写完代码后需要指令屏障,CPU 才会执行新代码 |
名字一样,思想一样:“之前的写,必须在之后的读之前完成。”
九、各语言里的"屏障工具箱"
C#
| 工具 | 语义 |
|---|---|
Volatile.Read | acquire 读 |
Volatile.Write | release 写 |
volatile字段 | 读为 acquire,写为 release |
Thread.MemoryBarrier()/Interlocked.MemoryBarrier() | 全屏障 |
Interlocked.* | 原子操作 + 全屏障 |
lock/Monitor | 进入为 acquire,退出为 release |
⚠️ C# 的
volatile不能阻止 StoreLoad 重排:一次 volatile 写后紧跟一次 volatile 读,仍然可能被调换。需要时用Thread.MemoryBarrier()。
C++
#include<atomic>std::atomic<bool>ready{false};intdata=0;// 生产者data=42;ready.store(true,std::memory_order_release);// 消费者if(ready.load(std::memory_order_acquire))use(data);// 独立的屏障std::atomic_thread_fence(std::memory_order_seq_cst);// 全屏障std::atomic_signal_fence(std::memory_order_seq_cst);// 仅编译器屏障| C++ 内存顺序 | 作用 |
|---|---|
relaxed | 只保证原子性,不约束顺序 |
acquire | 读操作,之后的不许往前 |
release | 写操作,之前的不许往后 |
acq_rel | 读-改-写操作,两边都管 |
seq_cst | 最强,所有线程看到同一个全局顺序(默认值) |
⚠️ C++ 的
volatile和 C# 的完全不同:它既不保证原子性,也不保证线程间顺序,主要用于硬件寄存器。多线程同步请用std::atomic。
十、常见误解
| 误解 | 实际情况 |
|---|---|
| “屏障会把数据立刻刷到内存” | 屏障约束的是顺序,不是速度;缓存一致性协议本来就保证数据最终可见 |
| “加了屏障,别的线程马上就能看到” | 屏障只保证"如果看到了 B,就一定能看到 A",不保证"什么时候看到" |
| “只在写的一方加屏障就够了” | 屏障要成对:发布方 release,读取方 acquire,缺一不可 |
“屏障能让i++变成原子的” | 屏障管顺序,不管原子性(上一篇的"原子管插队,屏障管顺序") |
| “x86 上测过没问题就是对的” | x86 内存模型强,掩盖了大部分问题;到 ARM 设备上才会暴露 |
| “屏障越多越安全” | 过多的全屏障会明显拖慢性能,应该按需选择最弱且足够的语义 |
| “单线程程序也需要屏障” | 单线程永远看到正确的顺序,只有跨线程(或跨设备)才需要 |
十一、调试:为什么这类 Bug 这么难抓
| 难点 | 原因 |
|---|---|
| 极难复现 | 需要特定的时序、特定的缓存状态,可能百万次才出现一次 |
| 加日志就消失 | 日志本身通常带锁和屏障,改变了时序(“海森堡 Bug”) |
| PC 上不出现 | x86 内存模型强 |
| Debug 版不出现 | 编译器优化少 |
应对方法:
| 方法 | 说明 |
|---|---|
| 在 ARM 设备上做压力测试 | 手机、Apple M 系列 Mac |
| 使用 Release 构建 | 让编译器优化生效 |
| 工具检测 | C++ 用 ThreadSanitizer;形式化验证可以用 CDSChecker、herd7 等 litmus 测试工具 |
| 代码审查时问一句 | “这个字段跨线程了吗?它靠什么保证顺序?” |
| 最根本的办法 | 少写自定义无锁代码,优先使用成熟的并发容器和 Job 系统 |
一句话总结
为了快,编译器会调换顺序,CPU 会乱序执行,写操作会先进"发件篮",作废通知会先堆在"桌上"。单线程看不出任何问题,跨线程就会看到"旗子举起来了,数据还没到"。内存屏障就是那条"之前的事必须先办完"的规矩。
问题 回答 谁在打乱顺序 编译器、CPU 乱序执行、写缓冲、失效队列 屏障管什么 顺序,不管速度,也不管原子性 最常用的组合 发布方release 写,读取方acquire 读,成对使用 什么时候要全屏障 先写自己的、再读别人的(StoreLoad),比如"举手再看别人举没举手" x86 和 ARM 的区别 x86 只会 StoreLoad 重排;ARM 四种都会,PC 上没问题不代表手机上没问题 哪些东西自带屏障 锁、 Interlocked、Volatile、并发容器、Job 依赖、JobHandle.Complete()最安全的做法 普通字段不跨线程共享;跨线程只通过自带屏障的工具交换数据 口诀:“先封箱再贴条(release),先签收再开箱(acquire);先写后读要全停(full fence);屏障管顺序不管快,成对出现才算数;PC 跑通不算对,手机上面见真章。”
附:三篇连起来看
前三篇其实是同一件事的三个侧面:
┌──────────────────────────────────────────────────────────┐ │ 无锁数据结构 用 CAS 循环、序号、互相帮忙,搭出不阻塞的容器 │ │ ↑ 依赖下面两样 │ ├──────────────────────────────────────────────────────────┤ │ 原子操作 / 原子指令 保证"一个变量的修改不会被插队" │ │ (原子管插队) │ ├──────────────────────────────────────────────────────────┤ │ 内存屏障 保证"多个变量的修改,别人按正确顺序看到" │ │ (屏障管顺序) │ ├──────────────────────────────────────────────────────────┤ │ 缓存一致性(MESI) 保证"同一个缓存行,最终大家看到的是同一个值" │ │ 写缓冲、失效队列是它的性能优化,也是乱序的根源 │ └──────────────────────────────────────────────────────────┘| 只做到 | 会出现的问题 |
|---|---|
| 有原子性,没有屏障 | 计数器是对的,但"数据和标志"之间顺序错乱 |
| 有屏障,没有原子性 | 顺序是对的,但count++仍然会丢更新 |
| 两者都有,但设计上竞争激烈 | 正确,但缓存行乒乓,比锁还慢 |
| 两者都有,竞争也少 | ✅ 这才是好的并发代码 |