Linux内核分析之内核同步-00

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 有三层前缀,自内向外:

  1. __mb/__rmb/__wmb——架构原语;
  2. mb/rmb/wmb——强制屏障(mandatory):无论 UP/SMP、无论对方是 CPU 还是设备 DMA 都有效;
  3. 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)

四个设计点:

  1. const volatile 修饰的读(line 44):volatile 强制真实访存、禁止提升与合并;const(读侧)让编译器把结果当常量值优化——READ_ONCE 的语义是"读一个别人可能改的值"而非"可变的变量",表达式结果仍是普通标量。
  2. __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 限定带进表达式结果类型,引发过一系列正确性争议,这一规范化正是历代演进的成熟形态。
  3. compiletime_assert_rwonce_type():操作数必须是标量——对结构体的"原子读"没有意义(大小不定必然撕裂),编译期直接拒绝。
  4. 零指令成本:展开后就是一次带 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 读都由这些零件组装而成;理解它们的成本模型,才能理解上层原语为什么长成现在的形状。