并发编程系列(二)—— store, load 与 RMW
tore buffer 与 L1/L2/L3 cache注意store buffer 不是缓存它属于 CPU 而不是缓存系统。不同 CPU 都有 store buffer 吗现代高性能的超标量处理器几乎都配备了 Store Buffer可能叫法不同但都有类似的“东西”和机制。什么是“写完成”store操作完成的标志是什么如果只是写到 store buffer那么在架构层面上Architecturally对全局依然是“未发生”的因为store完成的标志是“写入缓存让别人看见”。Store-Load 重排先看一个例子初始值:x 0, y 0thread 1 thread2:x.store(1) -- A y.store(1) -- Ca y.load() -- B b x.load() -- D时序1. thread 1 对 x 执行写操作但只是把 1 写入 store buffer2. 同时thread 2 对 y 执行写操作但也只是把 1 写入 store buffer3. thread 1 在没有把 store buffer 刷入 L1 cache 的前提下就率先读了 y 的值Store-Load 重排而此时thread 2 对 y 的写也还停留在 store buffer对 thread 1 不可见于是thread 1 读到的 y 值是 0。4. 同理thread 2 读到的 x 值也是 0。5. thread 1 和 thread 2 把各自的store buffer 刷到了 L1 cache但为时已晚因为 load先一步执行了a 0 b 0 的事实已经铸就。基于代码顺序有 A -- B 并且 C -- D。从 thread 1 的视角看既然它读到了y的旧值那么有 B-- C。从 thread 2 的视角看既然它读到了x的旧值那么有 D -- A。从 thread 1 的视角看总体顺序是 A -- B -- C -- D从 thread 2 的视角看总体顺序是 C -- D -- A-- B即不同线程看到的全局顺序不一样并且两者结合起来形成了 A-- B -- C --D -- A 这样的环。RMW (atomic read-modify-write operation) 和load的区别从“自我”的视角看RMW / load 都是从 cache 或 main memory 中读取至少在读到的那一刻它们看到的都是最新的值。如果在读的一瞬间其它线程修改了同一个原子变量但只写到了 store buffer那么从架构层面上看这个“写”并没有完成所以不算“最新值”。因此这种情况不影响第1条中“两者都读到最新值”的结论。两者的区别在于“对他人的影响”上。RMW 会先通过 RFO (Read For Ownership) 请求要求其它核心交出最新值并作废自己 cache 中的副本。在“读”动作完成后RMW 会锁住对应的 cache line 直到整个 RMW 操作完成这期间别的线程既不能读更不能写这条 cache line。普通 load 只是自己读不干涉别人它读的时候别人可能也在读在它刚读完的一瞬间别人就可能修改了这个值。RMW 和store的区别store可能会将数据放入 store buffer导致延迟可见但 RMW 一定不会。当 CPUstore某个变量时如果该变量所在的 Cache Line 不在 L1 Cache 中或者状态不是 Exclusive / Modified味着其他核心也缓存了该数据需要通过总线发送 Invalidate 消息让其他核心作废缓存CPU 必须向总线发送信号获取该缓存行的所有权。等待缓存行返回需要几百个时钟周期为了提效CPU 会把要写入的值暂存进 Store Buffer然后继续执行下一条指令。此时这个写操作对其他 CPU 是不可见的直到 Store Buffer 最终将数据刷入 L1 缓存。这就是普通 store 导致延迟可见的物理根源。RMW 在读取阶段就已经独占了 cache line写入的数据会直接放入 cache line。RMW 执行完成释放 cache line 锁的那一刻数据就全局可见了。RMW 会产生全内存屏障 (full memory barrier) 吗x86 会ARM 不会。x86 下RMW 会被翻译成lock前缀或者隐式lock前缀就是不显式携带lock但有lock的功能的指令例如x86-64下fetch_add被翻译成lock xadd指令。无论是显式 lock比如 lock xadd还是隐式 lock都会生成全内存屏障 (Full Memory Barrier)即在当前CPU core上在 lock 指令执行之前的所有读写操作必须在 lock 指令执行完毕前完成会强制将当前核心的 store buffer 刷入 L1 Cache确保 lock 之前的读写操作对其它核心都可见。在 lock 指令之后的所有读写操作必须等 lock 指令完成后才能开始。ARM 采用弱内存模型硬件有更大的自由度。比如data.fetch_add(1, std::memory_order_release)会被翻译成ldxr和stlxr的组合详见下文只具备 release 语义不具备 acquire 语义。即只能保证fetch_add前面的指令不会被重排到fetch_add后面但不能保证fetch_add后面的指令一定不会跑到fetch_add的前面。full memory barrier 的理解是双向的。即 前向屏障前面的指令不能越过 会产生 full memory barrier 的指令比如 lock xadd 后向屏障后面的指令不能越过 即1. lock xadd 前的指令不能排到 lock xadd 后面换言之在 lock xadd 被 CPU 执行之前它前面的指令都必须被CPU执行完且对全局可见。2. lock xadd 后的指令不能排到 lock xadd 前面换言之在 lock xadd 被 CPU 执行之前它后面的指令都不能先于它被 CPU 执行。store/load/ RMW 对应的汇编指令原子变量以std::atomiclong long data{0}为例RMW 操作以fetch_add为例编译器为 GCC 15.2通过 Compiler Explorer 来观察生成的汇编代码。指令内存序X86_64ARM64ARM64 (-mno-outline-atomics)[1]store[2]relaxedmovqstrstrreleasestlrstlrseq_cstxchgqload[3]relaxedmovqldrldracquireldarldarseq_cstfetch_addrelaxedlock xadd__aarch64_ldadd8_relaxldxrstxrrelease__aarch64_ldadd8_relldxrstlxracquire__aarch64_ldadd8_acqldaxrstxracq_rel__aarch64_ldadd8_acq_relldaxrstlxrseq_cst备注ARM 架构下默认编译成内置函数加上-mno-outline-atomics参数后编译成汇编指令。store只支持这3中内存序若使用其它内存序则是未定义行为。load只支持这3中内存序若使用其它内存序则是未定义行为。x86_64 体系下的store/load/fetch_addstore(relaxed)和store(release)为什么两者都翻译成movqmov 指令天然的原子性x86_64 采用TSO (Total Store Order)这种强内存模型对自然对齐的32位、64位内存进行读写mov 指令movl、movq等在硬件层面就保证了原子性。架构天然的 release 语义什么是 release 语义当前线程内store操作前的任何读写包括非原子变量的读写都不能被重排到该store操作的后面。为什么天然背景先要搞懂什么是 CPU 的乱序执行指令重排。汇编代码中是指令 A 在前指令 B 在后而 CPU 在实际执行是可能先执行指令 B后执行指令A这就是所谓的 CPU 乱序执行。如前所述x86_64 采用TSO强内存模型在硬件层面就禁止了下面这两种重排禁止 Load-Store 重排如果汇编代码是 读操作 A -- 写操作 B那么CPU在执行时绝不可能先执行 B 后执行 A。禁止 Store-Store 重排如果汇编代码是 写操作 A -- 写操作 B那么CPU在执行时绝不可能先执行 B 后执行 A。综上写操作前面的读写操作都不能跑到该写操作的后面天然满足 release 语义。特别注意release 语义是由 x86 体系结构硬件保证的不是由 mov 指令保证的mov 指令只能保证原子性但该指令本身不禁止任何重排。两者等价吗不等价。如上两者翻译成相同的汇编指令只能说是在CPU 层面硬件层面等价。但在编译器层面软件层面不会等价。从源代码到程序执行进程/线程可以简化成下面的模型只为说明问题所以只列出关键节点不要扣细节上的对错源代码 --- 编译器进行汇编 --- 汇编指令 --- CPU 执行 --- 程序跑起来进程 / 线程前面说过CPU执行指令时可能不会严格按照开发者看到的汇编代码的顺序执行。类似的从源代码到汇编指令这一步编译器也可能会重排。比如int data 0;std::atomicint flag{0};// 情况 A使用 releasevoid producer_release() {data 42; // (1) 普通写flag.store(1, std::memory_order_release); // (2) 原子写}// 情况 B使用 relaxedvoid producer_relaxed() {data 42; // (3) 普通写flag.store(1, std::memory_order_relaxed); // (4) 原子写}对于 producer_release()编译器知道 (2) 是 release 语义它必须保证data 42在flag 1之前发生。生成的 x86 汇编大概是这样mov DWORD PTR data[rip], 42 ; 先写 datamov DWORD PTR flag[rip], 1 ; 后写 flag (release)对于 producer_relaxed()因为是 relaxed编译器发现data和flag是没有数据依赖的两个变量为了优化寄存器分配或者流水线效率编译器完全有可能把代码重排成这样mov DWORD PTR flag[rip], 1 ; 编译器擅自把写 flag 提前了(relaxed 允许这么做)mov DWORD PTR data[rip], 42 ; 后写 data所以对开发者而言如果想要 release 语义就老老实实指定std::memory_order_release。load(relaxed)和load(acquire)和store的情况类似两者翻译成movq的原因是 TSO 内存模型也禁止 Load-Load 重排。因此TSO 内存模型禁止 Load-Load 和 Load-Store 重排 --读指令后面的任何读写指令都不可能排到该读指令前面 --天然满足 acquire 语义同样地两者只是在 CPU 层面等价但因为编译器重排的存在两者整体上不等价。store(seq_cst)和load(seq_cst)为什么两者翻译成的汇编代码不一样先说语义seq_cst作用在store上具有 release 语义。store本身就不可能有 acquire语义seq_cst作用在load上具有 acquire 语义。除此之外seq_cst还保证程序中所有标记为**seq_cst**的原子操作都有一个全局唯一的执行顺序并且所有线程看到的这个顺序都是相同的即所谓的全局单一全序 (Single Total Order)。注意这个约束只针对所有使用了seq_cst内存序的原子操作而不是任意的原子操作。前两点x86 上已经在硬件层面天然保证了关键看第三点。A: data_x.store(1, seq_cat)-- 假设翻译成 movq %edi, data_x(%rip)-- x86体系结构保证了这个 movq 前面的读写指令都必须先于它被CPU执行禁止 Load-Store / Store-Store重排x86体系结构保证了这个 movq 后面的写指令绝不能先于它被CPU执行禁止 Store-Store 重排B: data_y.load(seq_cst)-- 假设翻译成 movq data_y(%rip), %rax-- x86体系结构保证了这个 movq 后面的读写指令绝不能先于它被CPU执行禁止 Load-Load/ Load-Store重排x86体系结构保证了这个 movq 前面的读指令必须先于它被CPU执行禁止 Load-Load 重排可见光凭架构本身的约束是不能禁止 B 跑到 A 前面即 B 先于 A 被 CPU 执行 的因为 x86 本身不禁止 Store-Load 重排这样就违背第三点约束了。Store-Load 重排的本质原因是 store buffer 的存在详见前文对 Store-Load 重排的介绍。要保证第3点除了强制 CPU 刷新 store buffer避免 Store-Load 重排之外还需要保证“不同原子操作的结果被所有线程以相同的顺序看到”比如原子操作 A 和 B所有线程要么都先看到 A 的结果后看到 B 的结果要么反过来。不能有的线程先看到 A 的结果有的线程先看到 B 的结果。比如典型的非多副本原子性 (Non-Multi-Copy Atomicity) 问题。非多副本原子性 (Non-Multi-Copy Atomicity) 问题要解决上面的问题就需要加 full memory barrier。一个显而易见的方法是在 A 和 B 上都加一个 full memory barrier但这是冗余的影响效率因为只在某一个上面加就行了详见前面对 full memory barrier 的解读。那么加在谁上面呢经过统计多数程序中都是读操作多于写操作因此加到写操作上对效率的影响更小。所以store(seq_cst)被翻译成了带隐式lock的 exchange 指令xchgl、xchgq等而load(seq_cst)依然被翻译成 mov 指令。fetch_add在 x86 上fetch_add被统一翻译成lock xadd即采用了最严格的内存序加了 full memory barrier为的就是保证“读-改-写”这一套流程的整体原子性以及“结果对所有线程立即可见”详见背景知识部分对 RMW 的介绍。所以如果光从 CPU 的角度看采用哪种内存序都是一样的。但是如前所述在编译器层面不同的内存序依然是不一样的。ARM64 体系下的store/load/fetch_addstore和loadstore(relaxed)和load(relaxed)两者不要求任何同步或者顺序约束只要求原子性因此翻译成普通的str((StoreRegister))和ldr((LoadRegister))指令即可。store(release)/store(seq_cst)/load(acquire)/load(seq_cst)为什么store(release)/store(seq_cst)都翻译成stlr(Store-ReleaseRegister)load(acquire)/load(seq_cst)都翻译成ldar(Load-AcquireRegister)呢显然两者分别满足 Release 语义和 Acquire 语义所以用于store(release)和load(acquire)是没问题的那为什么可以用于store(seq_cst)和load(seq_cst)呢seq_cst要求的“全局单一全序 (Single Total Order)”如何来保证呢原因在于ARMv8 的硬件内存模型设计得非常强其**stlr**和**ldar**指令的组合不仅能满足 Acquire-Release 语义在硬件层面本身就直接提供了 Sequential Consistency (SeqCst) 的保证。硬件层面保证了如果代码中先执行了一个stlr指令随后又执行了一个ldar指令CPU 硬件绝对不会把它们重排No Store-Load Reordering。详见官网文档。另外ARMv8 内存模型具备多副本原子性 (Multi-copy atomicity)即当一个stlr写入的值被某一个观察者线程看到时它必然同时被所有观察者看到。详见官网文档。综合以上两点ARM 硬件在执行全是stlr和ldar的代码时天然就形成了一个所有线程一致认同的 Single Total Order。换言之此处 ARM 硬件提供了比C 标准更强的保证。xchgq: exchangeq 表示 Quadword即64位。该命令自带隐式 lock 语义。当 xchg 的操作数之一是内存地址时硬件会自动为它加上 lock 语义。注意如果 xchg 只在两个寄存器之间进行则没有 lock 语义只是普通的寄存器操作。fetch_add原子性的实现以data.fetch_add(1, std::memory_order_relaxed)为例实际生成的汇编指令是下面这样.L6:ldxr x2, [x1]add x3, x2, 1stxr w4, x3, [x1]cbnz w4, .L6ARM 架构没有类似 x86 的lock xadd单条原子指令而是使用独占加载/存储Load-Exclusive / Store-Exclusive即LL/SC机制配合循环来实现读-改-写的原子操作。ldxr x2, [x1]:LoadExclusiveRegister。从x1指向的地址即data加载 64 位数据到x2。Exclusive 表示标记该内存地址的“独占监视器”为后续的写操作做准备。add x3, x2, 1: 计算x2 1结果存入x3即准备写入的新值。stxr w4, x3, [x1]:Store ExclusiveRegister。尝试将x3新值写入x1指向的地址。写入的结果成功或失败会存入w432位寄存器成功写入为 0失败为 1。如果在此期间其他线程修改了该地址独占监视器会被清除导致写入失败。cbnz w4, .L6:Compare andBranch ifNotZero。如果w4不为 0说明写入失败发生了并发竞争则跳回.L6重新执行“加载-修改-写入”的循环。内存序的实现不难看出内存序的实现是通过替换 load / store 指令来试下的。例如要实现 release 语义就将stxr指令换成stlxr(Store Release ExclusiveRegister)要实现 acquire 语义就将ldxr指令换成ldxar(LoadAcquire ExclusiveRegister)要实现seq_cst语义就同时替换两者。与 x86 下fetch_add的区别