并发编程里我们说得最多的词是“锁”但锁并不是一种凭空存在的原语。把任意一把内核锁拆开底下永远是同样两块地基原子操作和内存屏障。本章我们暂不讲具体锁而是聚焦这两块地基——因为从第 2 章的spinlock到后面的mutex、rwsem、ww_mutex每一把锁都只是“原子操作 内存屏障 等待策略”的不同组合。地基不牢上层锁的正确性无从谈起。1.1 锁到底在防什么数据竞争的三种面孔两个 CPU 并发执行counter结果可能小于预期。原因是counter并非一条指令而是三步load r, [counter]; 读 add r, 1 ; 改 store [counter], r; 写若两个 CPU 几乎同时读到同一个旧值各自加一后写回两次自增只留下一次效果——这是丢失更新lost update。CPU Bcounter初值 0CPU ACPU Bcounter初值 0CPU A两次自增只留下 1丢失一次更新load 读到 0load 读到 00 1 10 1 1store 写回 1store 写回 1它是最直观的一种数据竞争但不是唯一一种。并发缺陷有三种面孔丢失更新多个“读-改-写”交错部分更新被覆盖。根因是 RMWread-modify-write 不是原子的。撕裂访问torn access一次读或写被编译器/硬件拆成多次。一个 64 位变量在某些路径上可能被拆成两个 32 位写另一个 CPU 就可能读到“写了一半”的值。乱序可见reordering即使每个访问本身原子不同 CPU 观察到的操作顺序也可能不同。重排来自两层——编译器在编译期调换无数据依赖的读写CPU 在运行期通过 store buffer、乱序执行改变全局可见顺序。针对前两种我们需要原子操作针对第三种我们需要内存屏障。1.2 先过编译器这一关READ_ONCE/WRITE_ONCE与编译器屏障上一节的三种竞争病根都在多核硬件。但在麻烦真正抵达硬件之前还站着一个更早出场的角色——编译器。它为了让代码跑得更快会对我们写下的每一次内存访问做大胆假设这些假设在单线程里天经地义搬到多线程就会闯祸。所以本章第一件武器既不是原子指令、也不是硬件屏障而是这层最轻、也最容易被忽略的防线。先看一个坑一个线程原地打转等另一个线程把标志置起while(!flag);/* 等 flag 变成非零 */在编译器看来这个循环里没有任何代码去改flag于是它把flag从内存读进寄存器一次之后每轮都只看寄存器、不再回内存核对。结果是哪怕另一个线程早已把flag置起这个循环也永远看不到变成了一个出不来的死循环。编译器并没有算错错在我们没有告诉它“这个变量会被别人从背后改动”。C 语言标准把这种“两个线程不加同步地读写同一个变量”直接定义为未定义行为data race is undefined behavior。一旦踩中编译器就有权做任何优化缓存进寄存器、把多次读合并成一次、甚至整段删掉。要堵住它我们得有办法对编译器下一道硬命令——“这次访问必须老老实实地、不多不少地打到内存上一次”。内核给出的答案就是READ_ONCE/WRITE_ONCE定义于include/asm-generic/rwonce.h#define__READ_ONCE(x)(*(constvolatile__unqual_scalar_typeof(x)*)(x))#defineREAD_ONCE(x)\({\compiletime_assert_rwonce_type(x);\__READ_ONCE(x);\})#define__WRITE_ONCE(x,val)\do{\*(volatiletypeof(x)*)(x)(val);\}while(0)拨开宏的外壳核心只有一个volatile强制转换。volatile是对编译器下的一道硬命令“这个地址随时可能被你看不见的力量改写不许缓存、不许合并、不许省略每次都实打实地去内存走一趟。”于是前面那个死循环只要改成while (!READ_ONCE(flag))编译器每一轮都会重新从内存取flag标志一置起循环立刻退出。顺带地它也堵住了 1.1 说的撕裂——volatile保证这次访问是机器字长内一次完整的读或写不会被拆成两半。而compiletime_assert_rwonce_type在编译期站岗一旦你拿它去访问超过机器字长、根本无法保证“单次原子”的类型直接编译报错把隐患挡在上线之前。READ_ONCE/WRITE_ONCE管得住“单个变量的单次访问”却管不了“多条语句之间谁先谁后”。当我们要约束的是“先写完 A再写 B两者别被调换”时就要请出另一件工具——编译器屏障#definebarrier()asmvolatile(:::memory)它是一条什么都不执行的空汇编玄机全在末尾的memory这相当于对编译器喊一声“走到这条线内存里的一切都可能已经变了别把我前面拿到的旧值跨过这里接着用”。编译器于是不敢把屏障两侧的内存访问对调顺序。但一定要记住它的边界——它只拦得住编译器拦不住 CPU。这里要特别强调一下本节两件工具都只作用于“源码 → 汇编”的编译期不会生成任何真实的屏障指令。READ_ONCE/WRITE_ONCE靠volatile强制转换约束的是编译器生成访存指令的行为——必须真的发一条 load/store不许缓存进寄存器不许合并或删除。它不产生任何额外的 CPU 指令如mfence、lock前缀生成的仍是一条普通的mov。barrier()是空汇编asm volatile( ::: memory)不生成任何机器指令只是命令编译器不要跨过这一点重排它安排指令的顺序。真正约束运行期 CPU 乱序、会实际生成LOCK前缀或mfence/lfence/sfence的手段要到 1.3 节的原子指令与 1.5 节的内存屏障才登场。把本节两件工具收成一句话READ_ONCE/WRITE_ONCE管“一个变量的这一次访问必须真实发生、且不被撕裂”barrier()管“编译器不许跨过这一点重排代码”。1.3 原子类型与原子操作atomic_t的本体朴素得出人意料就是一个对齐的intinclude/linux/types.htypedefstruct{int__aligned(sizeof(int))counter;}atomic_t;用结构体包一层是为了类型安全强制所有访问都走atomic_*API杜绝裸v.counter。但类型安全只是表壳真正的原子性来自硬件。注意本节是全章第一次跨过软硬件分界线1.2 节的READ_ONCE/barrier()都只在编译期约束编译器、不生成任何额外指令而从本节的原子 RMW 起我们第一次真正落到 CPU 指令层——x86 上靠LOCK前缀锁定缓存行现代实现基于缓存一致性协议而非锁总线由硬件保证一次读-改-写不可被打断。见arch/x86/include/asm/atomic.hstatic__always_inlineintarch_atomic_read(constatomic_t*v){return__READ_ONCE((v)-counter);/* 对齐读本身即原子无需 LOCK */}static__always_inlinevoidarch_atomic_add(inti,atomic_t*v){asm_inlinevolatile(LOCK_PREFIXaddl %1, %0:m(v-counter):ir(i):memory);/* RMW必须 LOCK */}static__always_inlineintarch_atomic_add_return(inti,atomic_t*v){returnixadd(v-counter,i);/* 带返回xadd 原子取回旧值 */}注意read/set不需要LOCK——在 x86 上对齐的字长读写天生原子只需READ_ONCE/WRITE_ONCE回到 1.2 的编译期控制防编译器捣乱即可真正需要LOCK的是“读-改-写”这类复合操作。这里请专注代码里的LOCK_PREFIX它正是软硬件分界。它定义在arch/x86/include/asm/alternative.h在 SMP 内核下展开成真实的lock前缀机器码0xF0拼在addl/cmpxchgl前面由 CPU 保证这条指令的原子性在单处理器UP内核下则展开成空串连前缀都不生成。换句话说LOCK_PREFIX是货真价实的一条硬件指令前缀这与 1.2 里“不生成任何指令”的barrier()、volatile形成鲜明对照——读到这一层我们已经站在硬件上了。据此把原子操作分为三类纯读写atomic_read/atomic_set。无返回值 RMWatomic_add/sub/inc/dec——只改不问结果。带返回值或带条件的 RMWatomic_add_return走xadd、atomic_dec_and_test/inc_and_test走GEN_UNARY_RMWcc直接读 CPU 标志位判断结果是否为零。第三类是引用计数、无锁算法的主力。1.4cmpxchg无锁与锁快速路径的共同基石如果说前面的原子操作是零件cmpxchgcompare-and-swapCAS就是把零件拼成任意无锁算法的万能接头。它的语义是“若内存当前值等于期望的旧值就写入新值并返回原值。”x86 实现见arch/x86/include/asm/cmpxchg.h#define__raw_cmpxchg(ptr,old,new,size,lock)\({\...\case__X86_CASE_L:\{\volatileu32*__ptr(volatileu32*)(ptr);\asm_inlinevolatile(lockcmpxchgl %2, %1\:a(__ret),m(*__ptr)\:r(__new),0(__old)\:memory);\break;\}\...\})#definearch_cmpxchg(ptr,old,new)\__cmpxchg(ptr,old,new,sizeof(*(ptr)))/* 带 LOCK_PREFIX */这里的lock不是新东西正是 1.3 那个LOCK_PREFIX——只不过__raw_cmpxchg把它抽成了一个宏参数好让同一份汇编模板服务两种变体arch_cmpxchg走__cmpxchg填入LOCK_PREFIXSMP 下即真实的lock前缀保证跨 CPU 原子而arch_cmpxchg_local走__cmpxchg_local填入空串只防本 CPU 中断、省掉前缀以求更快。所以cmpxchg的原子性依旧落在 1.3 划定的那条硬件线上。有了 CAS任何“读出旧值 → 算出新值 → 原子换上”的更新都能写成一个重试循环oldREAD_ONCE(v);do{newf(old);}while(!try_cmpxchg(v,old,new));/* 失败时 old 被刷新为最新值重来 */这个范式的重要性怎么强调都不过分后续每一把锁的“抢锁快速路径”本质都是一次 CAS——mutex用 CAS 把 owner 从 NULL 换成 currentqspinlock用 CAS 抢 pending 位rwsem用 CAS 改 count。理解了这个循环就理解了所有锁快速路径的骨架。CAS 有个经典陷阱叫 ABA 问题值从 A 变 B 再变回 ACAS 会误判“没变过”它在 RCU 与无锁数据结构章节再展开。1.5 内存屏障四种基本屏障与 CPU 侧实现原子操作解决了“单个操作不可分割”但解决不了“多个操作之间的可见顺序”。考虑经典的消息传递CPU 0: data 42; CPU 1: while (!flag) ; flag 1; r data;即使data和flag各自的写都是原子的CPU 1 也可能先看到flag 1却仍读到data的旧值——因为两个写在 CPU 0 的 store buffer 里可能乱序刷出或 CPU 1 的两个读被乱序执行。修好它需要屏障。内核的四种基本屏障与 x86 实现见arch/x86/include/asm/barrier.h#define__mb()asmvolatile(mfence:::memory)/* 全屏障读写都不跨越 */#define__rmb()asmvolatile(lfence:::memory)/* 读屏障 */#define__wmb()asmvolatile(sfence:::memory)/* 写屏障 */#define__smp_mb()asmvolatile(lock addl $0,-4(%%_ASM_SP):::memory,cc)#define__smp_rmb()dma_rmb()#define__smp_wmb()barrier()几个要点mb/rmb/wmb用于与设备MMIO/DMA交互映射到mfence/lfence/sfence。smp_mb/smp_rmb/smp_wmb用于 CPU 之间。x86 是强序模型普通读读、写写本就不会被 CPU 乱序所以smp_rmb/smp_wmb直接退化成编译器屏障只有“写后读”这一种重排 x86 允许因此smp_mb必须是真屏障——这里用一条lock addl $0, -4(%rsp)对栈顶做一次带锁的空加法充当全屏障比mfence更快。弱序架构ARM64、PowerPC 等上smp_rmb/smp_wmb都是实打实的屏障指令。正因如此可移植代码必须显式写屏障不能依赖 x86 的强序“运气”。顺着 1.3 划下的那条软硬件分界线这四种屏障也可以按“是否生成硬件指令”归位屏障x86 生成是否硬件指令mb/rmb/wmbmfence/lfence/sfence是smp_mblock addl $0, -4(%rsp)是靠lock前缀smp_rmb/smp_wmb退化为barrier()否纯编译期要抓住的关键是同一行屏障是否落到硬件指令取决于目标架构的强弱序。smp_wmb()在 x86 上是空的barrier()回到 1.2 的编译期控制换到 ARM64 就变成真实的dmb指令。这也解释了上一条要点——你在 x86 上漏写smp_wmb往往“碰巧正确”一移植到弱序架构立刻暴雷。1.6 acquire / release锁真正依赖的顺序模型全屏障语义强但开销大。锁真正需要的是一种单向的、更廉价的顺序临界区里的访问不许“泄漏”到临界区外。这正是 acquire/release 模型acquire用于加锁其后的所有访问不得被重排到 acquire 之前。release用于解锁其前的所有访问不得被重排到 release 之后。两者一夹临界区内的读写就被牢牢关在里面。x86 实现见arch/x86/include/asm/barrier.h#define__smp_store_release(p,v)\do{\compiletime_assert_atomic_type(*p);\barrier();\WRITE_ONCE(*p,v);\}while(0)#define__smp_load_acquire(p)\({\typeof(*p)___p1READ_ONCE(*p);\compiletime_assert_atomic_type(*p);\barrier();\___p1;\})在强序的 x86 上acquire/release不需要任何真屏障指令一个编译器屏障barrier()加READ_ONCE/WRITE_ONCE就够了——因为硬件已经保证了除“写后读”外的顺序。这解释了 x86 上锁的解锁路径为何可以如此廉价一次带 release 语义的普通写。但这仍是 x86 的红利在 ARM64 这类弱序架构上acquire/release 会落到专门的硬件指令如ldar/stlr解锁不再是一次普通写——这与 1.5 表格里smp_wmb的“x86 退化、弱序变真指令”如出一辙。同理x86 上原子操作前后的屏障是空的#define__smp_mb__before_atomic()do{}while(0)#define__smp_mb__after_atomic()do{}while(0)因为带LOCK前缀的原子操作本身就是一个全屏障无需再补。把 lock 理解为 acquire、unlock 理解为 release是贯穿后续所有锁章节的顺序模型基础。1.7 内核原子 API 的三层结构翻源码时会发现atomic_add有好几个名字arch_atomic_add、raw_atomic_add、atomic_add。这不是重复而是刻意的三层结构arch_atomic_*架构相关实现如上文 x86 的LOCK_PREFIX addl。架构只需实现一个最小集。fallback 生成层include/linux/atomic/atomic-arch-fallback.h脚本据最小集自动补全所有变体如架构没实现add_return就用cmpxchg循环合成。atomic_*插桩层include/linux/atomic/atomic-instrumented.h对外的公开 API包一层 KASAN/KCSAN 插桩用于并发缺陷检测。这三层几乎全部由scripts/atomic/下的脚本生成。分层的意义架构移植者只写最小汇编通用变体与调试插桩一次性交给公共层既减负又统一。日常写驱动只需用最上层的atomic_*无需关心底下两层。1.8 把地基拼成一把锁现在用本章的零件手写一把最小自旋锁验证“锁 CAS 抢 release 放 等待策略”这个公式structminimal_lock{atomic_tlocked;};/* 0空闲 1占用 */staticvoidminimal_lock(structminimal_lock*l){intexpected;do{expected0;/* CAS 抢锁把 0 换成 1成功即获得锁acquire 语义 */}while(!atomic_try_cmpxchg_acquire(l-locked,expected,1));}staticvoidminimal_unlock(structminimal_lock*l){/* release 语义写回 0保证临界区内的写都已对他人可见 */atomic_set_release(l-locked,0);}逐项对照atomic_try_cmpxchg_acquire是 1.4 的 CAS 1.6 的 acquireatomic_set_release是 1.6 的 releasedo-while里的空转就是“等待策略”。这把锁能用但它的等待策略太原始——所有争抢者都在同一个locked上自旋写制造大量缓存行颠簸cache-line bouncing。第 2 章的qspinlock会把这个“裸自旋”升级成 MCS 队列让每个等待者自旋在自己的本地节点上。这正是从“能用的锁”到“可扩展的锁”的分水岭。1.9 误用与调试地基层最常见的坑把裸v当原子多核下必丢更新改用atomic_inc或cmpxchg循环。漏READ_ONCE/WRITE_ONCE轮询共享标志时写while (!flag)编译器可能把flag提进寄存器变成死循环应写while (!READ_ONCE(flag))。屏障不配对smp_store_release的写方必须有smp_load_acquire的读方与之配对单边屏障等于没有顺序保证。消息传递两端都要设屏障。误信 x86 强序在 x86 上漏写smp_rmb/smp_wmb往往“碰巧正确”一移植到 ARM64 立即暴雷。写可移植代码要按弱序模型思考。本章小结一切锁都建立在两块地基上原子操作用LOCK前缀 /cmpxchg保证“读-改-写”不可分割解决丢失更新与撕裂内存屏障约束多核可见顺序其中 acquire/release 这对单向屏障恰好构成“临界区不外泄”的最小充分模型——lock 即 acquireunlock 即 release。带着“锁 原子 屏障 等待策略”这个公式下一章我们拆解第一把真正的锁——从最朴素的spinlock到用 MCS 队列消除争用的qspinlock。