2026/10/18 2:40:00

内存屏障:给乱序执行立规矩

内存屏障:给乱序执行立规矩 一句话为了快编译器会调换代码顺序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()stoptrue;// 另一个线程调用}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 / ARM64LoadLoad后面的读跑到前面的读之前❌ 不会✅ 会LoadStore后面的写跑到前面的读之前❌ 不会✅ 会StoreStore后面的写跑到前面的写之前❌ 不会✅ 会StoreLoad后面的读跑到前面的写之前✅会✅ 会x86TSO较强只允许 StoreLoad 重排 → 写缓冲导致 ARM较弱 四种都允许 → 写缓冲 失效队列 更激进的乱序这就是为什么同一份无锁代码在 PC 上跑几个月都没事一到手机上就偶发出错。PC 只是不容易出错不是写对了。四、两个经典实验亲眼看到乱序实验一发布数据StoreStore LoadLoadintdata0;boolreadyfalse;// 线程 A生产者data42;readytrue;// 线程 B消费者if(ready)Console.WriteLine(data);// 可能打印 0平台会不会出错原因x86硬件层面不会但编译器仍可能重排x86 不做 StoreStore、LoadLoad 重排ARM会ready可能比data先送达或者 B 先读了data的旧缓存实验二互相打招呼StoreLoad——连 x86 都会中招intx0,y0;intr1,r2;// 线程 A // 线程 Bx1;y1;r1y;r2x;直觉上至少有一个线程能看到对方的写入不可能出现r1 0 r2 0。但在x86 上也会出现线程 Ax 1 放进写缓冲 …… 读 y得到 0 线程 By 1 放进写缓冲 …… 读 x得到 0 两个写缓冲随后才清空可两人都已经读完了就像两个人同时写信给对方说我到了又同时去查信箱信都还在邮递员手里两人都以为对方没到。必须在写和读之间加全屏障x1;Thread.MemoryBarrier();// 先把写缓冲清空再往下读r1y;这个模式出现在 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 指令Cstd::atomic_signal_fence、GCCasm volatile( ::: memory)CPU 内存屏障同时约束编译器和 CPUmfence、dmb ish设备 / DMA 屏障约束 CPU 和外设之间的顺序驱动程序中写设备寄存器前的dsb指令屏障清空流水线让新写入的代码生效ARM 的isbJIT 生成代码后需要六、屏障在硬件上到底做了什么屏障不是把缓存刷到内存而是处理那两个导致乱序的部件屏障硬件动作比喻写屏障后面的写要等写缓冲中前面的写完成或打上标记按顺序提交发件篮里的信寄完新信才能寄读屏障先处理完失效队列再执行后面的读先看完作废通知再翻资料全屏障清空写缓冲 处理失效队列 阻止后续读写提前发件篮清空、通知看完、才继续工作没有屏障 核心 1写 x → [写缓冲x1] → 读 y直接执行不等 有全屏障 核心 1写 x → [写缓冲x1] → ███ 等待写缓冲清空 ███ → 读 y不同平台上的具体指令语义x86 / x64ARM64全屏障mfence或带LOCK前缀的指令常用lock or [rsp], 0dmb ish写-写屏障通常不需要普通写本来就有序dmb ishst读屏障通常不需要dmb ishldacquire 读普通movldar或ldaprrelease 写普通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){datavalue;Volatile.Write(refready,true);// releasedata 先于 ready 可见}publicboolTryRead(outintvalue){if(Volatile.Read(refready))// acquire看到 ready 就一定能看到 data{valuedata;returntrue;}value0;returnfalse;}}无锁那篇的 SPSC 环形队列、原子替换配置引用的热更新都是这个模式。作用二锁本身就包含屏障很多人不知道锁之所以能保护数据不只是因为互斥还因为它自带屏障。lock(obj) { ← 加锁 acquire之后读到的一定是上一个持锁者写的最新值 count; total x; } ← 解锁 release这里面的所有写一定在下一个人进来前可见如果锁只做到互斥而没有屏障下一个线程进来时可能看到的还是旧的count。所以用锁的代码不需要再手动加屏障。作用三双重检查锁定单例classAssetCache{staticvolatileAssetCacheinstance;// 关键在这个 volatilestaticreadonlyobjectgatenewobject();publicstaticAssetCacheInstance{get{varinstinstance;// acquire 读if(inst!null)returninst;lock(gate){if(instancenull)instancenewAssetCache();// release 写returninstance;}}}readonlyDictionarystring,objecttablenew();}如果没有屏障instance new AssetCache()可能被拆成① 分配内存 ② 把地址写进 instance ← 被提前了 ③ 执行构造函数初始化 table另一个线程在 ② 和 ③ 之间看到instance ! null拿到一个还没构造完的对象访问table时直接出错。实际项目中更推荐直接使用LazyT或静态只读字段初始化让运行时替你处理。作用四互相等待的协议必须用全屏障// 两个线程都想进入临界区先举手再看对方有没有举手volatileboolwantA,wantB;// 线程 AwantAtrue;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写为 releaseThread.MemoryBarrier()/Interlocked.MemoryBarrier()全屏障Interlocked.*原子操作 全屏障lock/Monitor进入为 acquire退出为 release⚠️ C# 的volatile不能阻止 StoreLoad 重排一次 volatile 写后紧跟一次 volatile 读仍然可能被调换。需要时用Thread.MemoryBarrier()。C#includeatomicstd::atomicboolready{false};intdata0;// 生产者data42;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仍然会丢更新两者都有但设计上竞争激烈正确但缓存行乒乓比锁还慢两者都有竞争也少✅ 这才是好的并发代码