在上一篇文章里,我们聊了”大多数自称无锁的代码其实不是无锁的”。那篇讲的是用户态
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] = *pkt
和 r->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_release。smp_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 全景
从最轻量到最重量:
| 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 做了两件事: 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
的”屏障骨架”:
- 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
配对——语义更清晰,不需要理解”哪个屏障配哪个屏障”:
/* 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_ONCE,herd7 会回答
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())。以下场景不需要额外的屏障:
已有锁保护。
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 只有三行:
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 是两层独立的重排。
volatile 和 barrier()
只管编译器。smp_wmb() 和
smp_store_release()
同时管两层。理解这两层的区别是写正确并发代码的前提。
使用 release/acquire
配对,而不是随手撒屏障。
smp_store_release +
smp_load_acquire
语义精确、易于推理、性能代价最小。如果你不确定该用什么屏障,先试
release/acquire。
如果上一篇讲的是”哪些代码不是真的无锁”,这一篇讲的是”即使是真的无锁,屏障放错位置一样崩”。两篇合起来,算是一份完整的并发编程避坑手册。
下一篇(从零实现无锁并发哈希表,计划中),我们会把这两篇的知识付诸实践——在真实的 lock-free 数据结构中选择正确的屏障,并在 x86 和 ARM 上分别验证。
参考资料:
- 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 的并发读写与内存顺序