Linux内核分析之内核同步-00
This language version is unavailable; showing the other language.
15.1 原子操作 —— atomic_t 与 atomic64_t
原子操作是内核并发设施的最低层:一条不可分割的"读-改-写"(RMW)指令完成的计数或位操作,不需要锁、不需要睡眠,是自旋锁(16.1 节)、refcount(8.1 节的 task_struct.usage)、信号量与全部引用计数机制的地基。Linux 7.0.10 把原子 API 组织成清晰的三层生成结构:手写的架构层(arch_atomic_*)→ 脚本生成的通用回退层(raw_atomic_*)→ 脚本生成的插桩层(atomic_*),内核代码只调用最上层。本节将结合 Linux 7.0.10 内核源码,逐行分析类型定义、API 家族、x86 的 LOCK 前缀指令实现,以及带饱和检查的 refcount_t。
15.1.1 类型定义与三层组织
atomic_t:对齐的 int 包装
// include/linux/types.h:186-196
typedef struct {
int __aligned(sizeof(int)) counter;
} atomic_t;
#define ATOMIC_INIT(i) { (i) }
#ifdef CONFIG_64BIT
typedef struct {
s64 counter;
} atomic64_t;
#endif
类型刻意做成单字段结构体而非裸 int:一是禁止编译器把它当普通变量优化掉,二是阻止原子变量与非原子变量混用(类型不匹配,编译报错)。__aligned(sizeof(int)) 保证 counter 的自然对齐——不对齐的原子操作在部分架构上是未定义行为。atomic_long_t 不是第三种类型,而是按位宽在两者间选择:
// include/linux/atomic/atomic-long.h:10-19
#ifdef CONFIG_64BIT
typedef atomic64_t atomic_long_t;
#define ATOMIC_LONG_INIT(i) ATOMIC64_INIT(i)
#define atomic_long_cond_read_acquire atomic64_cond_read_acquire
#define atomic_long_cond_read_relaxed atomic64_cond_read_relaxed
#else
typedef atomic_t atomic_long_t;
#define ATOMIC_LONG_INIT(i) ATOMIC_INIT(i)
...
#endif
语义上要"一个机器字长的原子量、且不关心 32/64 位差异"的场合(如 12.4 节 memcg 的页面计数)用 atomic_long_t。
三层生成结构
include/linux/atomic.h(顶层入口,include 顺序在 :80-82)把三个头文件串成调用链:
内核代码
| atomic_add(i, v)
v
atomic-instrumented.h 插桩层(脚本生成):KCSAN/KMSAN 钩子
| instrument_atomic_read_write() -> raw_atomic_add(i, v)
v
atomic-arch-fallback.h 通用回退层(脚本生成):raw_atomic_*
| 架构没提供的 API 用通用手法补齐(如 cmpxchg 循环)
v
arch_atomic_add() 架构层(手写):arch/x86/include/asm/atomic.h
LOCK_PREFIX "addl %1, %0"
// include/linux/atomic/atomic-instrumented.h:101-106
static __always_inline void
atomic_add(int i, atomic_t *v)
{
instrument_atomic_read_write(v, sizeof(*v));
raw_atomic_add(i, v);
}
插桩层每个函数只做两件事:挂上并发错误检测器(KCSAN)的读写观察点,然后转发 raw_atomic_*。三个生成文件的头部都标注 Generated by scripts/atomic/gen-*.sh / DO NOT MODIFY——API 清单由 scripts/atomic/atomics.tbl 声明,改 API 先改表再重新生成,保证三种位宽(atomic/atomic64/atomic_long)× 多种内存序(relaxed/acquire/release)的组合全部一致。本树的目录布局:include/linux/atomic/ 下仅 atomic-arch-fallback.h、atomic-instrumented.h、atomic-long.h 三个文件(旧版常见的独立 atomic64.h 已并入 instrumented 层)。
15.1.2 API 家族
内核代码调用的 atomic_* 家族按返回语义分四组:
| 组别 | 代表 API | 返回 | x86 实现 |
|---|---|---|---|
| 纯 RMW(无返回值) | atomic_add/sub/inc/dec/and/or/xor |
void | LOCK addl/incl/... |
| 返回值 | atomic_add_return/inc_return(新值)、atomic_fetch_add/...(旧值) |
int | lock xadd 等 |
| 交换/比较交换 | atomic_xchg/cmpxchg/try_cmpxchg |
旧值 / bool | lock cmpxchg / xchg |
| 位操作 | test_and_set_bit 等原子位 API |
旧位 | lock btsl/... |
命名规律:atomic_add_return 返回加完的新值,atomic_fetch_add 返回加之前的旧值;两者都隐含完整内存屏障(16 章锁实现大量依赖这一点,如 16.3.3 节的 atomic_add_return_acquire)。各 API 还有 ..._relaxed / ..._acquire / ..._release 后缀变体,显式指定所需的内存序(15.2 节语义),无后缀版本是 full barrier——按需选弱序版本是内核的性能惯例。
x86 底层指令
// arch/x86/include/asm/atomic.h:17-36
static __always_inline int arch_atomic_read(const atomic_t *v)
{
/*
* Note for KASAN: we deliberately don't use READ_ONCE_NOCHECK() here,
* it's non-inlined function that increases binary size and stack usage.
*/
return __READ_ONCE((v)->counter);
}
...
static __always_inline void arch_atomic_add(int i, atomic_t *v)
{
asm_inline volatile(LOCK_PREFIX "addl %1, %0"
: "+m" (v->counter)
: "ir" (i) : "memory");
}
读不是原子指令(line 17-24):arch_atomic_read() 就是 __READ_ONCE()(15.4 节)——单字对齐的 load 天然原子,加 LOCK 前缀纯属浪费。真正的总线锁只花在 RMW 上:add/sub/and/or/xor 用 LOCK_PREFIX 修饰对应算术指令,返回值族用 lock xadd:
// arch/x86/include/asm/atomic.h:83-95
static __always_inline int arch_atomic_add_return(int i, atomic_t *v)
{
return i + xadd(&v->counter, i);
}
#define arch_atomic_add_return arch_atomic_add_return
...
static __always_inline int arch_atomic_fetch_add(int i, atomic_t *v)
{
return xadd(&v->counter, i);
}
xadd(arch/x86/include/asm/cmpxchg.h:241-242)一条指令完成"旧值返回 + 相加",add_return 在其上加 i 还原新值——一条指令就是一个完整的返回值原子加法。64 位版本对称(arch/x86/include/asm/atomic64_64.h:13-21, 77-89,lock addq)。
LOCK_PREFIX 的定义揭示了 UP 内核的优化(arch/x86/include/asm/alternative.h:44-57):SMP 构建下展开为 lock 前缀并在 .smp_locks 节登记位置;UP 构建下是空串。运行时 alternatives 机制(2.8 节)还能在单 CPU 启动的 SMP 内核里把 LOCK 前缀 NOP 掉。CAS 族的 x86 实现值得单独注意 try_cmpxchg(cmpxchg.h:201-215):用 "=@ccz" 约束直接读 ZF 标志返回 bool 并把旧值回写,比传统 cmpxchg 返回值再比较的方式省一次比较分支——内核新代码的 CAS 循环一律推荐它(16.1 节 qspinlock 快路径即如此)。
15.1.3 CAS 循环:无锁算法的积木
atomic_cmpxchg/atomic_try_cmpxchg 是无锁数据结构的基本积木:读旧值 → 计算新值 → cmpxchg 提交;失败则拿到最新值重算重试。标准形态:
// kernel/trace/ring_buffer.c:4955-4964
void ring_buffer_record_off(struct trace_buffer *buffer)
{
unsigned int rd;
unsigned int new_rd;
rd = atomic_read(&buffer->record_disabled);
do {
new_rd = rd | RB_BUFFER_OFF;
} while (!atomic_try_cmpxchg(&buffer->record_disabled, &rd, new_rd));
}
EXPORT_SYMBOL_GPL(ring_buffer_record_off);
循环的三要素:旧值在循环外读一次(不是循环内反复 atomic_read)、新值由旧值推导、try_cmpxchg 失败时把最新旧值回写 rd 供下轮推导。这个形态没有锁、没有自旋等待——失败即重试,进展由竞争烈度决定,是"乐观并发"的极致形态(16.2 节 mutex 乐观自旋、17.4 节 llist 头插都是它的变体)。
15.1.4 refcount_t:带饱和检查的引用计数
动机与语义
裸 atomic_t 做引用计数的致命弱点:一个 UAF(use-after-free)导致的"减到零后再减"会把计数变成 -1,对象看似"又有引用"实则已释放;溢出绕回正数同样致命。refcount_t 在计数上叠加饱和语义:检测到异常就锁定到饱和值并告警,宁可泄漏不可重用。8.1 节的 task_struct.usage、17.4 节的 kref 都是它的用户。
// include/linux/refcount.h:280-292
static inline __signed_wrap
void __refcount_add(int i, refcount_t *r, int *oldp)
{
int old = atomic_fetch_add_relaxed(i, &r->refs);
if (oldp)
*oldp = old;
if (unlikely(!old))
refcount_warn_saturate(r, REFCOUNT_ADD_UAF);
else if (unlikely(old < 0 || old + i < 0))
refcount_warn_saturate(r, REFCOUNT_ADD_OVF);
}
加零检查(line 288-289):给计数为 0 的对象加引用,即对已释放内存的引用(REFCOUNT_ADD_UAF),立刻告警并把计数钉死在饱和值 REFCOUNT_SATURATED = INT_MIN/2(refcount.h:115)。溢出检查(line 290-291):旧值为负或结果为负——负值只可能来自 UAF 或溢出。实现上为省开销不在每个操作里做 cmpxchg 循环回退:先放任计数越过边界,检测到后用 atomic_set 显式写入饱和值——此后所有加减都撞在饱和值上不再变化(refcount.h:14-19 的设计注释),错误的对象永远不会被错误地释放第二次,也不会被错误地续命。
refcount_inc()(refcount.h:369-384)是"从 1 开始的加引用",计数为 0 直接告警——get_task_struct()(8.1 节)用的正是它;refcount_inc_not_zero() 走 __refcount_add_not_zero()(refcount.h:173-190)的 cmpxchg 循环,遇 0 静默失败返回 false,用于"对象可能已死、加不上就算了"的查寻路径(14.3 节信号发送前的目标确认是同类模式)。
减法同样有讲究:
// include/linux/refcount.h:386-403
static inline bool __refcount_sub_and_test(int i, refcount_t *r, int *oldp)
{
int old = atomic_fetch_sub_release(i, &r->refs);
...
if (old == i) {
smp_acquire__after_ctrl_dep();
...
return true;
}
...
}
归零判定用 release 语义的 fetch_sub + 控制依赖后的 acquire(line 387, 391):release 保证对象最后的清理写先于计数归零可见,smp_acquire__after_ctrl_dep() 保证"看到归零"之后的释放操作(kfree)不会提前——引用计数的归零即发布"可安全释放"的信号,这对内存序正是 15.2 节 RELEASE/ACQUIRE 协议的应用。
atomic_t 与 refcount_t 的选择
| atomic_t | refcount_t | |
|---|---|---|
| 语义 | 通用计数器 | 引用计数(≥0,饱和保护) |
| 开销 | 最小(单指令) | 略高(检查分支) |
| 误用后果 | 静默计数错乱 | 告警 + 饱和,错误可定位 |
| 典型场景 | 统计、状态标志、资源余量 | 对象生命周期引用(8.1 节 usage、kref) |
原则一句话:凡是"减到零要释放对象"的计数必须用 refcount_t;纯统计(数值下降不触发释放)用 atomic_t。
15.1.5 使用场景总结
| 场景 | 首选 | 说明 |
|---|---|---|
| 对象生命周期引用计数 | refcount_t / kref | 饱和保护防 UAF(8.1、17.4 节) |
| 简单统计计数 | atomic_t / atomic64_t | 高频时优先 per-CPU 变量(15.3 节) |
| 状态标志位 | atomic_t 位 API / atomic_long_t | test_and_set 实现单次触发 |
| 无锁结构提交 | cmpxchg / try_cmpxchg 循环 | 15.1.3 节三要素形态 |
| 发布-订阅指针 | 配合 smp_store_release/load_acquire | 15.2 节与 17.1.2 节 RCU |
要点回顾:原子操作是"单变量、单操作"的并发原语——它不提供临界区(多变量一致性要用锁),但在自己的变量上提供绝对可靠的最小同步。x86 上的成本梯度是:纯 load 免费、RMW 一条 LOCK 指令、CAS 循环按竞争付费。下一节的内存屏障解决的是原子操作没覆盖的另一半问题:多个普通访存之间的顺序。
15.2 内存屏障 —— compiler barrier 与 CPU barrier
编译器为了性能会重排指令,CPU 为了性能也会乱序执行访存——单线程语义下两者都无懈可击,但并发代码依赖的"写 A 之后写 B,别人必先看到 A"这类顺序保证会被悄悄打破。内存屏障是并发代码向编译器和 CPU 声明顺序要求的显式标注。Linux 把屏障分成两层:compiler barrier 只约束编译器,零指令开销;CPU barrier 约束真实的访存顺序,在弱序架构上生成同步指令、在 x86 的强序(TSO)模型上多数退化为空。13.2 节的 smp_mb__after_spinlock()、16 章各锁的 ACQUIRE/RELEASE、17.1 节 RCU 的发布-订阅都建立在本节的概念之上。本节将结合 Linux 7.0.10 内核源码,逐行分析两层屏障的实现、x86 的特殊优化、以及 acquire/release 语义的配对模型。
15.2.1 为什么需要屏障:两层乱序源
编译器乱序
编译器只保证单线程可观察行为等价。对下面这段"直觉正确"的代码:
/* 线程 0 */ /* 线程 1 */
data = 42; while (!ready)
ready = 1; ; /* 等 ready */
read data; /* 期待读到 42 */
编译器完全可能把 ready = 1 重排到 data = 42 之前(两者无数据依赖),或者——更隐蔽地——把 while (!ready) 优化成"读一次 ready 后死循环"(单线程语义允许)。两层问题分别需要 compiler barrier 与 volatile 语义的读来解决。
CPU 乱序
现代 CPU 的写缓冲、缓存一致性与推测执行引入额外的乱序:x86 是 TSO(Total Store Order)——store 不会越过前面的 store,load 不会越过前面的 load,唯一允许的重排是 Store→Load(一个 CPU 的 load 越过自己更早的 store,因为 store 还在写缓冲里没进缓存)。ARM64 与 RISC-V 是弱序模型,四类重排(Load→Load、Load→Store、Store→Store、Store→Load)几乎都允许。因此同一个正确算法在不同架构上需要的屏障数量不同——内核 API 的设计目标就是让上层代码表达"需要什么顺序",由架构层翻译成最便宜的指令。
Documentation/memory-barriers.txt(本树共 3016 行)是这一领域的权威规范,其分类框架:
// Documentation/memory-barriers.txt:1835-1843
CPU MEMORY BARRIERS
-------------------
The Linux kernel has seven basic CPU memory barriers:
TYPE MANDATORY SMP CONDITIONAL
======================= =============== ===============
GENERAL mb() smp_mb()
WRITE wmb() smp_wmb()
READ rmb() smp_rmb()
ADDRESS DEPENDENCY READ_ONCE()
四种类型(全屏障/写屏障/读屏障/地址依赖)× 两档(强制/SMP 条件),且文档明确:"除地址依赖外,所有内存屏障都隐含编译器屏障"(:1847-1848)——CPU barrier 是 compiler barrier 的超集。
15.2.2 compiler barrier:barrier() 与 READ_ONCE/WRITE_ONCE
barrier()
// include/linux/compiler.h:83-86
/* Optimization barrier */
#ifndef barrier
/* The "volatile" is due to gcc bugs */
# define barrier() __asm__ __volatile__("": : :"memory")
#endif
一条空的内联汇编,"memory" clobber 告诉编译器:此处的内存被未知代码读写了,之前缓存在寄存器里的内存值必须作废、访存不得跨越此点重排。它不生成任何指令,纯粹是编译器的顺序栅栏。典型用途是 preempt_enable() 这类"计数变了,但硬件没变"的场景(15.1 节 preempt.h:212 的 barrier())——需要约束的只是编译器视角。
READ_ONCE() / WRITE_ONCE()
并发访问单变量时,x = y 形式的普通赋值有两个隐患:编译器可能拆成多条指令(对宽于字长的类型)或拆分读改写(x |= FLAG 是三条指令的非原子序列);循环里的读可能被提升出循环。READ_ONCE/WRITE_ONCE 强制"单次、不可优化"的访问:
// include/asm-generic/rwonce.h:43-62
#ifndef __READ_ONCE
#define __READ_ONCE(x) (*(const volatile __unqual_scalar_typeof(x) *)&(x))
#endif
#define READ_ONCE(x) \
({ \
compiletime_assert_rwonce_type(x); \
__READ_ONCE(x); \
})
#define __WRITE_ONCE(x, val) \
do { \
*(volatile typeof(x) *)&(x) = (val); \
} while (0)
#define WRITE_ONCE(x, val) \
do { \
compiletime_assert_rwonce_type(x); \
__WRITE_ONCE(x, val); \
} while (0)
实现是volatile 限定的单次访存:volatile 禁止编译器合并、省略或重排这一次访问;__unqual_scalar_typeof(include/linux/compiler_types.h:619-643,_Generic 或 __typeof_unqual__ 实现)把表达式类型上的 const/volatile 限定剥掉,使读出的值仍是普通标量、可以正常参与运算;compiletime_assert_rwonce_type() 限定操作数必须是标量——对结构体的"一次性读写"本就不该存在。注意本树的文件位置:READ_ONCE/WRITE_ONCE 定义在 include/asm-generic/rwonce.h,不在 compiler.h。x86 的 arch_atomic_read()(15.1.2 节)就是它们的直接用户;文档把它们归类为"地址依赖屏障的当代实现"(memory-barriers.txt:1843)——17.1.2 节 RCU 订阅正是靠 READ_ONCE 保住地址依赖。
data_race():声明"我知道这里有竞争"
// include/linux/compiler.h:190-198
#define data_race(expr) \
({ \
__kcsan_disable_current(); \
disable_context_analysis(); \
auto __v = (expr); \
enable_context_analysis(); \
__kcsan_enable_current(); \
__v; \
})
对"多个 CPU 并发写同一标量、且语义上允许读到任意时刻快照"的场景(如统计计数、调试标志),用 data_race(expr) 包裹访问:KCSAN 数据竞争检测器与编译器上下文分析在此显式豁免。它的价值是把"故意的竞争"变成显式标注——检测器不再报警,review 者一眼看出此处为何没有保护。
15.2.3 CPU 内存屏障与 x86 实现
三层命名
屏障 API 有三层前缀,自内向外:
__mb/__rmb/__wmb——架构原语;mb/rmb/wmb——强制屏障(mandatory):无论 UP/SMP、无论对方是 CPU 还是设备 DMA 都有效;smp_mb/smp_rmb/smp_wmb——SMP 条件屏障:只约束 CPU 之间的可见性,UP 构建下退化为barrier()(include/asm-generic/barrier.h:110-124)。
x86 的架构层定义:
// arch/x86/include/asm/barrier.h:21-25, 50-57
#else
#define __mb() asm volatile("mfence":::"memory")
#define __rmb() asm volatile("lfence":::"memory")
#define __wmb() asm volatile("sfence" ::: "memory")
#endif
...
#define __dma_rmb() barrier()
#define __dma_wmb() barrier()
#define __smp_mb() asm volatile("lock addl $0,-4(%%" _ASM_SP ")" ::: "memory", "cc")
#define __smp_rmb() dma_rmb()
#define __smp_wmb() barrier()
TSO 的红利一览无余(line 53-57):
__smp_rmb():x86 的 load 不会越过 load,无需任何指令(dma_rmb()亦是barrier());__smp_wmb():store 不会越过 store,同样是barrier();__smp_mb():唯一堵不住的是 Store→Load 重排,需要一条真指令——内核选lock addl $0,-4(%rsp)(对栈顶做加零)而非mfence,因为这条指令在多数微架构上更快且不依赖 SSE2。
强制屏障则不同:mfence/lfence/sfence(line 22-24)用于设备 DMA 与 IO 内存场景(__dma_* 的空操作只对普通写回内存成立,设备一致性内存另论),代价高得多。经验法则:CPU 间同步用 smp_*,与设备打交道用 mb/wmb/rmb。
通用架构的对照
没有 TSO 的架构必须生成真指令。通用定义(include/asm-generic/barrier.h:138-155):
// include/asm-generic/barrier.h:138-155
#ifndef __smp_store_release
#define __smp_store_release(p, v) \
do { \
compiletime_assert_atomic_type(*p); \
__smp_mb(); \
WRITE_ONCE(*p, v); \
} while (0)
#ifndef __smp_load_acquire
#define __smp_load_acquire(p) \
({ \
__unqual_scalar_typeof(*p) ___p1 = READ_ONCE(*p); \
compiletime_assert_atomic_type(*p); \
__smp_mb(); \
(typeof(*p))___p1; \
})
#endif
通用版 release = 先全屏障再写、acquire = 先读再全屏障——正确但昂贵;ARM64 用专门的 stlr/ldar 指令实现(一次访存自带序),RISC-V 用 fence rw,w / fence r,rw。同一个 API 在三种架构上指令数从 0 到 1 不等,这正是分层 API 的意义。
15.2.4 smp_store_release / smp_load_acquire:成对的顺序保证
x86 的零开销实现
acquire/release 是内核并发模型中最重要的成对语义:release store 保证"此写之前的所有访存先于此写可见";acquire load 保证"此读之后的访存不早于此读"。二者配对即构成"先行发生"(happens-before)链。x86 上它们免费:
// arch/x86/include/asm/barrier.h:59-72
#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) ___p1 = READ_ONCE(*p); \
compiletime_assert_atomic_type(*p); \
barrier(); \
___p1; \
})
只需 compiler barrier + 普通访存:TSO 天然保证 store 顺序(release)与 load 顺序(acquire),且 TSO 不重排 Store→Store / Load→Load,唯一缺口 Store→Load 恰好不被这两种语义要求。16 章的 queued_spin_unlock()(16.1.5 节)、mutex 的 cmpxchg_acquire、17 章的 rcu_assign_pointer() 都靠这对免费语义。compiletime_assert_atomic_type() 拒绝非原子类型的操作数——release 语义只对单个原子大小的存储有意义。
与 smp_mb 的选择
| 需求 | 用什么 | x86 成本 |
|---|---|---|
| 只需发布一个值(之前的写先可见) | smp_store_release |
0 |
| 只需消费一个值(之后的读不提前) | smp_load_acquire |
0 |
| 需要双向全序(如 MMIO 前后、双向依赖) | smp_mb |
lock addl |
能用 acquire/release 就不要用全屏障——不仅是省指令,更是把"需要什么顺序"写进了代码语义,review 与验证工具都能读懂。
before_atomic / after_atomic:与原子操作的配对
// arch/x86/include/asm/barrier.h:74-79
/* Atomic operations are already serializing on x86 */
#define __smp_mb__before_atomic() do { } while (0)
#define __smp_mb__after_atomic() do { } while (0)
这对宏处理"非返回值原子操作不带屏障"(15.1.2 节命名规律的推论)的补序需求:smp_mb__before_atomic() + atomic_add() 相当于"先屏障再加";x86 上它们是空操作——LOCK 指令本身已是全序的。弱序架构上则展开为 smp_mb()。典型场景:先 smp_mb__after_atomic() 再读某个由另一 CPU 用普通写更新的标志——保证原子操作的效果先于标志读可见。13.2 节调度器的 smp_mb__after_spinlock()(定义在 include/linux/spinlock.h:175-177,x86 同样为空——自旋锁的 LOCK ACQUIRE 语义已提供所需顺序)是同一家族的第三员,其注释完整解释了 try_to_wake_up 与 set_current_state 配对所需的内存序。
15.2.5 真实用例:PSI trigger 的发布-订阅
// kernel/sched/psi.c:1557-1575
/* Take seq->lock to protect seq->private from concurrent writes */
mutex_lock(&seq->lock);
...
new = psi_trigger_create(&psi_system, buf, res, file, NULL);
if (IS_ERR(new)) {
mutex_unlock(&seq->lock);
return PTR_ERR(new);
}
smp_store_release(&seq->private, new);
// kernel/sched/psi.c:1482-1493
__poll_t psi_trigger_poll(void **trigger_ptr,
struct file *file, poll_table *wait)
{
__poll_t ret = DEFAULT_POLLMASK;
struct psi_trigger *t;
...
t = smp_load_acquire(trigger_ptr);
if (!t)
return DEFAULT_POLLMASK | EPOLLERR | EPOLLPRI;
写者创建 trigger 对象(含字段初始化)后用 release store 发布指针;读者的 acquire load 保证看到指针时对象字段已完整初始化——与 17.1.2 节 RCU 发布-订阅相同的协议,只是没有宽限期环节(对象生命周期由 seq->lock 保证)。这是 acquire/release 最小完整用例的形态。
15.2.6 屏障选择决策表
| 场景 | 屏障 | 依据 |
|---|---|---|
| 循环等另一 CPU 改的标志 | READ_ONCE() |
防编译器提升读(15.4 节) |
| 发布初始化完成的对象 | smp_store_release |
写者侧 |
| 消费已发布的对象 | smp_load_acquire |
读者侧 |
| 单 CPU 约束编译器 | barrier() |
preempt 计数等 |
| 原子 RMW 前后补序 | smp_mb__before/after_atomic |
x86 免费弱序付费 |
| 自旋锁后读共享状态 | smp_mb__after_spinlock |
调度器 wake 配对(13.2 节) |
| 设备 DMA 描述符 | wmb()/dma_wmb() |
强制屏障,非 smp |
| 发布 RCU 指针 | rcu_assign_pointer(内含 release) |
17.1.2 节 |
要点总结:
- 乱序有两个来源(编译器、CPU),屏障因此分两层:
barrier()/READ_ONCE/WRITE_ONCE 约束编译器且零指令成本,smp_*约束 CPU 间可见序。 - x86 TSO 让
smp_rmb/smp_wmb免费化,smp_mb只需一条lock addl $0;acquire/release 在 x86 上零指令——能用成对语义就不用全屏障。 - API 分层(架构原语 → 强制 → SMP 条件)让上层代码表达意图、架构层翻译最便宜指令;同一 API 的指令数从 0(x86)到数条(弱序架构)不等。
- READ_ONCE/WRITE_ONCE 在
asm-generic/rwonce.h,是原子读、地址依赖、seqlock、RCU 订阅的共同地基——下一节单独展开它们与数据竞争的关系。
15.3 per-CPU 变量 —— 无锁共享
并发争用的根源是共享写:多个 CPU 对同一缓存行的写必须串行化(缓存一致性协议裁决),哪怕逻辑上毫无冲突。per-CPU(每处理器)变量的解法釜底抽薪:让每个 CPU 拥有一份自己的副本——本 CPU 读写自己的副本无需任何原子操作、任何锁、任何缓存行争用;需要全局视图时(或读或周期性汇总)再把各 CPU 的副本相加。preempt_count 本身(15.1 节)、调度器的运行队列 rq(11 章)、统计计数(percpu_counter)、以及 8.5 节的 current 宏实现都建立在它上面。本节将结合 Linux 7.0.10 内核源码,逐行分析 per-CPU 变量的声明宏、x86_64 的 %gs 段寻址机制、三组访问 API 的抢占语义差异,以及底层分配器的 chunk 组织。
15.3.1 数据布局:一份定义,N 份实例
声明与定义
// include/linux/percpu-defs.h:99-114
#define DECLARE_PER_CPU_SECTION(type, name, sec) \
extern __PCPU_ATTRS(sec) __typeof__(type) name
#define DEFINE_PER_CPU_SECTION(type, name, sec) \
__PCPU_ATTRS(sec) __typeof__(type) name
#endif
...
#define DECLARE_PER_CPU(type, name) \
DECLARE_PER_CPU_SECTION(type, name, "")
#define DEFINE_PER_CPU(type, name) \
DEFINE_PER_CPU_SECTION(type, name, "")
// include/linux/percpu-defs.h:47-52
#define __PCPU_ATTRS(sec) \
__percpu __attribute__((section(PER_CPU_BASE_SECTION sec))) \
PER_CPU_ATTRIBUTES
DEFINE_PER_CPU(int, counter) 声明的 counter 被放进专门的 .data..percpu 段,并带 __percpu 地址空间注解。关键在于这个"变量"的真实身份:链接器(4.4 节链接脚本中 .data..percpu 段与 __per_cpu_start/__per_cpu_end 符号)把段内所有 per-CPU 变量排成一张"模板",启动时内核为每个 CPU 复制一份(3.2 节 setup_per_cpu_areas()),此后符号地址不再是有效指针,而是模板内的相对偏移——真正的实例地址要加上本 CPU 的偏移量。__percpu 注解让 sparse/编译器拒绝"直接解引用 per-CPU 变量"的错误代码。
变体后缀按对齐与放置需求选择(percpu-defs.h:123-187):_ALIGNED(缓存行对齐——OSQ 节点 16.2.4 节用的 DEFINE_PER_CPU_SHARED_ALIGNED 就是它,防伪共享)、_PAGE_ALIGNED、_READ_MOSTLY。
寻址:模板偏移 + 每 CPU 基址
每个 CPU 的实例区有一段基址偏移,存放在全局数组与 per-CPU 变量两处:
// include/asm-generic/percpu.h:33-52
/*'];
*/
extern unsigned long __per_cpu_offset[NR_CPUS];
#define per_cpu_offset(x) (__per_cpu_offset[x])
访问任意 CPU 的实例:per_cpu_ptr(ptr, cpu) = 模板地址 + __per_cpu_offset[cpu]。访问本 CPU 的实例则更快——x86_64 用段寄存器硬件化这个加法:
// arch/x86/include/asm/percpu.h:5-11
#ifdef CONFIG_X86_64
# define __percpu_seg gs
# define __percpu_rel (%rip)
#else
# define __percpu_seg fs
# define __percpu_rel
#endif
本树 x86_64 用 %gs 段(%fs 仅 32 位)。GS base 寄存器在 CPU 启动时被写成该 CPU 实例区的基址(arch/x86/kernel/cpu/common.c:809 的 wrmsrq(MSR_GS_BASE, cpu_kernelmode_gs_base(cpu))),于是"本 CPU 实例地址 = GS base + 模板偏移"由内存操作数的段前缀硬件完成——mov %gs:offset, %rax 一条指令直达本 CPU 的副本。偏移量本体也是 per-CPU 变量 this_cpu_off(arch/x86/kernel/setup_percpu.c:29,:166 初始化),供不支持段前缀寻址的场景软件计算:
// arch/x86/include/asm/percpu.h:52-77
/*
* Compared to the generic __my_cpu_offset version, the following
* saves one instruction and avoids clobbering a temp register.
*/
#define __my_cpu_offset this_cpu_read(this_cpu_off)
...
#define arch_raw_cpu_ptr(_ptr) \
({ \
unsigned long tcp_ptr__ = raw_cpu_read_long(this_cpu_off); \
\
tcp_ptr__ += (__force unsigned long)(_ptr); \
(TYPEOF_UNQUAL(*(_ptr)) __force __kernel *)tcp_ptr__; \
})
本树的新编译器路径(CONFIG_CC_HAS_NAMED_AS)进一步用 __seg_gs 地址空间属性替代内联汇编的段前缀(percpu.h:35-50),让普通 C 指针运算直接带 GS 语义、获得更好的优化与检查。8.5 节的 current 宏(raw_cpu_read(current_task),一条 %gs: 前缀的 mov)正是这套机制最著名的用户。
15.3.2 三组 API 的抢占语义
per-CPU 访问的最大陷阱是抢占:读到"本 CPU"地址后被抢占、换到另一个 CPU 恢复执行,后续访问就在改别的 CPU 的副本了。三组 API 的差别全部围绕抢占:
| API 组 | 抢占保护 | 语义 | 典型场景 |
|---|---|---|---|
raw_cpu_* |
无 | 调用者自行保证(已关抢占/中断) | 已持自旋锁、中断上下文 |
__this_cpu_* |
无(但做抢占检查,违规告警) | 同上,debug 构建下自检 | 同上,带防护网 |
this_cpu_* |
隐含(x86 单指令天然原子) | 被抢占也安全 | 未关抢占的普通上下文 |
get_cpu_var/put_cpu_var |
显式 preempt_disable/enable | 成对禁抢占访问 | 需要多条指令的读改写 |
// include/linux/percpu-defs.h:434-449, 499-515
#define __this_cpu_read(pcp) \
({ \
__this_cpu_preempt_check("read"); \
raw_cpu_read(pcp); \
})
#define __this_cpu_write(pcp, val) \
({ \
__this_cpu_preempt_check("write"); \
raw_cpu_write(pcp, val); \
})
...
/*
* Operations with implied preemption/interrupt protection. These
* operations can be used without worrying about preemption or interrupt.
*/
__this_cpu_*(line 434-493)与 raw_cpu_* 生成完全相同的代码,只多一个 __this_cpu_preempt_check——普通构建下编译为空,开启抢占正确性检查时若 preempt_count 为 0 则告警。this_cpu_*(line 499-515)的注释即其契约:x86 上 this_cpu_read/add/inc 等编译为单条带 %gs: 前缀的指令(如 incl %gs:offset),单指令对本 CPU 而言原子——即使中途被抢占、换 CPU 恢复,那条指令要么已在自己 CPU 完成、要么将在新 CPU 完成,副本数据仍然一致。
隐含保护的能力边界:仅覆盖单条指令的操作。需要"读-改-写并回读结果"的多步操作(如 this_cpu_read 后基于值决定再写)必须在禁抢占下进行——用 get_cpu_var()/put_cpu_var()(percpu-defs.h:279-305,封装 preempt_disable() + this_cpu_ptr)或先行 preempt_disable()。15.1 节的 preempt_count 四段位域此时派上用场:嵌套的关抢占只是计数递增。
get_cpu_var 的成对封装
// include/linux/percpu-defs.h:279-305
#define get_cpu_var(var) \
(*({ \
preempt_disable(); \
this_cpu_ptr(&var); \
}))
...
#define put_cpu_var(var) \
do { \
(void)&(var); \
preempt_enable(); \
} while (0)
get_cpu_var(var) 是个左值(*this_cpu_ptr(&var)),可直接赋值——get_cpu_var(jiffies_64) += delta; put_cpu_var(jiffies_64); 的形态在时间子系统中常见。注意本树它们定义在 percpu-defs.h(percpu.h 只含分配器接口)。
15.3.3 动态分配:alloc_percpu 与 chunk 组织
分配接口
// include/linux/percpu.h:139-158
extern void __percpu *__alloc_percpu_gfp(size_t size, size_t align, gfp_t gfp);
extern void __percpu *__alloc_percpu(size_t size, size_t align);
extern void free_percpu(void __percpu *__pdata);
静态 DEFINE_PER_CPU 覆盖编译期已知的变量;运行期按需的 per-CPU 内存(如 16.2.4 节 OSQ 那种每结构一份的阵列、percpu_counter 的计数数组)用 alloc_percpu(type)——返回 __percpu 注解的指针,同样通过 per_cpu_ptr/raw_cpu_ptr 访问,free_percpu() 释放。
chunk:分配器的组织单元
底层分配器(mm/percpu.c)把每 CPU 的实例区组织成 chunk(块):一个 chunk 覆盖所有 CPU 的同一序号实例区——CPU 0 的副本在 chunk->base_addr + 0,CPU 1 的在 + unit_size,依此类推。核心元数据:
// mm/percpu-internal.h:48-88
struct pcpu_chunk {
...
struct list_head list; /* linked to pcpu_slot lists */
int free_bytes; /* free bytes in the chunk */
struct pcpu_block_md chunk_md;
unsigned long *bound_map; /* boundary map */
...
void *base_addr ____cacheline_aligned_in_smp;
unsigned long *alloc_map; /* allocation map */
struct pcpu_block_md *md_blocks; /* metadata blocks */
...
int nr_pages; /* # of pages served by this chunk */
int nr_populated; /* # of populated pages */
int nr_empty_pop_pages; /* # of empty populated pages */
unsigned long populated[]; /* populated bitmap */
};
base_addr 是本 chunk 的起始虚拟地址(所有 CPU 的 unit 连续排布),alloc_map/md_blocks 维护块内分配位图,populated[] 记录哪些页已实际映射物理页(惰性填充——分配时先记账,缺页再补,pcpu_populate_chunk())。chunk 按"剩余空间"挂在 pcpu_chunk_lists 的 slot 链表上(mm/percpu.c:132-155 的 pcpu_unit_pages/unit_size/nr_units 等全局常量在首个 chunk 建立时由 pcpu_setup_first_chunk() 计算,:2667-2672)。
分配路径
pcpu_alloc_noprof()(mm/percpu.c:1736,alloc_percpu 的最终落点)的骨架:
// mm/percpu.c:1814-1833
restart:
/* search through normal chunks */
for (slot = pcpu_size_to_slot(size); slot <= pcpu_free_slot; slot++) {
list_for_each_entry_safe(chunk, next, &pcpu_chunk_lists[slot],
list) {
off = pcpu_find_block_fit(chunk, bits, bit_align,
is_atomic);
...
off = pcpu_alloc_area(chunk, bits, bit_align, off);
if (off >= 0) {
pcpu_reintegrate_chunk(chunk);
goto area_found;
}
}
}
流程:size 按 PCPU_MIN_ALLOC_SIZE(4 字节,percpu.h:24-37)对齐 → 可睡眠路径先拿 pcpu_alloc_mutex、原子(不可睡眠)路径只持 pcpu_lock 自旋锁 → 按剩余空间分级扫描 slot 链表,pcpu_find_block_fit() + pcpu_alloc_area() 在位图里找连续区间 → 找不到则 pcpu_create_chunk() 建 chunk 后 goto restart → area_found 处补页、清零、经 __addr_to_pcpu_ptr()(mm/percpu.c:112-130,地址与 pcpu 指针的双向映射)返回。vmap 区域(20.3 节)为 per-CPU 内存提供了各 CPU unit 的连续虚拟排布,两者在地址空间上交汇。
15.3.4 local_t:本 CPU 原子计数器
per-CPU 与原子操作的交叉点上还有一个轻量设施 local_t(本树无 include/linux/local.h,由 include/asm-generic/local.h:19-55 与 arch/x86/include/asm/local.h:7-43 提供):
// arch/x86/include/asm/local.h:19-36
static inline void local_inc(local_t *l)
{
asm volatile(_ASM_INC "%0"
: "+m" (l->a.counter));
}
...
static inline void local_add(long i, local_t *l)
{
asm volatile(_ASM_ADD "%1,%0"
: "+m" (l->a.counter)
: "ir" (i));
}
没有 LOCK 前缀——local_inc 就是普通 incl。它保证的原子性范围是本 CPU(含本 CPU 上的中断/NMI 打断),对其他 CPU 完全不原子。价值:本 CPU 独占写、其他 CPU 只读的计数(perf 环形缓冲区写指针 kernel/events/internal.h:26、ftrace ring buffer 的提交计数 kernel/trace/ring_buffer.c:343-364)省掉总线锁。与 this_cpu_inc 的分工:local_t 强调"精确的本 CPU 原子语义"(NMI 安全),this_cpu_* 强调"副本隔离";现代代码多数场景直接用后者。
15.3.5 使用模式总结
统计计数的两级聚合
高频统计的标准形态是 per-CPU 累加 + 读时汇总:
static DEFINE_PER_CPU(unsigned long, pkt_count);
void count_pkt(void) /* 热路径:本 CPU 单条指令 */
{
this_cpu_inc(pkt_count);
}
u64 total_pkts(void) /* 冷路径:汇总所有 CPU */
{
int cpu;
u64 sum = 0;
for_each_online_cpu(cpu)
sum += per_cpu(pkt_count, cpu);
return sum;
}
写侧零争用、任意伸缩;读侧 O(NR_CPUS) 但低频。percpu_counter(include/linux/percpu.h 提供的封装,9.6 节 IPC ns 计数用之)在此之上加了批量缓冲——本 CPU 攒够 batch 才落入全局 atomic,读侧更快但计数有 ±batch 误差。选型规则:需要精确计数用裸 per-CPU + 全局锁读,容忍误差用 percpu_counter。
独占写模型
per-CPU 更深的应用是结构级独占:调度器的 rq(11 章)整个结构 per-CPU,锁(rq_lock)只为"本 CPU 的 rq 与远程 CPU 的只读交互"存在;qnodes(16.1.7 节)、osq_node(16.2.4 节)是"每 CPU 固定槽位"形态——锁算法本身建立在 per-CPU 内存上,这是它消除缓存行风暴的根基。
要点总结:
- per-CPU 变量用"模板 + 每 CPU 偏移"的布局把共享写拆成 N 份私有写,x86_64 用
%gs段基址硬件化寻址,单条指令直达本 CPU 副本。 - 三组 API 的分野是抢占:
raw_cpu_*无保护、__this_cpu_*加检查、this_cpu_*隐含保护(单指令原子);多步操作必须get_cpu_var/显式禁抢占。 - 动态分配由 chunk 组织(每 chunk 覆盖所有 CPU 的同一序号 unit),惰性补页,slot 链表按剩余空间分级。
- local_t 是"仅本 CPU 原子"的计数器特例;统计计数选 per-CPU + 汇总、对象引用计数选 refcount_t(15.1.4 节),两者的分工不可混淆。
per-CPU 消除了副本之间的争用,但"读自己的副本"仍可能与其他 CPU 的写发生逻辑上的顺序问题——这把话题引回本章 15.2 的屏障,以及下一个更日常的问题:一个普通变量的并发读写到底会发生什么。这正是 15.4 节 READ_ONCE/WRITE_ONCE 与数据竞争的主题。
15.4 READ_ONCE / WRITE_ONCE 与数据竞争
内核里数量最多的并发 bug 不是死锁,而是数据竞争:一个 CPU 写、另一个 CPU 读同一个普通变量,中间没有任何同步。这类 bug 的阴险之处有两层:一是竞争窗口可能极窄,压测多年不复现;二是编译器优化会把"没有同步"的代码变得与源码语义完全不同——读被提升出循环、写被拆分成两半、访存被重排。READ_ONCE/WRITE_ONCE 是内核对此的第一道防线:强制单次访问的语义,让"并发的单变量读写"至少行为可预期;第二道防线是 KCSAN 这样的动态检测器。本节将结合 Linux 7.0.10 内核源码,逐行分析这对原语的实现细节、它们与 volatile/atomic 的关系、典型数据竞争形态及其修复,以及内核对这些场景的分层策略。
15.4.1 没有 READ_ONCE 会发生什么
三个经典的编译器陷阱
陷阱一:读被提升(hoisting)。 竞争等待的标准写法:
/* 错误版本 */
while (!stop)
do_work();
/* 编译器眼中的等价形式(无数据依赖,允许优化) */
if (!stop)
for (;;) do_work(); /* stop 永远不再被读! */
单线程语义下 stop 在循环内不会变,编译器把它提出循环完全合法——但写者永远无法停下这个循环。修复:while (!READ_ONCE(stop))。
陷阱二:写被拆分(tearing)。 对宽于机器字长的类型(32 位机上写 64 位变量)或位域,编译器可以生成多次访存:
/* 错误版本 */
config.mode = MODE_A; /* 64 位写,32 位机上拆成两条 store */
/* 另一个 CPU 读到的可能是"半旧半新" */
修复:WRITE_ONCE(config.mode, MODE_A)(配对位宽的对齐访问),或换 atomic API。内核要求:跨 CPU 传递的变量必须用字长对齐的类型 + ONCE 访问。
陷阱三:重排与省略。 flag = 1; data = 42; 可能被重排(15.2 节的乱序源);条件写入 if (cond) shared = x; 中 shared = x 可能被推测执行后写回(编译器发现 x 已在寄存器时可能无条件写)。修复:显式控制流保护 + WRITE_ONCE。
volatile 为什么不够
C 的 volatile 解决"不要省略这次访问",但不提供任何跨线程语义:不禁止重排(相对其他访问)、不保证原子性(宽类型照拆)、不建立 happens-before。Java/C++ 的 volatile 语义各不相同且都不等于内存屏障。内核的立场(memory-barriers.txt 明确阐述):volatile 在内核并发中是错的工具——需要顺序用屏障 API,需要单次访问用 READ_ONCE/WRITE_ONCE,需要原子性用 atomic API。内核仅有的 volatile 使用场景是 MMIO 寄存器访问(readl/writel 内部)——那里"每次都必须真的读写设备"正是 volatile 的本义。
15.4.2 READ_ONCE / WRITE_ONCE 的实现剖析
15.2.2 节已给出实现骨架,本节补齐语义细节:
// include/asm-generic/rwonce.h:43-80
#ifndef __READ_ONCE
#define __READ_ONCE(x) (*(const volatile __unqual_scalar_typeof(x) *)&(x))
#endif
#define READ_ONCE(x) \
({ \
compiletime_assert_rwonce_type(x); \
__READ_ONCE(x); \
})
#define __WRITE_ONCE(x, val) \
do { \
*(volatile typeof(x) *)&(x) = (val); \
} while (0)
#define WRITE_ONCE(x, val) \
do { \
compiletime_assert_rwonce_type(x); \
__WRITE_ONCE(x, val); \
} while (0)
四个设计点:
const volatile修饰的读(line 44):volatile强制真实访存、禁止提升与合并;const(读侧)让编译器把结果当常量值优化——READ_ONCE 的语义是"读一个别人可能改的值"而非"可变的变量",表达式结果仍是普通标量。__unqual_scalar_typeof(include/linux/compiler_types.h:619-643):_Generic分支(或__typeof_unqual__)剥掉类型限定符——volatile int x; int y = READ_ONCE(x);中 y 的类型是int而非volatile int,避免限定符沿表达式扩散引发诡异行为。早期实现曾把 volatile 限定带进表达式结果类型,引发过一系列正确性争议,这一规范化正是历代演进的成熟形态。compiletime_assert_rwonce_type():操作数必须是标量——对结构体的"原子读"没有意义(大小不定必然撕裂),编译期直接拒绝。- 零指令成本:展开后就是一次带 volatile 限定的普通访存,x86 上无任何额外指令;内存序由 15.2 节的屏障 API 显式叠加(
smp_load_acquire内部就是 READ_ONCE + 屏障,asm-generic 版见 15.2.3 节)。
与 atomic API 的边界:READ_ONCE/WRITE_ONCE 提供"单次访问不撕裂、不被优化掉",但两个 ONCE 写不构成原子 RMW——需要"读-改-写原子"(计数器递增)必须升级到 atomic API(15.1 节),需要"写写互斥"需要锁(16 章)。ONCE 家族是"比 atomic 弱、比裸访问强"的中间档。
15.4.3 数据竞争的定义与内核的分类处理
什么是数据竞争
两个(或更多)CPU 并发访问同一内存位置、至少一个是写、且没有任何同步关系(锁、原子 RMW、屏障配对、RCU 宽限期)——按 C11 内存模型这就是未定义行为;工程上表现为读到撕裂值、过期值或编译器优化导致的逻辑荒诞。
内核对这个问题的态度是分层分类而非一刀切:
| 并发访问的模式 | 内核的工具 | 代表场景 |
|---|---|---|
| 需要多变量一致性 | 锁(16 章) | 临界区 |
| 单变量读改写原子性 | atomic API(15.1 节) | 计数器、标志 RMW |
| 单变量单次读写 | READ_ONCE/WRITE_ONCE | 竞争标志、发布值 |
| 故意的良性竞争 | data_race() |
统计、调试计数 |
| 读者容忍旧值+写者单次发布 | RCU / seqcount(17 章、16.3 节) | 配置指针、只读快照 |
| 先写后发的顺序依赖 | 屏障(15.2 节) | 生产-消费 |
data_race():良性竞争的显式豁免
15.2.2 节已给出 data_race() 的定义(compiler.h:190-198,包住表达式并禁用 KCSAN 与上下文分析)。典型用户是"读数仅供展示"的计数:
/* 形态示例:并发写、读出的值允许略旧或不精确 */
pr_info("dropped %llu packets\n", data_race(stats.dropped));
不用 data_race 时 KCSAN 会报竞争(无法区分良性与恶性);用它即声明"竞争存在且无害"——检测器的信噪比与代码的自文档性同时提升。注意其边界:data_race 只豁免"单次访问",多步读改写仍需 atomic。
15.4.4 竞争标志:set_current_state 的配对协议
数据竞争最精妙的形态是睡眠-唤醒配对——8.2 节已从状态机角度分析过,这里从内存序角度重看:
/* 等待者(15 章视角) */
set_current_state(TASK_INTERRUPTIBLE); /* WRITE_ONCE(current->__state) + smp_mb */
if (!cond) /* 读写者将更新的条件 */
schedule();
/* 唤醒者 */
cond = true; /* 或 WRITE_ONCE(cond, true) */
try_to_wake_up(task); /* 内含 smp_mb__after_spinlock(13.2 节) */
set_current_state() = smp_store_mb()(写状态 + 全屏障,8.2.5 节);try_to_wake_up() 在检查任务状态前先过 smp_mb__after_spinlock()。两者配对保证:等待者要么在设状态前看到了 cond=true(不睡),要么唤醒者一定看到新状态并发起唤醒——不存在"cond 已真但等待者永远睡着"的窗口。如果等待者用 __set_current_state()(纯 WRITE_ONCE 无屏障),这个保证就只剩锁路径有效,裸等待(无锁轮询 cond)会出现丢失唤醒。这是数据竞争修复的教科书案例:竞争本身(无锁读写 cond 与 __state)依然存在,但通过屏障配对把"未定义行为"收窄成"两种可接受的结果之一"——内核大量代码正是这种"受控竞争"。
15.4.5 KCSAN:动态竞争检测
内核自带的竞争检测器 KCSAN(Kernel Concurrency SANitizer,配置项 CONFIG_KCSAN)采用watchpoint 采样:编译期插桩(15.1.1 节 instrumented 层的 instrument_atomic_read_write 即其一)后,运行时对随机选中的访存设置延迟 watchpoint——若延迟窗口内另一 CPU 访问同一地址且其中有写,即报告竞争。它与 data_race()、原子 API 的插桩钩子协作:被显式标注或走原子路径的访问不报。KCSAN 与 lockdep(16.4 节)构成并发正确性的双检测器——lockdep 证锁序、KCSAN 证数据访问,两者都在 debug 构建运行于 CI 与 syzkaller。
15.4.6 修复模式总结
| 症状 | 根因 | 修复 |
|---|---|---|
| 循环等不到标志 | 读被提升 | READ_ONCE() |
| 读到半新半旧的值 | 写撕裂(宽类型/位域) | 对齐字长 + WRITE_ONCE(),或 atomic |
| 偶发丢失唤醒 | set_current_state 用了无屏障变体 | set_current_state()(含 smp_store_mb) |
| 计数偶发丢失 | 裸 x++ 并发 |
atomic_inc 或 per-CPU(15.3 节) |
| KCSAN 报告刷屏 | 良性竞争未标注 | data_race() |
| 对象字段读到未初始化值 | 发布无 release | smp_store_release + smp_load_acquire(15.2.5 节) |
| 多变量状态不一致 | 竞争需临界区 | 锁(16 章) |
要点总结:
- 数据竞争的第一杀手是编译器优化(提升、撕裂、重排),READ_ONCE/WRITE_ONCE 以零指令成本消除前两者;volatile 不是答案。
- ONCE 家族处于"裸访问"与"atomic"之间的中间档:保证单次访问不撕裂不被优化,不提供 RMW 原子性与互斥。
- 内核以"分层分类"处理竞争:锁管一致性、atomic 管 RMW、ONCE 管单次访问、data_race 豁免良性竞争、屏障配对把必要竞争收窄为受控竞争(set_current_state 协议)。
- KCSAN 以采样 watchpoint 动态检测竞争,与 lockdep 互补构成并发正确性检测体系。
至此第 15 章的四个支柱——原子操作、内存屏障、per-CPU、ONCE 访问——全部展开。它们单独看都极小,但 16 章的每一把锁、17 章的每一次 RCU 读都由这些零件组装而成;理解它们的成本模型,才能理解上层原语为什么长成现在的形状。