一段"理论上没问题"的代码,在 ARM 上炸了


这是一个真实的故事。一段看起来逻辑严谨、通过原子变量同步的多线程代码,在 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);           // 通知 A

A 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!)

refevent_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()

关键变量

变量说明
ev0xffff800008e0A 从 TAILQ_FIRST 读到的指针
ev 指向的内存type=1, marker=1确实是 A 本轮 malloc 的节点
NODE.event_list.tqh_first0xffff800008b0被 A 的 TAILQ_REMOVE 改写了
double-free 地址0xffff800008e0ev 一样

还原时间线

时刻    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 abort

T7 是致命的一步: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 barrierstlr/ldar 只同步自身 cache line
Store buffer 排空积极(load 前排空)可长期滞留

x86 的 TSO 模型虽然也是逐 cache line 的,但有两个关键区别:

  1. x86 的 lock 前缀指令发出 full barrier,会排空 store buffer 并失效其他 cache line(虽然是副作用,不是语义保证)。ARM 的 stlr 只做 release,不刷其他 cache line。
  2. 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
    ret

dmb ish 是一个全局数据内存屏障。它会:

  1. 排空当前核的整个 store buffer——不只是 lock 的 cache line,是所有 pending 的写
  2. 确保 dmb 之后的写不会重排到 dmb 之前

pthread_spin_lockldaxr(acquire load)保证:

  1. lock 之后的读不会被重排到 lock 之前
  2. 如果拿到了锁,之前的写(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 buffer

A 持锁遍历时,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 语义保证能看到最新值,unlockdmb 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 配对,或者锁。

声明:一丁点儿的网络日志|版权所有,违者必究|如未注明,均为原创|本网站采用BY-NC-SA协议进行授权

转载:转载请注明原文链接 - 一段"理论上没问题"的代码,在 ARM 上炸了


勿在浮沙筑高台,每天进步一丁点儿!