土法炼钢兴趣小组的算法知识备份

Linux 内核的内存屏障:一个让我调了三天的 bug

目录

上一篇文章里,我们聊了”大多数自称无锁的代码其实不是无锁的”。那篇讲的是用户态 C11 memory_order 的问题。这一篇换个视角——内核态。

故事很短:一个在 x86 上稳定运行了两年的内核模块,迁移到 ARM64 的第一周就开始丢数据。最终修复只有两行代码。但找到这两行代码,花了我三天。

症状

我们有一个自定义的网络驱动模块,核心是一个环形缓冲区(ring buffer):内核中断上下文作为 producer 往里写数据包元信息,用户态线程通过 mmap 作为 consumer 读取。标准的单生产者/单消费者(SPSC)模式。

x86 服务器上这个模块跑了两年多,零事故。

公司决定把这批工作负载迁移到 ARM64 服务器(成本考量)。迁移过程很顺利——编译通过,功能测试通过,小流量灰度通过。

然后流量上来了。

监控开始报偶发的数据校验错误。频率不高,大概每百万次操作出现一两次。但在高负载下,错误率稳定在万分之几。而且只在 ARM 服务器上出现。同样的内核模块、同样的负载,x86 上跑满压测——零错误。

ring buffer 的核心代码大概长这样:

/* producer(中断上下文) */
void produce(struct ring *r, struct packet_info *pkt)
{
    unsigned int tail = r->tail;
    unsigned int next = (tail + 1) % r->size;
    if (next == READ_ONCE(r->head))  /* 满了 */
        return;

    r->buf[tail] = *pkt;            /* 写数据 */
    r->tail = next;                  /* 更新 tail */
}

/* consumer(用户态 mmap 读取,简化表示) */
struct packet_info consume(struct ring *r)
{
    unsigned int head = r->head;
    if (head == READ_ONCE(r->tail))  /* 空的 */
        return EMPTY;

    struct packet_info pkt = r->buf[head];  /* 读数据 */
    r->head = (head + 1) % r->size;
    return pkt;
}

看上去很直白,对不对?数据写了,tail 更新了;tail 变了,数据就该在那里了。

在 x86 上确实如此。在 ARM 上不是。


第一章:三个错误假设

假设一:“一定是竞态条件”

第一反应是经典的并发 bug:某个地方缺了锁保护。

于是我在 producer 和 consumer 的关键路径上都加了 spinlock_t。producer 拿锁写数据、更新 tail、放锁;consumer 拿锁读 head、读数据、更新 head、放锁。

结果:性能暴跌(中断上下文里拿自旋锁本来就贵),错误频率降低了一个数量级,但没有消失。偶尔还是会读到不一致的数据。

回过头看,这是典型的”掩盖症状而非修复根因”。加锁确实减少了时间窗口,但问题根本不在”竞态”这个层面。

假设二:“编译器优化的锅”

第二个想法:编译器把 r->buf[tail] = *pktr->tail = next 重排了,或者把某些读写优化掉了。

于是我给关键变量加上了 volatile

volatile unsigned int tail;
volatile unsigned int head;

结果:完全没有改善。该错还是错。

这让我非常困惑——volatile 不是告诉编译器”不要对这个变量做假设”吗?为什么没用?

(剧透:volatile 确实阻止了编译器优化,但它对 CPU 的行为完全无能为力。这是 C/C++ 领域最深的误解之一。后面第六章专门讲。)

假设三:“ARM 编译器有 bug 吧”

走投无路时人会开始怀疑工具链。我用了三个不同版本的 GCC(9.4、11.3、12.2)和两个版本的 Clang(14、15)分别编译,结果完全一样。

还反汇编对比了关键路径的指令序列,store 和 load 指令的顺序在所有编译器输出中一致:先 str(写数据),再 str(写 tail)。编译器没有重排任何东西。

那问题到底在哪里?


第二章:工具救场

KCSAN 的线索

第二天早上,我决定启用 KCSAN(Kernel Concurrency Sanitizer)。KCSAN 是内核内置的数据竞争检测工具,通过编译器插桩在运行时检测并发访问冲突。

重新编译内核(CONFIG_KCSAN=y),加载模块,跑负载。

几分钟后 dmesg 里出现了:

BUG: KCSAN: data-race in produce / consume

write to 0xffff800012345678 of 4 bytes by interrupt on cpu 2:
 produce+0x48/0x80 [mydriver]

read to 0xffff800012345680 of 4 bytes by task 1234 on cpu 5:
 consume+0x30/0x60 [mydriver]

KCSAN 指出了 produce 的写操作和 consume 的读操作之间存在数据竞争。但它指向的不是同一个地址——producer 写 buf[tail] 和 consumer 读 buf[head],在不同的位置。

这条线索一开始让我更困惑了:它们读写的明明是不同的缓冲区槽位,怎么会有数据竞争?

ftrace 的时序反常

接着我用 ftrace 给 produce/consume 加了 kprobe,记录每次操作的时间戳和 head/tail 值。

翻了几千条日志后,一条异常记录跳了出来:

[cpu 2] produce: write buf[42], set tail=43, ts=10000500
[cpu 5] consume: read tail=43, read buf[42]=STALE, ts=10000520

consumer 在 produce 之后 20ns 读到了 tail=43(说明 tail 的更新已经可见),但 buf[42] 的内容还是旧的——producer 写入的新数据还没传播过来。

tail 的更新比数据的写入先到达另一个核心。

在 x86 上这不可能发生——TSO(Total Store Order)保证同一个核心的 store 按程序顺序对其他核心可见。但在 ARM 上,CPU 可以自由重排两个无依赖关系的 store。r->buf[tail] = *pkt(写数据)和 r->tail = next(写索引)对 CPU 来说是两个独立的写操作——它完全有权利先让 tail 的更新对其他核心可见。

顿悟时刻

时间线清楚了,但修复方案还模糊。这时候我在读 io_uring 的 SQE/CQE ring buffer 实现(我们的 io_uring 多线程模式文章中提到过 io_uring 通过内存屏障协作保证队列正确性),看到了这两行:

/* io_uring: 内核写 CQE 后发布给用户态 */
smp_store_release(&rings->cq.tail, ctx->cached_cq_tail);

/* io_uring: 用户态读 CQE 前获取 */
head = smp_load_acquire(&rings->cq.head);

smp_store_releasesmp_load_acquire

回头看我们的 ring buffer——r->tail = next 是一个普通的 store。没有任何屏障。在 ARM 上,CPU 完全有权利在这个 store 之前,先让它对其他核心可见,哪怕数据还没写完。

这不是竞态条件,不是编译器 bug,不是 ARM 编译器的锅。这是一个缺失的内存屏障


第三章:真相——编译器屏障 vs 硬件屏障

要理解内存屏障,必须先理解一件事:你的代码在被执行之前,要经过两层变形

第一层:编译器重排

编译器为了优化性能,会在不改变单线程语义的前提下重排指令。例如:

a = 1;
b = 2;

编译器完全有权利生成先写 b 再写 a 的代码,因为在单线程下这两条语句没有依赖关系。volatile 可以阻止这种重排——但它只在这一层有效。

内核的 barrier() 宏也是纯编译器屏障:

#define barrier() __asm__ __volatile__("" ::: "memory")

这条内联汇编什么硬件指令都不生成(空字符串),但 "memory" clobber 告诉编译器:“我可能修改了任意内存,你之前缓存在寄存器里的值全部作废。” 编译器因此不会跨过这个点做读写重排。

第二层:CPU 重排

编译器生成了正确顺序的指令之后,CPU 的乱序执行引擎和 store buffer 还会进一步重排。这一层重排是硬件行为,编译器屏障管不了,volatile 也管不了。

在 x86 上,TSO(Total Store Order)内存模型保证: - store-store 不重排(先写的先被其他核心看到) - load-load 不重排 - load 不会被重排到后面的 store 之前

唯一允许的重排是 store-load:一个 store 可能被后面的 load “超过”。这就是为什么 smp_wmb()(写屏障)在 x86 上编译成空操作——TSO 已经保证了 store-store 顺序,不需要额外的硬件指令。

ARM 完全不同。ARM 的弱内存模型允许四种重排全部发生:store-store、store-load、load-load、load-store。这意味着 smp_wmb() 在 ARM 上必须生成真正的硬件屏障指令。

Linux 内核屏障 API 全景

Linux 内核屏障 API 分类

从最轻量到最重量:

API 作用 x86 编译产物 ARM64 编译产物
barrier() 纯编译器屏障 无硬件指令 无硬件指令
READ_ONCE(x) 防编译器撕裂/合并/发明读 mov (普通 load) ldr (普通 load)
WRITE_ONCE(x, v) 防编译器撕裂/合并/发明写 mov (普通 store) str (普通 store)
smp_rmb() 读屏障:rmb 之前的 load 不会被重排到 rmb 之后 barrier() dmb ishld
smp_wmb() 写屏障:wmb 之前的 store 不会被重排到 wmb 之后 barrier() dmb ishst
smp_mb() 全屏障:阻止所有方向的重排 lock; addl $0,(%rsp) dmb ish
smp_load_acquire(p) 读 + 后续操作不会被重排到此读之前 mov (普通 load) ldar
smp_store_release(p, v) 前面的操作不会被重排到此写之后 mov (普通 store) stlr

关键观察:

  1. READ_ONCE / WRITE_ONCE 不是屏障。它们只防止编译器对访问做”创造性”优化(合并多次读为一次、发明不存在的读写、撕裂大于字长的访问)。很多人把它们当屏障用——这是错的。

  2. smp_wmb() 在 x86 上是空操作(只有编译器屏障效果)。这就是为什么我们的 ring buffer 在 x86 上即使没有任何显式屏障也能正确工作——TSO 免费提供了 store-store 顺序。

  3. smp_store_release / smp_load_acquire 是推荐的现代 API。它们比成对使用 smp_wmb() + smp_rmb() 更精确、更易推理,也更符合 C11 的 release/acquire 语义。io_uring 的 ring buffer 就用的这对 API。

回到我们的 bug

在我们的 ring buffer 中,producer 做了两件事: 1. 写数据到 buf[tail] 2. 更新 tail

没有任何屏障。

在 x86 上:TSO 保证这两个 store 按顺序可见。没问题。

在 ARM 上:CPU 可能先让 tail 的更新对其他核心可见,再让 buf[tail] 的数据可见。consumer 看到新的 tail,去读 buf[head],读到的还是旧数据。

这就是那个”不可能的时间戳”的解释:tail 先到了,数据后到了。


第四章:修复

最小修复:smp_wmb / smp_rmb

/* producer */
void produce(struct ring *r, struct packet_info *pkt)
{
    unsigned int tail = r->tail;
    unsigned int next = (tail + 1) % r->size;
    if (next == READ_ONCE(r->head))
        return;

    r->buf[tail] = *pkt;
    smp_wmb();           /* <-- 数据写入完成后,再让 tail 可见 */
    WRITE_ONCE(r->tail, next);
}

/* consumer */
struct packet_info consume(struct ring *r)
{
    unsigned int head = r->head;
    if (head == READ_ONCE(r->tail))
        return EMPTY;

    smp_rmb();           /* <-- 确认 tail 可见后,再读数据 */
    struct packet_info pkt = r->buf[head];
    smp_wmb();           /* <-- 数据读完后,再让 head 更新可见 */
    WRITE_ONCE(r->head, (head + 1) % r->size);
    return pkt;
}

两行 smp_wmb(),一行 smp_rmb()。这三行是 ring buffer 的”屏障骨架”:

Ring buffer 屏障时序

更好的写法:smp_store_release / smp_load_acquire

上面的 smp_wmb + smp_rmb 是经典写法,但现代内核代码推荐用 release/acquire 配对——语义更清晰,不需要理解”哪个屏障配哪个屏障”:

/* producer */
void produce(struct ring *r, struct packet_info *pkt)
{
    unsigned int tail = r->tail;
    unsigned int next = (tail + 1) % r->size;
    if (next == smp_load_acquire(&r->head))
        return;

    r->buf[tail] = *pkt;
    smp_store_release(&r->tail, next);  /* 数据写完后发布 tail */
}

/* consumer */
struct packet_info consume(struct ring *r)
{
    unsigned int head = r->head;
    if (head == smp_load_acquire(&r->tail))  /* 获取最新 tail */
        return EMPTY;

    struct packet_info pkt = r->buf[head];
    smp_store_release(&r->head, (head + 1) % r->size);
    return pkt;
}

smp_store_release 保证它之前的所有读写操作完成后才执行这个 store。smp_load_acquire 保证它之后的所有读写操作在这个 load 完成之前不会开始。配对使用,release 端”发布”数据,acquire 端”获取”数据——名字本身就说明了语义。

这与用户态的 C11 memory_order_release / memory_order_acquire 是同一个语义模型。在上一篇关于无锁编程中,我们看到的 LevelDB skip list 用的就是这对语义——只是用户态用 atomic_store_explicit(..., memory_order_release),内核态用 smp_store_release()。同一个思想,不同的 API。

LKMM:Linux Kernel Memory Model

如果你想形式化验证你的屏障是否正确,Linux 内核自带了一套形式化内存模型(LKMM),配合 herd7 工具可以写 litmus test 来穷举所有可能的执行顺序。

例如,验证”producer 的 smp_store_release 与 consumer 的 smp_load_acquire 配对后,consumer 一定读到最新数据”:

C ring-buffer-test
{
  buf = 0;
  tail = 0;
}

P0(int *buf, int *tail)  // producer
{
  WRITE_ONCE(*buf, 1);
  smp_store_release(tail, 1);
}

P1(int *buf, int *tail)  // consumer
{
  int r0 = smp_load_acquire(tail);
  int r1 = READ_ONCE(*buf);
}

exists (1:r0=1 /\ 1:r1=0)  // tail 可见但 buf 还是旧值?

运行 herd7 后回答 No——这个组合不可能出现。release/acquire 配对保证了正确性。

如果把 smp_store_release 换成普通的 WRITE_ONCEherd7 会回答 Yes——在弱内存模型下,consumer 可以看到新 tail 但旧 buf。这正是我们遇到的 bug。


第五章:什么时候需要屏障

从一个特定的 ring buffer bug 聊到这里,值得总结一些通用的经验法则。

模式一:Ring buffer / 生产者-消费者

这是最经典的屏障场景,也是我们刚经历的。模式是:

producer: 写数据 -> wmb/release -> 更新索引
consumer: 读索引 -> rmb/acquire -> 读数据

Linux 内核到处都是这个模式:io_uring 的 SQE/CQE ring、perf_event 的 ring buffer、网络驱动的收发队列。如果你在写任何形式的环形缓冲区,这三行屏障是标配。

模式二:Flag + Data

先写数据,再置一个 flag 告诉读者”数据准备好了”。读者先检查 flag,再读数据:

/* writer */
WRITE_ONCE(data, new_value);
smp_store_release(&ready, 1);

/* reader */
if (smp_load_acquire(&ready))
    use(READ_ONCE(data));

本质上和 ring buffer 的 tail 一样——flag 就是”发布信号”,需要 release/acquire 语义。

模式三:引用计数归零后释放

if (atomic_dec_and_test(&obj->refcount)) {
    /* refcount 刚变成 0 */
    smp_mb();  /* 确保其他 CPU 对 obj 的访问都已完成 */
    kfree(obj);
}

这里需要全屏障 smp_mb(),因为要保证其他核心对 obj 的所有方向的内存访问都在 kfree 之前完成。仅靠 wmb 或 rmb 不够——你需要 load 和 store 都排好队。

什么时候不需要屏障

别过度使用屏障,它们有性能代价(尤其是 smp_mb())。以下场景不需要额外的屏障:

  1. 已有锁保护spin_lock / spin_unlock 内部已经包含了 acquire/release 语义。锁保护的临界区内不需要额外屏障。

  2. 单核 / 单线程。重排只在多核之间产生可观察的效果。单核上程序顺序总是被保证的。

  3. 同一个变量的依赖读。如果你读了指针 p,然后通过 p 访问 *p,ARM 硬件保证数据依赖的顺序性(address dependency)。但注意:这个保证不能跨变量。

  4. 使用了 atomic_* 带 memory order 参数的 API。例如 atomic_read_acquire / atomic_set_release 已经内含了屏障语义。

用户态 C11 vs 内核态 smp_*

C11 用户态 Linux 内核态 语义
atomic_load_explicit(..., memory_order_acquire) smp_load_acquire() acquire 读
atomic_store_explicit(..., memory_order_release) smp_store_release() release 写
atomic_load_explicit(..., memory_order_relaxed) READ_ONCE() 松散读
atomic_store_explicit(..., memory_order_relaxed) WRITE_ONCE() 松散写
atomic_thread_fence(memory_order_seq_cst) smp_mb() 全屏障
atomic_thread_fence(memory_order_release) smp_wmb() 写屏障
atomic_thread_fence(memory_order_acquire) smp_rmb() 读屏障

注意:对应关系是近似的,不是精确等价。C11 memory model 和 LKMM 是两套不同的形式化模型,在某些边界情况下语义有差异。但在日常使用中,上表足够指导你的选择。

上一篇文章中我们看到的 LevelDB NoBarrier_Load,就是 C11 memory_order_relaxed 的自定义包装——对应内核态的 READ_ONCE()。LevelDB skip list 写入端的 release store 对应内核态的 smp_store_release()。同一个问题在用户态和内核态用不同的 API,但屏障选型的思路完全一样。


第六章:为什么 volatile 不是答案

回到第一章的伏笔。我在 ring buffer 的 head/tail 上加了 volatile,为什么完全没有效果?

C/C++ volatile 的真实含义

C/C++ 标准对 volatile 的定义是:每次访问都必须真正执行读写操作,编译器不能优化掉、合并、缓存到寄存器。就这些。

volatile 不保证: - 其他 CPU 核心能看到最新值(没有缓存一致性语义) - 多个 volatile 访问的顺序对其他核心可见(没有 store-store / load-load 屏障) - 原子性(对大于字长的类型,volatile 读写可以被撕裂)

换句话说,volatile 解决的是编译器层面的”不要把我的读写优化掉”,而我们的 bug 是 CPU 层面的”store 被重排了”。

这就像你锁好了前门(编译器),但后门(CPU)大开着。

Java volatile 完全不同

一个常见的误解来源:Java 的 volatile 具有 happens-before 语义,等价于 acquire/release 屏障。Java 程序员转写 C/C++ 时,经常以为 volatile 有同样的效果。没有。两个语言对这个关键字的定义完全不同。

Linux 内核的官方立场

内核文档 Documentation/process/volatile-considered-harmful.rst 开篇就说:

C 程序员通常认为 volatile 意味着变量可以在当前执行线程之外被改变;因此,它有时被用在内核代码中进行共享数据访问。换句话说,他们把 volatile 当作一种简单的原子操作,但它不是。在内核代码中使用 volatile 几乎从来都不是正确的做法。

正确的替代是: - 需要防止编译器优化读写?用 READ_ONCE() / WRITE_ONCE() - 需要跨核心的可见性顺序?用 smp_wmb() / smp_rmb()smp_store_release() / smp_load_acquire() - 需要原子操作?用 atomic_t 系列 API

volatile 在内核中唯一合理的用途是访问 MMIO(内存映射 I/O)寄存器——那里的”不要优化掉”语义确实是你想要的。对于共享内存同步,它不是工具。


结尾:两行代码,三天教训

两层重排:编译器与 CPU

回到那个 ring buffer。最终的修复 diff 只有三行:

  r->buf[tail] = *pkt;
+ smp_wmb();
  WRITE_ONCE(r->tail, next);
+ smp_rmb();
  struct packet_info pkt = r->buf[head];

ARM 上压测三天,零错误。

这三天教会我的核心教训:

x86 的 TSO 是最危险的 feature。 它免费提供了足够强的内存顺序保证,让你的代码在没有任何显式屏障的情况下”碰巧正确”。然后你迁移到 ARM,所有隐藏的 bug 同时爆发。这和上一篇文章中”relaxed load 在 x86 上行为接近 acquire”是同一个陷阱——x86 把你的代码惯坏了。

编译器和 CPU 是两层独立的重排。 volatilebarrier() 只管编译器。smp_wmb()smp_store_release() 同时管两层。理解这两层的区别是写正确并发代码的前提。

使用 release/acquire 配对,而不是随手撒屏障。 smp_store_release + smp_load_acquire 语义精确、易于推理、性能代价最小。如果你不确定该用什么屏障,先试 release/acquire。

如果上一篇讲的是”哪些代码不是真的无锁”,这一篇讲的是”即使是真的无锁,屏障放错位置一样崩”。两篇合起来,算是一份完整的并发编程避坑手册。

下一篇(从零实现无锁并发哈希表,计划中),我们会把这两篇的知识付诸实践——在真实的 lock-free 数据结构中选择正确的屏障,并在 x86 和 ARM 上分别验证。


参考资料

  1. Linux Kernel Documentation: memory-barriers.txt – 内核内存屏障的权威参考
  2. Linux Kernel Documentation: volatile-considered-harmful.rst – 为什么不要在内核中用 volatile
  3. LKMM (Linux Kernel Memory Model) – 内核的形式化内存模型
  4. io_uring SQE/CQE ring implementation – io_uring ring buffer 的屏障使用
  5. LevelDB skiplist.h – 用户态 NoBarrier_Load 的真实案例
  6. SQLite WAL 实现与 wal-index – mmap + 原子操作的另一个视角
  7. LSM-Tree: WAL + MemTable – skip list 的并发读写与内存顺序

By .