引言:从"一行 i++"说起
i++ 在 C 里是一行,但编译成汇编是"读 + 加 1 + 写回"三步。两个 CPU 同时 i++,各读一次、各写一次,最终 i 可能只加了 1------这就是数据竞争(data race)。
内核里到处都是这种东西:自旋锁的加锁、引用计数的增减、RCU 的读写、percpu-rwsem 的计数。它们凭什么在几十个 CPU 并发下不出错?靠两层硬件底座:
- 原子操作(atomic operation) :把"读-改-写"变成 CPU 眼里不可分割的一条,其他 CPU 要么看到改之前、要么看到改之后,绝看不到中间态。
- 内存屏障(memory barrier) :CPU 和编译器会重排访存指令(为了性能),屏障强制某段代码的访存顺序,保证"先写 A 再写 B"这个约定在别的 CPU 眼里依然成立。
这篇讲这两层的硬件机制 + 内核封装 ------为什么需要屏障、LOCK 前缀怎么保证原子性、x86 的 TSO 模型允许什么重排、内核的 barrier()/smp_mb()/READ_ONCE 分别管什么。
全景图
#mermaid-svg-KDHgTQr72cohSN5y{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-KDHgTQr72cohSN5y .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KDHgTQr72cohSN5y .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KDHgTQr72cohSN5y .error-icon{fill:#552222;}#mermaid-svg-KDHgTQr72cohSN5y .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KDHgTQr72cohSN5y .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KDHgTQr72cohSN5y .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KDHgTQr72cohSN5y .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KDHgTQr72cohSN5y .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KDHgTQr72cohSN5y .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KDHgTQr72cohSN5y .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KDHgTQr72cohSN5y .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KDHgTQr72cohSN5y .marker.cross{stroke:#333333;}#mermaid-svg-KDHgTQr72cohSN5y svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KDHgTQr72cohSN5y p{margin:0;}#mermaid-svg-KDHgTQr72cohSN5y .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-KDHgTQr72cohSN5y .cluster-label text{fill:#333;}#mermaid-svg-KDHgTQr72cohSN5y .cluster-label span{color:#333;}#mermaid-svg-KDHgTQr72cohSN5y .cluster-label span p{background-color:transparent;}#mermaid-svg-KDHgTQr72cohSN5y .label text,#mermaid-svg-KDHgTQr72cohSN5y span{fill:#333;color:#333;}#mermaid-svg-KDHgTQr72cohSN5y .node rect,#mermaid-svg-KDHgTQr72cohSN5y .node circle,#mermaid-svg-KDHgTQr72cohSN5y .node ellipse,#mermaid-svg-KDHgTQr72cohSN5y .node polygon,#mermaid-svg-KDHgTQr72cohSN5y .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-KDHgTQr72cohSN5y .rough-node .label text,#mermaid-svg-KDHgTQr72cohSN5y .node .label text,#mermaid-svg-KDHgTQr72cohSN5y .image-shape .label,#mermaid-svg-KDHgTQr72cohSN5y .icon-shape .label{text-anchor:middle;}#mermaid-svg-KDHgTQr72cohSN5y .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-KDHgTQr72cohSN5y .rough-node .label,#mermaid-svg-KDHgTQr72cohSN5y .node .label,#mermaid-svg-KDHgTQr72cohSN5y .image-shape .label,#mermaid-svg-KDHgTQr72cohSN5y .icon-shape .label{text-align:center;}#mermaid-svg-KDHgTQr72cohSN5y .node.clickable{cursor:pointer;}#mermaid-svg-KDHgTQr72cohSN5y .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-KDHgTQr72cohSN5y .arrowheadPath{fill:#333333;}#mermaid-svg-KDHgTQr72cohSN5y .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-KDHgTQr72cohSN5y .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-KDHgTQr72cohSN5y .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KDHgTQr72cohSN5y .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-KDHgTQr72cohSN5y .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KDHgTQr72cohSN5y .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-KDHgTQr72cohSN5y .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-KDHgTQr72cohSN5y .cluster text{fill:#333;}#mermaid-svg-KDHgTQr72cohSN5y .cluster span{color:#333;}#mermaid-svg-KDHgTQr72cohSN5y div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-KDHgTQr72cohSN5y .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-KDHgTQr72cohSN5y rect.text{fill:none;stroke-width:0;}#mermaid-svg-KDHgTQr72cohSN5y .icon-shape,#mermaid-svg-KDHgTQr72cohSN5y .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KDHgTQr72cohSN5y .icon-shape p,#mermaid-svg-KDHgTQr72cohSN5y .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-KDHgTQr72cohSN5y .icon-shape .label rect,#mermaid-svg-KDHgTQr72cohSN5y .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KDHgTQr72cohSN5y .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-KDHgTQr72cohSN5y .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-KDHgTQr72cohSN5y :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 原子 RMW 本身是全屏障
LOCK 前缀 = 最便宜的屏障
并发正确性两件套
原子操作 atomic_t
解决: 中间态
内存屏障 barrier
解决: 乱序
LOCK 前缀
lock; addl/inc/dec
专用指令
xadd / cmpxchg / xchg
128 位
cmpxchg16b (CX16)
锁 cache line
(RFO 独占, 接 CPU 篇 #14 MESI)
xchg 隐式原子
cmpxchg/xadd 要 LOCK
编译器屏障 barrier()
memory clobber
CPU 屏障
mfence/lfence/sfence
smp_ 屏障
smp_mb/smp_wmb/smp_rmb
READ/WRITE_ONCE
volatile 强转
x86 TSO 下:
smp_wmb/rmb = barrier()
smp_mb = lock; addl $0
这张图的核心信息只有一句:原子操作和内存屏障是"原子性"和"顺序"两本账,但在 x86 上它们共享同一个硬件机制------LOCK 前缀。
一、概念合集(零基,术语首现即定义)
写这一篇必须先把几个词钉死,否则后面全是浆糊。
| 术语 | 一句话定义 | 反例/后果 |
|---|---|---|
| 原子性(atomicity) | 一次操作对别的观察者"要么全做、要么没做",看不到中间态 | 两个 CPU 同时 i++,丢一次加 |
| 可见性(visibility) | 一个 CPU 的写,何时能被别的 CPU 看到 | 写进了 store buffer 但还没刷进 cache,别的核读旧值 |
| 重排(reordering) | 实际执行顺序 ≠ 程序书写顺序(编译器或 CPU 干的) | a=1; b=2 实际先写 b |
| 顺序一致性 SC | 最强的模型:所有 CPU 的所有操作,按某个全局顺序串行执行,且每 CPU 内部保序 | 代价极高,真实 CPU 都不做 |
| TSO(Total Store Order) | x86 的内存模型:只允许"store 之后跟 load"这一种重排 | Dekker 例子 (r1,r2)=(0,0) |
| acquire(获取语义) | 读操作之后的所有访存不许重排到这个读之前 | 拿到锁后,临界区读的数据一定是最新的 |
| release(释放语义) | 写操作之前的所有访存不许重排到这个写之后 | 释放锁前,临界区改的数据一定已全局可见 |
| 数据竞争(data race) | 多个 CPU 访问同一内存、至少一个是写、且没有同步 | 未定义行为,一切皆可能 |
关键区分,务必记住:
- 原子性 ≠ 可见性 。
atomic_set()是原子的(一次写不撕裂),但它不提供任何排序 ,别的 CPU 不保证马上看到。看源码一目了然:atomic_read()/atomic_set()的实现就是__READ_ONCE()/__WRITE_ONCE()(atomic.h:17/23,见 4.5),只是"不被撕裂的单次访问"------排序归排序、原子归原子。 - 原子性 ≠ 顺序 。
LOCK addl既保证原子性、又顺带提供了全屏障(下文第四节),但这是 x86 的实现巧合 ,不是"原子操作"的定义要求。C11 里atomic_fetch_add(..., memory_order_relaxed)就是"只要原子性、不要顺序"。
二、乱序的三个来源
内存屏障要防的是"重排"。重排有三个独立的来源,一层比一层难抓:
2.1 编译器重排(最上层,可静态抓)
编译器优化时会做:把不相关的访存放在寄存器里不写回、把两个无依赖的访存交换顺序、把循环里的读提到循环外(loop-invariant code motion)。只要编译器认为"单线程语义不变",它就敢重排。
内核的防线 :barrier()(include/linux/compiler.h:85)------
c
# define barrier() __asm__ __volatile__("": : :"memory")
这是一个空的汇编语句,但 "memory" clobber 告诉编译器:假设所有内存都可能被这个语句改了 。于是编译器不敢把任何内存访问跨过它移动。它管的是编译器,对 CPU 硬件乱序毫无作用。
2.2 CPU 乱序执行(中间层)
现代 CPU 是乱序执行(out-of-order execution)的:指令按序进流水线,但真正执行、提交(commit,把结果写回寄存器)可以不按序。只要不影响单核自己的程序语义,CPU 就敢让后面的指令先跑完。
访存之间的四种相邻组合,CPU 可能重排哪几种,取决于内存模型(第三节)。
2.3 store buffer(最底层,StoreLoad 重排的根源)
这是最反直觉的一个,也是 x86 上唯一需要真正硬件屏障的地方。
CPU 执行 store X=1 时,为了不阻塞(写要等 cache line 独占,慢),它把写操作先丢进一个每核私有的 store buffer(写缓冲区) ,立刻返回去执行后面的指令。当后面的 load Y 来了,如果 CPU 直接读内存/cache,就可能读到旧值 ------因为自己的 store X 还没真正刷出去。
于是从别的 CPU 视角看:CPU A 先写了 X,后读了 Y,但它的 X 写还没被别人看到 。这就表现为 store 之后的 load 提前完成 ,即 StoreLoad 重排。
#mermaid-svg-eZP2vRdL1enmMp2Y{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-eZP2vRdL1enmMp2Y .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-eZP2vRdL1enmMp2Y .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-eZP2vRdL1enmMp2Y .error-icon{fill:#552222;}#mermaid-svg-eZP2vRdL1enmMp2Y .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-eZP2vRdL1enmMp2Y .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-eZP2vRdL1enmMp2Y .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-eZP2vRdL1enmMp2Y .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-eZP2vRdL1enmMp2Y .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-eZP2vRdL1enmMp2Y .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-eZP2vRdL1enmMp2Y .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-eZP2vRdL1enmMp2Y .marker{fill:#333333;stroke:#333333;}#mermaid-svg-eZP2vRdL1enmMp2Y .marker.cross{stroke:#333333;}#mermaid-svg-eZP2vRdL1enmMp2Y svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-eZP2vRdL1enmMp2Y p{margin:0;}#mermaid-svg-eZP2vRdL1enmMp2Y .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-eZP2vRdL1enmMp2Y .cluster-label text{fill:#333;}#mermaid-svg-eZP2vRdL1enmMp2Y .cluster-label span{color:#333;}#mermaid-svg-eZP2vRdL1enmMp2Y .cluster-label span p{background-color:transparent;}#mermaid-svg-eZP2vRdL1enmMp2Y .label text,#mermaid-svg-eZP2vRdL1enmMp2Y span{fill:#333;color:#333;}#mermaid-svg-eZP2vRdL1enmMp2Y .node rect,#mermaid-svg-eZP2vRdL1enmMp2Y .node circle,#mermaid-svg-eZP2vRdL1enmMp2Y .node ellipse,#mermaid-svg-eZP2vRdL1enmMp2Y .node polygon,#mermaid-svg-eZP2vRdL1enmMp2Y .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-eZP2vRdL1enmMp2Y .rough-node .label text,#mermaid-svg-eZP2vRdL1enmMp2Y .node .label text,#mermaid-svg-eZP2vRdL1enmMp2Y .image-shape .label,#mermaid-svg-eZP2vRdL1enmMp2Y .icon-shape .label{text-anchor:middle;}#mermaid-svg-eZP2vRdL1enmMp2Y .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-eZP2vRdL1enmMp2Y .rough-node .label,#mermaid-svg-eZP2vRdL1enmMp2Y .node .label,#mermaid-svg-eZP2vRdL1enmMp2Y .image-shape .label,#mermaid-svg-eZP2vRdL1enmMp2Y .icon-shape .label{text-align:center;}#mermaid-svg-eZP2vRdL1enmMp2Y .node.clickable{cursor:pointer;}#mermaid-svg-eZP2vRdL1enmMp2Y .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-eZP2vRdL1enmMp2Y .arrowheadPath{fill:#333333;}#mermaid-svg-eZP2vRdL1enmMp2Y .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-eZP2vRdL1enmMp2Y .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-eZP2vRdL1enmMp2Y .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eZP2vRdL1enmMp2Y .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-eZP2vRdL1enmMp2Y .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eZP2vRdL1enmMp2Y .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-eZP2vRdL1enmMp2Y .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-eZP2vRdL1enmMp2Y .cluster text{fill:#333;}#mermaid-svg-eZP2vRdL1enmMp2Y .cluster span{color:#333;}#mermaid-svg-eZP2vRdL1enmMp2Y div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-eZP2vRdL1enmMp2Y .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-eZP2vRdL1enmMp2Y rect.text{fill:none;stroke-width:0;}#mermaid-svg-eZP2vRdL1enmMp2Y .icon-shape,#mermaid-svg-eZP2vRdL1enmMp2Y .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eZP2vRdL1enmMp2Y .icon-shape p,#mermaid-svg-eZP2vRdL1enmMp2Y .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-eZP2vRdL1enmMp2Y .icon-shape .label rect,#mermaid-svg-eZP2vRdL1enmMp2Y .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eZP2vRdL1enmMp2Y .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-eZP2vRdL1enmMp2Y .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-eZP2vRdL1enmMp2Y :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} CPU1
CPU0
还没进 cache
还没进 cache
store X=1
store buffer
(X=1 暂存, 未刷出)
load Y (读旧值)
store Y=1
store buffer
load X (读旧值)
内存/cache
为什么 store buffer 必须 FIFO 且不能合并 :x86 要求 StoreStore 不重排,如果两个 store 进 buffer 后被合并(coalescing)或乱序刷出,第二个 store 可能先被看到,就破坏了 StoreStore 顺序。所以 x86 的 store buffer 是先进先出的。
一句话总结这一节 :编译器重排靠
barrier()(memory clobber);CPU 乱序靠内存模型约束;store buffer 是 StoreLoad 重排的硬件根源,只能靠真·屏障指令(mfence或lock指令)压制。
三、x86 的内存模型:TSO
3.1 TSO 允许什么、禁止什么
x86 采用 TSO(Total Store Order,全存储序) ,是一种强内存模型(相对 ARM 的弱模型而言)。四种相邻访存组合里:
| 重排类型 | 含义 | x86 (TSO) 是否允许 | 需要什么屏障 |
|---|---|---|---|
| LoadLoad | 两个读换序 | ❌ 禁止 | smp_rmb()(实为编译器屏障) |
| LoadStore | 读之后跟写换序 | ❌ 禁止 | 无需 |
| StoreStore | 两个写换序 | ❌ 禁止 | smp_wmb()(实为编译器屏障) |
| StoreLoad | 写之后跟读换序 | ✅ 允许 | smp_mb()(真指令) |
这正是 arch/x86/include/asm/barrier.h 的代码所表达的(详细见第五节):
c
#define __smp_wmb() barrier() // barrier.h:60 ------ 写屏障只是编译器屏障
#define __smp_rmb() dma_rmb() // barrier.h:59 ------ 读屏障最终也是编译器屏障
#define __smp_mb() asm volatile("lock; addl $0,-4(%%" _ASM_SP ")" ...) // :57 ------ 全屏障才是真指令
读屏障和写屏障为什么在 x86 上只是 barrier() :因为 TSO 已经保证 LoadLoad/LoadStore/StoreStore 三种顺序,CPU 硬件不会重排它们,唯一要防的是编译器 重排,所以一个 memory clobber 就够了。只有 StoreLoad 会乱,所以 smp_mb() 才需要真正发指令。
3.2 Dekker 例子:为什么 StoreLoad 重排违反直觉
两个 CPU 各自"先写后读"一个对方的变量:
初始: X = 0, Y = 0
CPU 0 CPU 1
STORE X = 1 STORE Y = 1
r0 = LOAD Y r1 = LOAD X
如果没有任何同步,可能得到 r0 = 0, r1 = 0------两个 CPU 都读到了对方的旧值。
- 按顺序一致性(SC) :所有操作得排成一个全局串行顺序,两个写必有一个先发生,后发生的那个 CPU 的读一定能看到先写的值,
(0,0)不可能。 - 按 TSO :每个 CPU 的
STORE先钻进自己的 store buffer,然后LOAD直接读 cache 里的旧值,于是两个 CPU 都看到0。(0,0)完全合法。
这就是 TSO 与 SC 的唯一差别:TSO 允许 store 被"延迟"到后续 load 之后。
3.3 传递性与多拷贝原子性
Documentation/memory-barriers.txt:1429 讲了两个 smp_mb() 独有的能力,是 acquire/release 链给不了的:
- 多拷贝原子性(multicopy atomicity) :x86 是"other-multicopy atomic"------除了执行 store 的 CPU 自己(它可以从 store buffer 提前读到自己的写),其他所有 CPU 对这个 store 被看到的顺序是一致的。
- 传递性(transitivity) :要保证"CPU 1 看到 X=1 且 CPU 2 看到 Y=1 时,某个第三者的写也一定被一致地看到",靠的是通用屏障
smp_mb()。读屏障、数据依赖、写屏障都给不了 传递性------这是smp_mb()贵、但不可替代的原因。
c
// memory-barriers.txt:1437 的 C 代码骨架
cpu0: r0 = smp_load_acquire(&x); WRITE_ONCE(u,1); smp_store_release(&y,1);
cpu1: r1 = smp_load_acquire(&y); r4 = READ_ONCE(v); smp_store_release(&z,1);
cpu2: r2 = smp_load_acquire(&z); smp_store_release(&x,1);
// 结论: 一条 release-acquire 链只保证链上 CPU 一致,
// 要"所有 CPU 一致"必须上 smp_mb() (general barrier)
Why(这一章的意义) :TSO 是 x86 的硬件契约。内核写的屏障,本质是"在 TSO 允许的那唯一一个漏洞(StoreLoad)上补一张网"。理解了 TSO,就理解了为什么 x86 上 wmb() 便宜(几乎免费)、mb() 贵(要发指令),也就理解了为什么 ARM 移植代码时屏障最容易出 bug------ARM 是弱模型,wmb() 也必须是真指令。
四、原子操作与 LOCK 前缀
4.1 LOCK 前缀:锁的是 cache line,不是总线
arch/x86/include/asm/atomic.h:31 的 arch_atomic_add:
c
static __always_inline void arch_atomic_add(int i, atomic_t *v)
{
asm volatile(LOCK_PREFIX "addl %1,%0"
: "+m" (v->counter)
: "ir" (i) : "memory");
}
翻译成人话:LOCK addl = "原子地把 i 加到 v->counter 上"。
老 CPU(Pentium 之前) :LOCK 前缀直接锁总线------整条总线被独占,其他 CPU 全都不能访存,直到操作完成。粗暴但正确。
现代 CPU(P6 起) :LOCK 前缀锁 cache line。机制是 MESI(接 CPU 篇 #14):
- CPU 要把这条 cache line 读进自己 L1 并置为 M(Modified,独占+脏) 状态------即先做一次 RFO(Read For Ownership,读所有权)。
- RFO 会广播 Invalidate,让其他 CPU 上这条 line 的副本全部失效(置 I)。
- 独占之后,这个 CPU 在这条 line 上做"读-改-写",期间别的 CPU 拿不到这条 line,自然看不到中间态。
- 做完后 line 仍是 M,等将来被赶出去时写回。
所以 LOCK addl 的"原子性",本质是 MESI 协议保证同一时刻只有本 CPU 独占这条 line 。这就是为什么 CPU 篇 #14 讲 MESI 是这里的地基。
顺带一句 :正因为 LOCK 操作要独占 cache line,所以两个 CPU 对同一行 上的不同变量做原子操作会互相打架(false sharing,缓存乒乓),这也呼应了
CPU 篇 #8percpu 把热点数据每核一份的动机。
4.2 x86 原子操作的三类实现
atomic.h 里几十个函数,按底层指令分成三族:
| 家族 | 底层指令 | LOCK 前缀? | atomic.h 例子 |
|---|---|---|---|
| 算术/逻辑 | addl/sub/incl/decl/andl/orl/xorl |
要 LOCK_PREFIX |
arch_atomic_add :31、arch_atomic_inc :51 |
| 读改写返回旧值 | xadd / cmpxchg |
要 LOCK_PREFIX |
arch_atomic_add_return :83、arch_atomic_cmpxchg :107 |
| 交换 | xchg |
不要(隐式原子) | arch_atomic_xchg :119 |
关键差异在 xchg:arch/x86/include/asm/cmpxchg.h:73 的注释写得很清楚------
c
/*
* Note: no "lock" prefix even on SMP: xchg always implies lock anyway.
*/
#define arch_xchg(ptr, v) __xchg_op((ptr), (v), xchg, "")
XCHG 指令天生带锁语义 ,不管加不加 LOCK 前缀都是原子的。这是 x86 的历史设计(XCHG 常用于信号量),所以内核的 arch_xchg 传空字符串当 lock 参数(cmpxchg.h:78)。
而 cmpxchg/xadd 就不行,必须显式 LOCK_PREFIX(cmpxchg.h:133-134、:239-240)。
fetch_and/or/xor 的怪相 (atomic.h:133):这三个没有对应的"LOCK 且返回旧值"的指令,内核用 CAS 循环模拟:
c
static __always_inline int arch_atomic_fetch_and(int i, atomic_t *v)
{
int val = arch_atomic_read(v);
do { } while (!arch_atomic_try_cmpxchg(v, &val, val & i)); // :137
return val;
}
读当前值 → 算新值 → CAS 试写,失败(被抢)就重读重试。这是无锁编程最基础的 CAS 循环模板。
4.3 LOCK_PREFIX 的 alternatives 魔法:SMP 内核跑在 UP 上
LOCK_PREFIX 不是一个简单的 "lock; " 字符串,arch/x86/include/asm/alternative.h:39 定义了它的双重身份:
c
#ifdef CONFIG_SMP
#define LOCK_PREFIX_HERE \
".pushsection .smp_locks,\"a\"\n" \
".balign 4\n" \
".long 671f - .\n" /* offset */ \
".popsection\n" \
"671:"
#define LOCK_PREFIX LOCK_PREFIX_HERE "\n\tlock; "
#else
#define LOCK_PREFIX_HERE ""
#define LOCK_PREFIX ""
#endif
它展开成两部分:
.smp_locks段 :把这条lock前缀指令的地址偏移 记进一个纯地址表(:34-36注释:不含替换指针和 size,表尽量小)。lock;:实际指令前缀。
用途(注释 :26-32):同一个 SMP 内核,跑在单核 UP 机器上时 ,apply_alternatives() 用 X86_FEATURE_UP 标志,把这堆 lock 前缀全 NOP 掉 ------UP 上没有并发,lock 纯属浪费。而且表可以保留在内存里,hotplug 热插一个新 CPU 进来时再切回 SMP 版本(这在虚拟化环境特别有用)。
Why :这是"一次编译、两套行为"的 alternatives 机制在 lock 前缀上的特化------用最小的表(只存地址、不存替换内容)换 UP/SMP 两个模式的零成本切换。CPU 篇 #13 CPU 特性位篇讲过 static_cpu_has 的 alternatives patching,这里是同一套机制作用在指令前缀上。
4.4 128 位原子:cmpxchg16b
atomic_t 是 32 位、atomic64_t 是 64 位。要原子地改 128 位(比如一个 64 位指针 + 64 位序列号的无锁栈),x86 靠 cmpxchg16b(arch/x86/include/asm/cmpxchg_64.h:35):
c
asm volatile(_lock "cmpxchg16b %[ptr]"
: [ptr] "+m" (*(_ptr)), "+a" (o.low), "+d" (o.high)
: "b" (n.low), "c" (n.high) : "memory");
这条指令用 RDX:RAX(旧值)+ RCX:RBX(新值)四寄存器拼 128 位,与内存里的 128 位比较交换。前提是 CPU 支持 CX16 特性 (system_has_cmpxchg128() = boot_cpu_has(X86_FEATURE_CX16),cmpxchg_64.h:88)。
4.5 陷阱:atomic_read/atomic_set 不是"带序的原子"
这是最容易踩的坑。atomic.h:17/26:
c
static __always_inline int arch_atomic_read(const atomic_t *v)
{
return __READ_ONCE((v)->counter); // :23
}
static __always_inline void arch_atomic_set(atomic_t *v, int i)
{
__WRITE_ONCE(v->counter, i); // :28
}
atomic_read/atomic_set 就是 READ_ONCE/WRITE_ONCE(见 5.3)------保证一次访问不被撕裂,但完全不提供排序 。多核下 atomic_set(v, 1) 之后,别的 CPU 不保证马上看到 1,也不保证看到 1 之前已看到你更早写的其它变量。
真正带全屏障的是 RMW 操作 (add/inc/cmpxchg 等),因为 x86 的 LOCK 前缀指令本身就是序列化点。这也正是 barrier.h:78-80 写空实现的原因:
c
/* Atomic operations are already serializing on x86 */
#define __smp_mb__before_atomic() do { } while (0)
#define __smp_mb__after_atomic() do { } while (0)
结论 :atomic_inc 前不用再加 smp_mb()(LOCK 指令自带全屏障);但 atomic_read 前后的顺序必须自己用屏障管。
五、内存屏障分类与 x86 实现
5.1 两层屏障:编译器 vs CPU
内存屏障必须先分两层,混淆这两层是新手最常犯的错:
| 层 | 防谁 | x86 实现 | 代价 |
|---|---|---|---|
编译器屏障 barrier() |
编译器重排 | "memory" clobber(compiler.h:85) |
几乎零 |
CPU 屏障 mb()/smp_mb() |
CPU 硬件重排 | mfence / lock; addl |
有(几十~上百 cycle) |
所有 CPU 屏障都隐含 编译器屏障(因为汇编带 "memory" clobber),但反过来 barrier() 对 CPU 零作用。
5.2 完整家族对照表
arch/x86/include/asm/barrier.h 定义了 x86 的 __ 底层版本,include/asm-generic/barrier.h 再包一层 kcsan 检测。x86 下的最终效果:
| 宏 | x86 展开 | 管什么 | 备注 |
|---|---|---|---|
barrier() |
"" : "memory" |
编译器 | compiler.h:85 |
mb()/rmb()/wmb() |
mfence/lfence/sfence |
全部访存/读/写 | 公开宏 asm-generic:30/34/38(kcsan + 底层 __mb()),底层 __mb()=mfence arch barrier.h:22 |
dma_rmb()/dma_wmb() |
barrier() |
设备 DMA 访存顺序 | barrier.h:54-55,cache coherent 下只需编译器屏障 |
smp_mb() |
lock; addl $0,-4(%rsp) |
SMP 全屏障 | barrier.h:57 |
smp_rmb() |
barrier() |
SMP 读屏障 | barrier.h:59(经 dma_rmb) |
smp_wmb() |
barrier() |
SMP 写屏障 | barrier.h:60 |
smp_store_mb() |
xchg |
存 + 全屏障 | barrier.h:61 |
smp_load_acquire() |
READ_ONCE + barrier() |
acquire 读 | barrier.h:70 |
smp_store_release() |
barrier() + WRITE_ONCE |
release 写 | barrier.h:63 |
smp_mb__before/after_atomic() |
空 | 原子操作前后 | barrier.h:79-80 |
三条关键规律:
- 非 SMP 版(
mb/rmb/wmb)用真 fence,SMP 版(smp_*)反而更省 。mb()用mfence是因为它还要对付设备 (UP 单核上跟设备 DMA 的可见性也要保证,见 barrier.h:8-11 注释);而smp_wmb/rmb只在多核间排序,TSO 下只需编译器屏障。 smp_mb()是唯一需要真指令的 SMP 屏障,因为 TSO 只留了 StoreLoad 这个漏洞。- UP 系统上所有
smp_*屏障退化成barrier()(asm-generic/barrier.h:110-124)------单核自洽,多核排序无意义。
5.3 READ_ONCE / WRITE_ONCE:不是屏障,是"访问纪律"
include/asm-generic/rwonce.h:44/53 的实现只有一行强转:
c
#define __READ_ONCE(x) (*(const volatile __unqual_scalar_typeof(x) *)&(x)) // :44
#define __WRITE_ONCE(x, val) *(volatile typeof(x) *)&(x) = (val); // :53
本质是把一次访问强转成 volatile。它干三件事(rwonce.h 文件头注释 :3-18):
- 禁止编译器合并/重取 :不许把两次
READ_ONCE(x)优化成读一次,也不许把WRITE_ONCE的多次写合并。 - 禁止跨它重排(仅当编译器"意识到顺序"时,即两个访问在不同 C 语句)。
- 保证不被撕裂 (配合
compiletime_assert_rwonce_type,:35)。
它管的是编译器,不是 CPU 。READ_ONCE 不产生任何 CPU 指令,CPU 该乱序还乱序。但它是最基础的"读一次、别缓存"纪律,是 RCU、无锁结构里每一个共享变量访问的前置要求。
5.4 acquire / release:x86 上"免费的午餐"
C11 内存模型和内核的 smp_load_acquire/smp_store_release 表达的是单向屏障:
smp_load_acquire(p):读 p 之后的所有访存不许跑到这个读之前。smp_store_release(p, v):写 p 之前的所有访存不许跑到这个写之后。
通用实现(asm-generic/barrier.h:138/147)要在前后各插一个 smp_mb()。但 x86 override 掉了,只留 barrier()(barrier.h:63-76):
c
#define __smp_store_release(p, v) do { compiletime_assert_atomic_type(*p); \
barrier(); WRITE_ONCE(*p, v); } while (0) // :63
#define __smp_load_acquire(p) ({ typeof(*p) ___p1 = READ_ONCE(*p); \
compiletime_assert_atomic_type(*p); barrier(); ___p1; }) // :70
Why 免费 :TSO 已经保证"读之后的访问不提前""写之前的访问不推后",acquire/release 要防的那两种重排(LoadLoad/LoadStore、StoreStore/LoadStore 的跨边界移动)在 x86 硬件上本来就不发生,所以只需挡住编译器即可。这是强内存模型(x86 TSO)相比弱模型(ARM)的最大红利 ------ARM 上 smp_store_release 必须是一条真正的 stlr 指令。
5.5 为什么 smp_mb 用 lock; addl $0,-4(%rsp) 而不是 mfence
barrier.h:57:
c
#define __smp_mb() asm volatile("lock; addl $0,-4(%%" _ASM_SP ")" ::: "memory", "cc")
这条指令:lock 前缀 + 把 0 加到栈顶下方 4 字节 (-4(%rsp)),结果不变、纯属占位。为什么选它当全屏障?
lock前缀指令天然是全屏障(序列化点),能堵住 StoreLoad 重排------这是我们要的唯一效果。- 操作数落在栈上(每 CPU 私有的内存位置) :不会和其他 CPU 竞争 cache line,代价最低。如果
lock; addl操作一个共享变量,反而会引发 cache line 抢占。 mfence也能当全屏障,但通常更慢 :mfence要等待所有之前的 store 刷出,lock空操作在某些微架构上更便宜。所以内核选lock; addl $0这个"最便宜的序列化点"当smp_mb()。
顺带:32 位代码里
mb()/rmb()/wmb()用ALTERNATIVE("lock; addl $0,-4(%esp)", "mfence", X86_FEATURE_XMM2)(barrier.h:15-20)------有 XMM2(SSE2)就用mfence,老 CPU 退回lock空操作。64 位直接固定用 fence(barrier.h:22-24)。
六、应用实例:屏障怎么拼成同步原语
前面几章讲的是"零件"(第四、五章的原子操作 + 内存屏障),这一章看内核怎么把它们拼成三个真实同步原语,正好呼应 #3(死锁)里的三个主角。
6.1 自旋锁 qspinlock:acquire 抢锁 + release 解锁
x86 的自旋锁是 queued spinlock(MCS 队列锁) ,不是老式 ticket lock(spinlock.h:19 的注释已经过时,实际 include 的是 qspinlock.h:27)。include/asm-generic/qspinlock.h:107 的快速路径:
c
static __always_inline void queued_spin_lock(struct qspinlock *lock)
{
int val = 0;
if (likely(atomic_try_cmpxchg_acquire(&lock->val, &val, _Q_LOCKED_VAL)))
return; // 抢到锁,直接进临界区
queued_spin_lock_slowpath(lock, val); // 被占了,进 MCS 队列排队
}
解锁(qspinlock.h:123):
c
static __always_inline void queued_spin_unlock(struct qspinlock *lock)
{
smp_store_release(&lock->locked, 0); // :128 ------ release 语义解锁
}
两个屏障在这里各司其职:
atomic_try_cmpxchg_acquire:cmpxchg的 LOCK 前缀保证"抢锁"这个比较交换是原子的(只有一个 CPU 能成功),acquire 语义保证抢到锁之后,临界区里读到的共享数据一定是最新的(不会被提前到抢锁之前读)。smp_store_release:release 语义保证解锁之前,临界区里改的所有数据都已经全局可见(不会被推迟到解锁之后写)。
一进一出,正好用 acquire/release 把"临界区内的访存"夹在中间,这就是"锁 = 临界区的内存屏障"的精确含义。
6.2 RCU:release 发布 + READ_ONCE 读取
RCU 的"读-拷贝-更新"全靠内存屏障保证读者要么看到旧指针、要么看到新指针,绝看不到半成品。include/linux/rcupdate.h:
c
#define rcu_assign_pointer(p, v) \
do { \
... \
smp_store_release(&p, RCU_INITIALIZER((typeof(p))_r_a_p__v)); // :502
} while (0)
#define rcu_dereference(p) rcu_dereference_check(p, 0) // :658
- 发布端
rcu_assign_pointer=smp_store_release(rcupdate.h:502):release 语义保证"初始化新对象"的写,一定在"发布新指针"这个写之前全局可见。读者一旦读到新指针,就能保证新对象已初始化完。 - 读取端
rcu_dereference:底层是READ_ONCE(读一次、不缓存),配合 acquire/数据依赖,保证读到指针之后,对指针指向对象的访问不会乱序。
这正是 #3 里 cpu_hotplug_lock(percpu-rwsem)的底层:它的读者快速路径判据 rcu_sync_is_idle,就是靠 RCU 的这套 release/acquire 机制,让读者几乎无锁地判断"现在有没有写者在等"。
6.3 percpu-rwsem:读多写少,用 percpu 计数器 + rcu_sync
#3 详细讲过 cpu_hotplug_lock 是 percpu-rwsem。这里只从"原子 + 屏障"的角度补一句它的组合方式:
- 读侧 :每个读者
this_cpu_inc一个 percpu 计数器(CPU 篇 #8percpu 的%gs段寻址,一条指令、无锁),写者要看"有没有读者"时把这些 percpu 计数求和。读路径零屏障、零竞争。 - 写侧 :写者要用
rcu_sync(本质是 RCU 宽限期)等所有在读的读者退出 ,再独占。这就是smp_mb()全屏障 + RCU 宽限期联手保证的。
三种原语的选型逻辑已经浮现:抢锁看竞争激烈度(qspinlock 用 CAS + 队列)、读多写少用 percpu(无锁读)、指针发布用 RCU(release/acquire + 宽限期)。这正是下一章的选型表。
七、对比与选型
7.1 x86(TSO,强模型) vs ARM(弱模型):屏障的代价差
这是面试最爱问的"内存模型"题。核心差异:
| 维度 | x86 (TSO) | ARM (弱模型) |
|---|---|---|
| 允许的重排 | 只 StoreLoad 一种 | 四种都可能 |
smp_wmb()/smp_rmb() |
barrier()(几乎免费) |
真 fence 指令(dmb) |
smp_load_acquire/store_release |
READ_ONCE+barrier() |
专用 ldar/stlr 指令 |
smp_mb() |
lock; addl / mfence |
dmb ish(全屏障,重) |
| 移植风险 | 代码在 x86 上"碰巧对",换 ARM 就炸 | 屏障少写了在弱模型上必然出 bug |
关键结论 :在 x86 上靠"忘记加屏障"侥幸跑对的代码,移植到 ARM 会静默出错 ------因为 ARM 需要屏障的地方比 x86 多得多。这也是为什么内核用 smp_* 屏障(而非裸 barrier())表达意图:同一份代码,在强/弱模型上各展开成正确的实现。写内核同步代码时,语义(acquire/release/full)比具体指令重要。
7.2 什么时候用原子操作 vs 锁 vs RCU
| 场景 | 用什么 | 原因 |
|---|---|---|
| 单个计数器增减 | atomic_inc/dec |
一条 LOCK 指令,最轻 |
| 短临界区、竞争中等 | 自旋锁 qspinlock | 简单、公平(MCS 队列) |
| 读极多、写极少 | percpu 变量 / percpu-rwsem / RCU | 读路径零锁零竞争 |
| 指针发布、链表遍历 | RCU | 读者永不阻塞,写者延迟回收 |
| 读改写一个值、要旧值 | atomic_cmpxchg/atomic_xchg |
一条指令完成,免锁 |
| 无锁栈/队列(含 128 位) | cmpxchg16b 的 CAS 循环 |
需要指针+序号整体原子 |
判断口诀 :先看访问模式------纯计数用 atomic;短临界区用锁;读多写少用 RCU/percpu;需要"整体快照"用 CAS。
7.3 一张表收尾:重排 → 屏障 → x86 代价
| 要防的重排 | 内核宏 | x86 指令 | 代价 |
|---|---|---|---|
| 编译器任意重排 | barrier() |
"" : "memory" |
零 |
| LoadLoad / LoadStore | smp_rmb() / 数据依赖 |
barrier() |
零(TSO 已保证) |
| StoreStore | smp_wmb() |
barrier() |
零(TSO 已保证) |
| StoreLoad | smp_mb() |
lock; addl $0 / mfence |
有(唯一真花钱的) |
| acquire 读 | smp_load_acquire |
READ_ONCE+barrier() |
零 |
| release 写 | smp_store_release |
barrier()+WRITE_ONCE |
零 |
八、记住三点
- 两本账分开算 :原子性(LOCK 前缀锁 cache line,接 MESI)解决"中间态",内存屏障(防编译器/CPU/store buffer 三层乱序)解决"顺序"。
atomic_read/atomic_set只有原子性、没有顺序。 - x86 是 TSO 强模型,只有 StoreLoad 会乱 ,根因是每核的 store buffer。所以
smp_wmb()/smp_rmb()/acquire/release 在 x86 上都是免费的barrier(),只有smp_mb()要真指令 (lock; addl $0)。 - 同步原语 = 原子操作 + 屏障的固定组合 :qspinlock 用
cmpxchg_acquire抢锁 +store_release解锁;RCU 用store_release发布 +READ_ONCE读取;读懂这三层,#3死锁里的锁序、CPU 篇 #8percpu 的无锁读、CPU 篇 #14MESI 的缓存乒乓就全部打通了。