这是一个真实的故事。一段看起来逻辑严谨、通过原子变量同步的多线程代码,在 x86 上跑得好好的,移植到 ARM 后直接 core dump。罪魁祸首不是逻辑 bug,而是 ARM 的弱内存模型。
一、故事的开端:一段"看起来没问题"的代码
先看程序流程。有两个线程,A 和 B,分别绑定到不同的 CPU 核上。它们共享一个链表 NODE.event_list,通过一个原子变量 ref 做同步:
Thread A (core 0) Thread B (core 1)
───────────────── ──────────────────
1. malloc(e) 等待 ref == 1
2. lock → INSERT_TAIL(e) → unlock
3. ref = 1 (RELEASE) 4. lock → REMOVE(e) → unlock
5. spin 直到 ref == 0 6. free(e)
7. ref = 0 (RELEASE)
8. 遍历链表,逐个 REMOVE + free看起来天衣无缝:
- A 插入节点后设
ref = 1,通知 B - B 取出节点、释放后设
ref = 0,通知 A - A spin 到
ref == 0后,知道 B 已经处理完了,开始遍历清理链表
说明:本程序是基于真实业务场景简化构造的复现用例。真实业务中,线程 A 在收到"对方已处理完"的信号后会执行一系列节点清理动作(遍历、移除、释放),这本身是有意义的。但在这个极简的 demo 中,B 已经把唯一节点 remove 并 free 了,A 的"遍历 + REMOVE + free"在逻辑上是多余的——之所以保留这段代码,是为了精确复现真实业务中 A 在无锁状态下访问链表头的竞态路径,而不是为了演示一个有意义的业务流程。
逻辑上完全没有问题。在 x86 上也确实跑得好好的。
但在 ARM 上——
#3 _int_free () from /lib64/libc.so.6
#4 thread_a at test_v3.c:122
122 free(ev);
Program terminated with signal 6, Aborted.Double-free。 A 要 free 的那个节点,B 已经 free 过了。
二、为什么"理论上没问题"?
让我们站在程序员的角度,逐一检查这段代码的同步逻辑。
同步设计:ref 原子变量
A 和 B 用 ref 做握手:
// Thread A
__atomic_store_n(&ref, 1, __ATOMIC_RELEASE); // 通知 B
while (__atomic_load_n(&ref, __ATOMIC_RELAXED) != 0) // 等 B 处理完
...
// Thread B
free(e);
__atomic_store_n(&ref, 0, __ATOMIC_RELEASE); // 通知 AA spin 到 ref == 0 时,确实能确定 B 已经执行了 ref = 0 之前的操作(RELEASE 语义保证了 happens-before)。也就是说,A 知道 B 已经把节点从链表 remove 并 free 了。
链表保护:spinlock
B 的 TAILQ_REMOVE 持有 NODE.lock,A 的 TAILQ_INSERT_TAIL 也持锁。链表结构的修改是互斥的。
看起来唯一的"漏洞"
A 在 spin 完成后遍历链表时没有持锁:
// A 退出 spin 后
for (ev = TAILQ_FIRST(&NODE.event_list); ev; ev = tmp) {
TAILQ_REMOVE(&NODE.event_list, ev, next);
free(ev);
}但程序员会想:此时 B 已经处理完了,链表里要么是空的,要么是 A 自己刚插入的节点,不会有并发竞争。 无锁遍历应该是安全的。
这个推理在 x86 上成立。在 ARM 上不成立。
三、ARM 的世界:缓存一致性是"逐行"的
Cache Line:CPU 缓存的最小单位
CPU 的 L1 cache 不是按字节缓存的,而是按 cache line(通常 64 字节)缓存的。当你读一个字节,整个 64 字节的 line 都会被加载到 L1。
关键:缓存一致性协议(MESI/MOESI)也是以 cache line 为单位失效和更新的。
三个变量,三条 cache line
代码中故意把关键变量放在不同的 cache line 上:
static alignas(64) struct node NODE; // event_list 在 NODE 内部,10MB pad 之后
static alignas(64) volatile int ref; // ref 独占一条 cache line
static alignas(64) volatile int stop;从 core dump 确认的地址:
NODE.event_list.tqh_first @ 0x18201c8 ← cache line A
ref @ 0x420140 ← cache line B (和 event_list 相距 ~20MB!)ref 和 event_list 在完全不同的 cache line 上。
原子操作只同步自己的 cache line
A 在 spin ref 时,用的是 __ATOMIC_RELAXED:
while (__atomic_load_n(&ref, __ATOMIC_RELAXED) != 0)B 写 ref = 0 用的是 __ATOMIC_RELEASE:
__atomic_store_n(&ref, 0, __ATOMIC_RELEASE);这两个操作只会让 ref 所在的 cache line 在两个核之间同步。也就是说:
- A 能看到
ref的最新值 0 ✅ - A 不一定能看到 B 对
event_list的修改 ❌
因为 event_list 在另一条 cache line 上,ref 的原子操作碰不到它。
ARM 的"任性":L1 cache 可以长期持有旧值
在 x86 上,由于 TSO(Total Store Order)模型和更积极的 store buffer 排空策略,一个核的写通常会较快地对其他核可见。即使没有显式屏障,x86 也会保证 load-load 有序、store-store 有序。
ARM 不会。 ARM 是弱内存模型:
- Load 可以重排
- Store 可以重排
__ATOMIC_RELAXED没有任何屏障保证
这意味着:B 在 core 1 上修改了 event_list 的 cache line,但这修改到达 core 0 的 L1 cache 的时间不确定。core 0 的 L1 可以长期持有旧值——指向那个已经被 B free 掉的节点。
四、现场还原:core dump 里的证据
崩溃现场
#3 _int_free () ← glibc 的 free 实现
#4 thread_a at test_v3.c:122 ← free(ev) 这一行
122 free(ev);A 调用 free(ev),glibc 发现这块内存已经被 free 过了,abort()。
关键变量
| 变量 | 值 | 说明 |
|---|---|---|
ev | 0xffff800008e0 | A 从 TAILQ_FIRST 读到的指针 |
ev 指向的内存 | type=1, marker=1 | 确实是 A 本轮 malloc 的节点 |
NODE.event_list.tqh_first | 0xffff800008b0 | 被 A 的 TAILQ_REMOVE 改写了 |
| double-free 地址 | 0xffff800008e0 | 和 ev 一样 |
还原时间线
时刻 Thread B (core 1) Thread A (core 0)
──── ───────────────── ────────────────────────
T1 lock(&NODE.lock)
T2 TAILQ_REMOVE(event_list, e)
→ event_list.tqh_first = NULL
→ 修改了 cache line A
T3 unlock(&NODE.lock) [dmb ish]
T4 free(e)
→ e 的内存进入 freelist
→ e->tqe_next 被 freelist 覆写
T5 ref = 0 (RELEASE)
→ 刷新 cache line B (ref)
T6 ldr ref → 看到 0, 退出 spin
T7 ldr event_list.tqh_first
↑ 读到 L1 cache 旧值!
★ ev = 0xffff800008e0 (已被 free!) ★
T8 TAILQ_NEXT(ev, next)
↑ UAF! 读到 freelist 垃圾值
T9 TAILQ_REMOVE(event_list, ev)
↑ 破坏链表! tqh_first = 垃圾值
T10 free(ev)
↑ DOUBLE-FREE! glibc abortT7 是致命的一步:A 读 event_list.tqh_first 时,读到的是 L1 cache 里的旧值。 这个旧值指向 B 已经在 T4 释放的节点 e。从此以后,A 拿着一个悬垂指针,一路狂奔到 double-free。
一个细节:为什么 tqh_first 不是 NULL?
你可能注意到:B 在 T2 把 tqh_first 写成了 NULL,为什么 A 在 T7 还能读到非 NULL 的旧值?
因为 cache line 的更新是异步的。B 对 tqh_first 的写(NULL)进入了 core 1 的 store buffer,然后刷到共享缓存。但 core 0 的 L1 cache 里,tqh_first 所在的 cache line 还是旧值(指向 e)。只有当 core 0 的这条 cache line 被失效(invalidate)后,下次读才会从共享缓存重新加载。
而 ref 的原子操作只失效了 ref 的 cache line,没有碰 tqh_first 的 cache line。所以 core 0 的 L1 里 tqh_first 一直保持旧值——直到 A 主动读它,读到的是过期的指针。
另一个细节:ev->tqe_next 为什么不是 NULL?
A 读到悬垂指针 ev = 0xffff800008e0 后,又通过 TAILQ_NEXT(ev, next) 读了 ev->tqe_next。这块内存已经被 B free() 了,tqe_next 字段被 malloc freelist 覆写成了一个非 NULL 的值(指向另一个空闲 chunk)。
这就是为什么 tqh_first 被 A 的 TAILQ_REMOVE 改写成了 0xffff800008b0——不是 NULL,而是 freelist 的垃圾值。
五、为什么 x86 不出问题?
| 特性 | x86 (TSO) | ARM (Weak) |
|---|---|---|
| Load 重排 | 不重排 | 可重排 |
| Store 重排 | 不重排(同地址) | 可重排 |
__ATOMIC_RELAXED | 仍有 TSO 保证 | 无任何保证 |
| 原子操作副作用 | lock 前缀发 full barrier | stlr/ldar 只同步自身 cache line |
| Store buffer 排空 | 积极(load 前排空) | 可长期滞留 |
x86 的 TSO 模型虽然也是逐 cache line 的,但有两个关键区别:
- x86 的
lock前缀指令发出 full barrier,会排空 store buffer 并失效其他 cache line(虽然是副作用,不是语义保证)。ARM 的stlr只做 release,不刷其他 cache line。 - x86 不允许 load-load 重排。A 读
ref后再读tqh_first,顺序保证。ARM 允许重排,即使ref读到了新值,tqh_first的读可能被重排到ref之前,读到更旧的值。
所以在 x86 上,A spin 到 ref == 0 后,通常能看到 B 对 tqh_first 的修改(要么因为 store buffer 排空、要么因为 cache line 已经同步)。但在 ARM 上,没有任何保证。
六、spinlock 的"隐藏功能"
代码里其实已经有 spinlock 保护链表了。B 修改链表时持锁。如果 A 遍历时也持锁,问题就消失了。为什么?
看 glibc 在 aarch64 上的 pthread_spin_unlock 实现:
pthread_spin_unlock:
dmb ish ; ★ Inner Shareable 全屏障!
str wzr, [x0] ; lock = 0
retdmb ish 是一个全局数据内存屏障。它会:
- 排空当前核的整个 store buffer——不只是 lock 的 cache line,是所有 pending 的写
- 确保
dmb之后的写不会重排到dmb之前
而 pthread_spin_lock 的 ldaxr(acquire load)保证:
lock之后的读不会被重排到lock之前- 如果拿到了锁,之前的写(B 的
unlock排空的那些)对 A 可见
所以 spinlock 不只是"互斥",它还隐含了内存屏障的作用。 A 如果在遍历时持锁:
pthread_spin_lock(&NODE.lock); // acquire: 之后的读不会被重排到前面
// 且能看到 B unlock 前的所有写
ev = TAILQ_FIRST(&NODE.event_list); // ← 能读到最新值!
...
pthread_spin_unlock(&NODE.lock); // dmb ish: 排空 store bufferA 持锁遍历时,B 不可能同时修改链表(互斥),且 A 能看到链表的最新状态(acquire 语义)。问题彻底消失。
七、怎么修?
方案一:遍历时持锁(最简单、最正确)
pthread_spin_lock(&NODE.lock);
for (ev = TAILQ_FIRST(...); ev; ev = tmp) {
TAILQ_REMOVE(&NODE.event_list, ev, next);
free(ev);
}
pthread_spin_unlock(&NODE.lock);lock 的 acquire 语义保证能看到最新值,unlock 的 dmb ish 排空 store buffer。问题彻底消失。
方案二:显式内存屏障
__atomic_thread_fence(__ATOMIC_ACQUIRE); // 在读 event_list 之前
ev = TAILQ_FIRST(&NODE.event_list);acquire fence 会阻止后面的读被重排到 fence 之前,并让本核的 cache line 与共享缓存同步。
方案三:让 ref 和 event_list 在同一 cache line
struct {
volatile int ref;
TAILQ_HEAD(, event) event_list;
} __attribute__((aligned(64))) shared;这样 ref 的 release store 会顺带让同 cache line 的 tqh_first 对其他核可见。但会引入 false sharing 性能问题。
八、总结:三个认知陷阱
陷阱一:"原子变量同步了,就能看到所有最新值"
错。 原子操作只同步自身 cache line。不同 cache line 上的变量,即使一个用 RELEASE 写、一个用 RELAXED 读,也没有跨 cache line 的可见性保证。
陷阱二:"x86 上没问题,ARM 上也应该没问题"
错。 x86 的 TSO 模型掩盖了大量并发 bug。ARM 的弱内存模型不做任何隐式保证,必须在代码层面显式使用屏障或锁。
陷阱三:"无锁遍历只要逻辑上没有并发就安全"
错。 "逻辑上没有并发"是程序员的推理,不是硬件的保证。ARM 的 cache 可以长期持有旧值,即使另一个核早就改了。只有锁或屏障才能强制 cache 同步。
一句话
在 ARM 上,"我看到了ref == 0"只意味着我看到了ref == 0,不意味着我看到了ref == 0之前的任何其他内存写。要看到后者,你需要 acquire-release 配对,或者锁。


Comments | NOTHING