☰
内存屏障:给乱序执行立规矩
2026/10/18 2:39:55 网站建设 项目流程

一句话:为了快,编译器会调换代码顺序,CPU 会乱序执行,写操作会先进"发件箱"而不是立刻送达。单线程看不出区别,多线程之间就会出现"旗子已经举起来,数据还没到"的怪事。内存屏障就是一道"在此之前的事,必须先办完"的规矩。它不会让数据更快送达,只保证别人看到的先后顺序和你想要的一致。

前两篇讲无锁结构和原子操作时,多次提到 acquire、release 和"屏障"。这一篇专门讲清楚三件事:为什么会乱序、屏障到底做了什么、什么时候必须用它。


一、先打个比方:快递员和公告栏

你是公司的行政,要通知全员"新的排班表已经贴在 3 号柜里"。

你的操作顺序是:

  1. 把排班表放进 3 号柜
  2. 在群里发消息:“排班表已更新,去 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 / x64ARM / 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 内存屏障同时约束编译器和 CPUmfence、dmb ish
设备 / DMA 屏障约束 CPU 和外设之间的顺序驱动程序中写设备寄存器前的dsb
指令屏障清空流水线,让新写入的代码生效ARM 的isb,JIT 生成代码后需要

六、屏障在硬件上到底做了什么

屏障不是"把缓存刷到内存",而是处理那两个导致乱序的部件:

屏障硬件动作比喻
写屏障后面的写要等写缓冲中前面的写完成(或打上标记,按顺序提交)发件篮里的信寄完,新信才能寄
读屏障先处理完失效队列,再执行后面的读先看完作废通知,再翻资料
全屏障清空写缓冲 + 处理失效队列 + 阻止后续读写提前发件篮清空、通知看完、才继续工作
没有屏障: 核心 1:写 x → [写缓冲:x=1] → 读 y(直接执行,不等) 有全屏障: 核心 1:写 x → [写缓冲:x=1] → ███ 等待写缓冲清空 ███ → 读 y

不同平台上的具体指令

语义x86 / x64ARM64
全屏障mfence,或带LOCK前缀的指令(常用lock or [rsp], 0)dmb ish
写-写屏障通常不需要(普通写本来就有序)dmb ishst
读屏障通常不需要dmb ishld
acquire 读普通movldar(或ldapr)
release 写普通movstlr
原子读-改-写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.Readacquire 读
Volatile.Writerelease 写
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++仍然会丢更新
两者都有,但设计上竞争激烈正确,但缓存行乒乓,比锁还慢
两者都有,竞争也少✅ 这才是好的并发代码

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询