原文发布于 quant67.com,转载请保留出处。
在上一篇文章里,我们聊了"大多数自称无锁的代码其实不是无锁的"。那篇讲的是用户态 C11 memory_order 的问题。这一篇换个视角------内核态。
故事很短:一个在 x86 上稳定运行了两年的内核模块,迁移到 ARM64 的第一周就开始丢数据。最终修复只有两行代码。但找到这两行代码,花了我三天。
症状
我们有一个自定义的网络驱动模块,核心是一个环形缓冲区(ring buffer):内核中断上下文作为 producer 往里写数据包元信息,用户态线程通过 mmap 作为 consumer 读取。标准的单生产者/单消费者(SPSC)模式。
x86 服务器上这个模块跑了两年多,零事故。
公司决定把这批工作负载迁移到 ARM64 服务器(成本考量)。迁移过程很顺利------编译通过,功能测试通过,小流量灰度通过。
然后流量上来了。
监控开始报偶发的数据校验错误。频率不高,大概每百万次操作出现一两次。但在高负载下,错误率稳定在万分之几。而且只在 ARM 服务器上出现。同样的内核模块、同样的负载,x86 上跑满压测------零错误。
ring buffer 的核心代码大概长这样:
c
/* 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] = *pkt 和 r->tail = next 重排了,或者把某些读写优化掉了。
于是我给关键变量加上了 volatile:
c
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 里出现了:
vbnet
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 值。
翻了几千条日志后,一条异常记录跳了出来:
ini
[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 通过内存屏障协作保证队列正确性),看到了这两行:
c
/* 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_release。smp_load_acquire。
回头看我们的 ring buffer------r->tail = next 是一个普通的 store。没有任何屏障。在 ARM 上,CPU 完全有权利在这个 store 之前,先让它对其他核心可见,哪怕数据还没写完。
这不是竞态条件,不是编译器 bug,不是 ARM 编译器的锅。这是一个缺失的内存屏障。
第三章:真相------编译器屏障 vs 硬件屏障
要理解内存屏障,必须先理解一件事:你的代码在被执行之前,要经过两层变形。
第一层:编译器重排
编译器为了优化性能,会在不改变单线程语义的前提下重排指令。例如:
c
a = 1;
b = 2;
编译器完全有权利生成先写 b 再写 a 的代码,因为在单线程下这两条语句没有依赖关系。volatile 可以阻止这种重排------但它只在这一层有效。
内核的 barrier() 宏也是纯编译器屏障:
c
#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 全景

从最轻量到最重量:
| 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 |
关键观察:
-
READ_ONCE/WRITE_ONCE不是屏障。它们只防止编译器对访问做"创造性"优化(合并多次读为一次、发明不存在的读写、撕裂大于字长的访问)。很多人把它们当屏障用------这是错的。 -
smp_wmb()在 x86 上是空操作(只有编译器屏障效果)。这就是为什么我们的 ring buffer 在 x86 上即使没有任何显式屏障也能正确工作------TSO 免费提供了 store-store 顺序。 -
smp_store_release/smp_load_acquire是推荐的现代 API 。它们比成对使用smp_wmb()+smp_rmb()更精确、更易推理,也更符合 C11 的 release/acquire 语义。io_uring 的 ring buffer 就用的这对 API。
回到我们的 bug
在我们的 ring buffer 中,producer 做了两件事:
- 写数据到
buf[tail] - 更新
tail
没有任何屏障。
在 x86 上:TSO 保证这两个 store 按顺序可见。没问题。
在 ARM 上:CPU 可能先让 tail 的更新对其他核心可见,再让 buf[tail] 的数据可见。consumer 看到新的 tail,去读 buf[head],读到的还是旧数据。
这就是那个"不可能的时间戳"的解释:tail 先到了,数据后到了。
第四章:修复
最小修复:smp_wmb / smp_rmb
c
/* 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 的"屏障骨架":
- producer 的
smp_wmb():保证buf[tail]的写入在tail更新之前对其他核心可见 - consumer 的
smp_rmb():保证看到新的tail之后,才去读buf[head]的内容 - consumer 的
smp_wmb():保证buf[head]读完之后,才让head更新可见(否则 producer 可能覆盖 consumer 还没读完的数据)

更好的写法:smp_store_release / smp_load_acquire
上面的 smp_wmb + smp_rmb 是经典写法,但现代内核代码推荐用 release/acquire 配对------语义更清晰,不需要理解"哪个屏障配哪个屏障":
c
/* 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 一定读到最新数据":
ini
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_ONCE,herd7 会回答 Yes------在弱内存模型下,consumer 可以看到新 tail 但旧 buf。这正是我们遇到的 bug。
第五章:什么时候需要屏障
从一个特定的 ring buffer bug 聊到这里,值得总结一些通用的经验法则。
模式一:Ring buffer / 生产者-消费者
这是最经典的屏障场景,也是我们刚经历的。模式是:
rust
producer: 写数据 -> wmb/release -> 更新索引
consumer: 读索引 -> rmb/acquire -> 读数据
Linux 内核到处都是这个模式:io_uring 的 SQE/CQE ring、perf_event 的 ring buffer、网络驱动的收发队列。如果你在写任何形式的环形缓冲区,这三行屏障是标配。
模式二:Flag + Data
先写数据,再置一个 flag 告诉读者"数据准备好了"。读者先检查 flag,再读数据:
c
/* 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 语义。
模式三:引用计数归零后释放
c
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())。以下场景不需要额外的屏障:
-
已有锁保护 。
spin_lock/spin_unlock内部已经包含了 acquire/release 语义。锁保护的临界区内不需要额外屏障。 -
单核 / 单线程。重排只在多核之间产生可观察的效果。单核上程序顺序总是被保证的。
-
同一个变量的依赖读 。如果你读了指针 p,然后通过 p 访问
*p,ARM 硬件保证数据依赖的顺序性(address dependency)。但注意:这个保证不能跨变量。 -
使用了
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)寄存器------那里的"不要优化掉"语义确实是你想要的。对于共享内存同步,它不是工具。
结尾:两行代码,三天教训

回到那个 ring buffer。最终的修复 diff 只有三行:
diff
r->buf[tail] = *pkt;
+ smp_wmb();
WRITE_ONCE(r->tail, next);
diff
+ smp_rmb();
struct packet_info pkt = r->buf[head];
ARM 上压测三天,零错误。
这三天教会我的核心教训:
x86 的 TSO 是最危险的 feature。 它免费提供了足够强的内存顺序保证,让你的代码在没有任何显式屏障的情况下"碰巧正确"。然后你迁移到 ARM,所有隐藏的 bug 同时爆发。这和上一篇文章中"relaxed load 在 x86 上行为接近 acquire"是同一个陷阱------x86 把你的代码惯坏了。
编译器和 CPU 是两层独立的重排。 volatile 和 barrier() 只管编译器。smp_wmb() 和 smp_store_release() 同时管两层。理解这两层的区别是写正确并发代码的前提。
使用 release/acquire 配对,而不是随手撒屏障。 smp_store_release + smp_load_acquire 语义精确、易于推理、性能代价最小。如果你不确定该用什么屏障,先试 release/acquire。
如果上一篇讲的是"哪些代码不是真的无锁",这一篇讲的是"即使是真的无锁,屏障放错位置一样崩"。两篇合起来,算是一份完整的并发编程避坑手册。
下一篇(从零实现无锁并发哈希表,计划中),我们会把这两篇的知识付诸实践------在真实的 lock-free 数据结构中选择正确的屏障,并在 x86 和 ARM 上分别验证。
如果你想看"靠经验保证正确性"这个主题在另一个场景的体现,可以读用 Rust 重写一个 C 网络服务器,编译器拦了我五次------C 版本能跑的代码,Rust 编译器拦了五次,每次都对应一个真实风险。
参考资料:
- Linux Kernel Documentation: memory-barriers.txt -- 内核内存屏障的权威参考
- Linux Kernel Documentation: volatile-considered-harmful.rst -- 为什么不要在内核中用 volatile
- LKMM (Linux Kernel Memory Model) -- 内核的形式化内存模型
- io_uring SQE/CQE ring implementation -- io_uring ring buffer 的屏障使用
- LevelDB skiplist.h -- 用户态 NoBarrier_Load 的真实案例
- SQLite WAL 实现与 wal-index -- mmap + 原子操作的另一个视角
- LSM-Tree: WAL + MemTable -- skip list 的并发读写与内存顺序
- unsafe Rust:当编译器不再替你扛枪 -- 内存屏障 + 裸指针 = 最容易出错的组合