4.4 原子操作与内存屏障


文档摘要

4.4 原子操作与内存屏障 本节摘要:本节处理并发世界的秩序装置:为什么 在多核下会丢更新,交换与比较并交换指令如何救场,获取释放语义与屏障如何约束跨核可见性。本节是第 6 章内存模型的先导——先在指令层面认识这些原语,再到程序层面理解它们的组合规则。读完你应当能用汇编级视角讲清一次无锁计数器的正确性依据。 并发世界的秩序装置 从一个事故现场开始。两个核心同时执行 ,这一句高级语言展开成三条指令:读 count、加一、写回。两个核心都读到旧值 5,各自加一写回 6——两次自增只涨了 1。这不是概率性玄学,是读改写操作被中途插入的必然结果,交叉时机对上就必然发生。 修复它的指令级答案分两派。

4.4 原子操作与内存屏障

本节摘要:本节处理并发世界的秩序装置:为什么 count++ 在多核下会丢更新,交换与比较并交换指令如何救场,获取释放语义与屏障如何约束跨核可见性。本节是第 6 章内存模型的先导——先在指令层面认识这些原语,再到程序层面理解它们的组合规则。读完你应当能用汇编级视角讲清一次无锁计数器的正确性依据。

并发世界的秩序装置

从一个事故现场开始。两个核心同时执行 count++,这一句高级语言展开成三条指令:读 count、加一、写回。两个核心都读到旧值 5,各自加一写回 6——两次自增只涨了 1。这不是概率性玄学,是读改写操作被中途插入的必然结果,交叉时机对上就必然发生。

修复它的指令级答案分两派。x86 用锁定前缀:在指令前加 LOCK 前缀(编码 0xF0),硬件保证整条指令对内存的读改写不可分割——其他核心无法插足:

; 原子自增:一条指令完成 读-改-写 并锁住总线/缓存行 F0 83 05 xx xx xx xx 01 ; lock add dword [counter], 1

RISC-V 用预留加载与条件存储(LR/SC)这对组合:lr.w 读取目标地址并把该地址"登记"为预留,sc.w 尝试写回——若登记后地址被别的核心动过,写回失败并置标志,程序就重试整段序列:

retry: lr.w t0, (a0) ; 预留读取 addi t0, t0, 1 ; 本地加一 sc.w t1, t0, (a0) ; 条件写回:t1 为 0 表示成功,非 0 表示被打断,重试 bnez t1, retry

两派风格不同——前缀锁定是把"不可分割"交给硬件现场裁决,LR/SC 是把"失败重试"交给软件循环——但解决的是同一件事:把多条指令的读改写捆成一个外界无法观察中间态的整体。

比较并交换:无锁结构的基石

比原子自增更通用的原语是比较并交换(CAS,x86 的 cmpxchg):若目标值仍等于期望值,则写入新值并报告成功;否则报告失败、什么都不写。所有无锁数据结构——无锁栈、无锁队列、并发哈希表——的地基都是它,配合重试循环就构成通用的"原子地更新任意逻辑":

; CAS 循环骨架(x86 风格) again: mov eax, [expected] ; 取当前期望值 mov ecx, [new_value] lock cmpxchg [target], ecx ; 若 target == eax 则写入 ecx,否则 eax 被 refreshed 为现值 jne again ; 不等则 eax 已更新为最新值,用它重算期望再来

CAS 的甜蜜与辛酸各半。甜蜜在于它把"检查并更新"合成一步,无锁结构的地基由此铺成;辛酸在于著名的 ABA 问题——目标值从 A 变成 B 又变回 A,CAS 看不出中间发生过什么,栈顶指针被复用时会出逻辑错误。工程解法要么给值配版本号(连指针带版本一起 CAS),要么用带方向戳的指针变体。另外两个工程细节:CAS 与一切原子指令一样要求目标自然对齐(4.1 节的伏笔在此兑现,不对齐时许多实现直接抛异常或退化为非原子);宽于 8 字节的 CAS(cmpxchg8b/16b)要小心与浮点/SIMD 单元的合作,跨寄存器操作既慢又易错。

屏障与获取释放:约束谁先看见谁

原子性只保证"中间态不可见",还不保证"顺序如你所写"。处理器与编译器都会重排访存(5.4 节细讲),另一个核心眼里的顺序可能与你的源码顺序不同。看一个经典的破坏现场:核心 A 先写数据再置标志,核心 B 看到标志后读数据——直觉上 B 读到的一定是新数据,实际上若两处访存都被重排,B 可能看到标志却读到旧数据。

约束重排的指令级工具是屏障(fence)获取释放语义。屏障指令(x86 的 mfence 全屏障、sfence 存屏障、lfence 取屏障)划出红线:红线两侧的访存不得跨线重排。获取释放语义则把约束封装进原子操作本身:释放.store 好比"完工广播"——本核心在此之前的全部访存,都先于这次写入对他人可见;获取.load 好比"入场签到"——本核心在此之后的全部访存,都晚于这次读取发生。上面的现场用获取释放修复:置标志用释放写,读标志用获取读,数据可见性顺序就有了契约保障。RISC-V 把语义直接编进 AMO 指令的两个附加位(aq 与 rl),x86 则更省事——它的多数指令天生带强序性质,store 与 load 之间天然禁止部分重排,mfence 只在少数场景才需要出场。

工具 x86 形态 RISC-V 形态 约束内容
原子读改写 lock 前缀指令 lr/sc、amoadd 等带 aq/rl 位 中间态不可见
全屏障 mfence(0F AE F0) fence rw,rw 两侧访存不得跨线重排
释放写 xchg 或 lock 指令 amoswap.rl 之前的访存先于本次写入可见
获取读 mov + 编译器约束 lr.aq 或 fence 后置 之后的访存晚于本次读取

容易踩的坑

第一坑:以为加锁前缀能管住一切。LOCK 只约束这一条指令的原子性,不管前后指令的重排——自增是原子的,但"自增后写日志"的顺序仍需语义或屏障保证。第二坑:把 volatile 当同步原语。C/C++ 的 volatile 只禁止编译器优化这些访问,不产生任何硬件级原子性与顺序约束,用它替代原子变量是经典错误——真原语是标准库的原子类型,它才会在需要的平台生成 LOCK 前缀或 aq/rl 位。第三坑:忽视宽原子与对齐的耦合,跨缓存行的原子操作要么异常要么性能断崖(缓存行跨界的锁定代价极高),结构体里把热点原子字段塞在奇偶边界上是性能评审的常客。最后一个坑属于乐观者:CAS 循环在高竞争下会退化成活锁,重试风暴比锁的排队更伤吞吐——竞争激烈时,老老实实的锁(内部正是 CAS 保护的状态机)反而更快。

一场完整的竞争诊断

把 4.4 的工具装进一个诊断案例。某服务的计数器在高并发下偶发偏小,日志无异常。用本节的视角走一遍诊断:第一步确认读改写的指令形态——反汇编发现自增被编译成"加载、加一、存回"三条普通指令,没有锁定前缀也没有 LR/SC,竞争的物理基础成立;第二步确认交叉窗口——两个核心在"加载与存回"之间被中断或缓存延迟拉开,计数即丢;第三步开方——把变量换成语言级原子类型,重汇编确认生成了 lock add 或 AMO 序列,缺陷消失。全程没用玄学:竞争要成立,缺的只是"中间态可见"这个条件。

顺手把这个案例升级成避坑守则。共享可变状态的自增、标志置位、链表指针更新,只要存在"读改写"三段式,就有竞争窗口;判断标准不是"概率低"而是"窗口存在"。原子原语的选择次序:单变量计数用原子自增;复合条件更新(检查再改)用 CAS 循环;多变量成组更新用锁或把多个变量合并成一个原子宽度内的字段包。三条路走不通的,回头看设计——需要复杂同步的场景多半该重新划分状态所有权。

本节要点回顾

  • 读改写竞争是原子指令存在的理由:lock 前缀与 LR/SC 重试是两派实现,效果等价于"外界看不到中间态"。
  • CAS 是无锁结构的地基,但要看住 ABA 问题;原子操作要求自然对齐。
  • 原子性不等于顺序:跨核可见顺序靠获取释放语义或屏障约束——释放是完工广播,获取是入场签到。
  • x86 强序底子厚、屏障出场少;RISC-V 弱序、语义显式编进指令位——平台差异要落在工具链上核对。
  • volatile 不是原子,CAS 高竞争会活锁:原语用对场合,比认识原语本身更重要。

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U