第 5 讲 · 内存模型:复现一个"x86 能跑、ARM 挂掉"的 bug 并修好它

文章来源声明: 原文作者:lucas_AI; 来源站点:掘金; 原文链接:https://juejin.cn/post/7686067030373564462; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

适合做 ARM 迁移或并发调优的工程师精读。它把抽象的弱内存模型落到可复现的 bug 与反汇编验证上,并给出内存序选型阶梯,能直接用于代码审查与性能取舍。

> 理解 ARM 弱内存模型与 x86 TSO 强内存模型的差异;复现一个跨架构失效的并发 bug,从 volatile 的错误尝试走到 `__atomic` acquire/release 的正确修法,并用反汇编验证。

这是迁移中最危险的一类问题

第 4 讲的问题编译器直接报错,你立刻知道哪里要改。这一类问题完全不同:代码能编译、能启动、能通过功能测试,然后在生产环境偶发崩溃——可能一万次才出一次、压力大才出现、只在大核数机器上出现。

根源是:x86 和 ARM 的内存模型不一样。

  • x86 是 TSO(Total Store Order)强内存模型。除了极少数 store buffer 重排,它天然提供 acquire/release 级别的顺序保证。你写 data = 42; ready = 1;,其他核心看到的顺序基本就是这个顺序。
  • ARM 是弱内存模型。load 可以越过 store 重排,store 之间也没有固定顺序。上面那两行代码,其他核心可能先看到 ready == 1,再看到 data 还是 0。

这意味着:很多在 x86 上"碰巧正确"的代码,在 ARM 上会暴露真实的 bug。

复现:一个经典的无锁标志位

新建 race_bug.c

<span>/*
 * race_bug.c —— 一个在 x86 上"能跑"、在 ARM 上可能失效的并发程序
 *
 * 编译:gcc -O2 -pthread -o race_bug race_bug.c
 * 运行:./race_bug
 *
 * 在 ARM 上大概率会看到 "data = 0"(错误结果),或消费者线程永久自旋。
 */</span>
<span>#<span>include</span> <span><stdio.h></span></span>
<span>#<span>include</span> <span><pthread.h></span></span>

<span>volatile</span> <span>int</span> data  = <span>0</span>;
<span>volatile</span> <span>int</span> ready = <span>0</span>;   <span>/* ← 用普通变量当跨线程标志位 —— 错误做法 */</span>

<span>void</span> *<span>producer</span><span>(<span>void</span> *arg)</span>
{
    (<span>void</span>)arg;
    data  = <span>42</span>;     <span>/* ① 先写数据 */</span>
    ready = <span>1</span>;      <span>/* ② 再写标志 —— 但在 ARM 上,①②的可见顺序没有保证! */</span>
    <span>return</span> <span>NULL</span>;
}

<span>void</span> *<span>consumer</span><span>(<span>void</span> *arg)</span>
{
    (<span>void</span>)arg;

    <span>/* ③ 自旋等待标志位 */</span>
    <span>while</span> (!ready) { <span>/* 空转 */</span> }

    <span>/* ④ 读到 ready==1 之后才读 data。
     *    在 x86 上这里几乎总能读到 42;
     *    在 ARM 上可能读到 0 —— 因为 data 的写还没对其他核心可见。 */</span>
    <span>printf</span>(<span>"data = %d  (期望 42)\n"</span>, data);

    <span>return</span> <span>NULL</span>;
}

<span>int</span> <span>main</span><span>(<span>void</span>)</span>
{
    <span>pthread_t</span> t1, t2;

    pthread_create(&t1, <span>NULL</span>, producer, <span>NULL</span>);
    pthread_create(&t2, <span>NULL</span>, consumer, <span>NULL</span>);
    pthread_join(t1, <span>NULL</span>);
    pthread_join(t2, <span>NULL</span>);

    <span>return</span> <span>0</span>;
}

编译运行:

gcc -O2 -pthread -o race_bug race_bug.c
<span>for</span> i <span>in</span> $(<span>seq</span> 1 100); <span>do</span> ./race_bug; <span>done</span> | <span>sort</span> | <span>uniq</span> -c

在 x86 上:100 次全是 data = 42,看起来完全正确。

在 ARM 上:可能出现 data = 0。两个原因同时作用——

原因一(生产者侧):ARM 弱内存模型下,data = 42ready = 1 两个 store 的可见顺序没有保证。消费者可能先观察到 ready == 1,而 data 的写还在 store buffer 里。

原因二(消费者侧):编译器可能把 while (!ready) 优化成死循环,或者把 data 的读取提前到循环之前。volatile 只能挡住一部分编译器优化,它不产生任何硬件屏障。

先纠正一个常见误解

很多人第一反应是"加个 volatile 就好了"——上面的代码里 readydata 都已经是 volatile 了,照样有问题。它在并发场景下的语义要分清:

它做了什么它没做什么
禁止编译器把该变量的访存优化掉**不保证原子性**
保证每次访问都真的去内存读/写**不产生任何内存屏障**
禁止跨该访问的指令重排(仅编译器层面)**不约束 CPU 的乱序执行**

volatile ≠ 原子 ≠ 屏障。正确的工具是 C11 原子操作(或 GCC 的 __atomic 内建)。

修复:用 __atomic 与 acquire/release 语义

新建 race_fixed.c

<span>/*
 * race_fixed.c —— 用 C11 原子操作修复,跨架构正确
 *
 * 编译:gcc -O2 -pthread -o race_fixed race_fixed.c
 * 运行:./race_fixed
 */</span>
<span>#<span>include</span> <span><stdio.h></span></span>
<span>#<span>include</span> <span><pthread.h></span></span>
<span>#<span>include</span> <span><stdatomic.h></span></span>

<span>static</span> <span>int</span> data = <span>0</span>;                    <span>/* 数据本身不需要是原子的 */</span>
<span>static</span> <span>_Atomic</span> <span>int</span> ready = <span>0</span>;           <span>/* ← 标志位必须是原子类型 */</span>

<span>void</span> *<span>producer</span><span>(<span>void</span> *arg)</span>
{
    (<span>void</span>)arg;

    data = <span>42</span>;

    <span>/* atomic_store_explicit 配合 memory_order_release:
     * 【释放语义】保证本线程在此之前的所有内存写(含 data = 42),
     * 都在这次 store 之前对其他线程可见。
     * 在 AArch64 上编译为 STLR(Store-Release)指令。 */</span>
    atomic_store_explicit(&ready, <span>1</span>, memory_order_release);

    <span>return</span> <span>NULL</span>;
}

<span>void</span> *<span>consumer</span><span>(<span>void</span> *arg)</span>
{
    (<span>void</span>)arg;

    <span>/* atomic_load_explicit 配合 memory_order_acquire:
     * 【获取语义】保证读到 ready==1 之后,能看到生产者在本线程
     * release 之前做的所有写(即 data == 42)。
     * 在 AArch64 上编译为 LDAR(Load-Acquire)指令。 */</span>
    <span>while</span> (atomic_load_explicit(&ready, memory_order_acquire) == <span>0</span>) {
        <span>/* 空转 */</span>
    }

    <span>printf</span>(<span>"data = %d  (期望 42)\n"</span>, data);   <span>/* 现在一定读到 42 */</span>

    <span>return</span> <span>NULL</span>;
}

<span>int</span> <span>main</span><span>(<span>void</span>)</span>
{
    <span>pthread_t</span> t1, t2;
    pthread_create(&t1, <span>NULL</span>, producer, <span>NULL</span>);
    pthread_create(&t2, <span>NULL</span>, consumer, <span>NULL</span>);
    pthread_join(t1, <span>NULL</span>);
    pthread_join(t2, <span>NULL</span>);
    <span>return</span> <span>0</span>;
}

用反汇编验证它真的变了

同一份逻辑,改与不改生成的指令完全不同

<span># 看修复版的消费者线程生成什么</span>
gcc -O2 -pthread -S -o race_fixed.s race_fixed.c
grep -A5 -E <span>"ldar|stlr"</span> race_fixed.s

你应该能看到 ldar(Load-Acquire)和 stlr(Store-Release)指令。在 x86 上编译同样的代码,你会看到的是普通的 mov——因为 x86 的 TSO 模型天然有序,不需要特殊指令。

AArch64 上的完整映射关系

C11 原子操作和屏障在 AArch64 上对应哪些指令?这张表来自 Cambridge 的 C/C++11 mappings 文档:

C11 操作AArch64 指令
Load Acquire`LDAR`
Load SeqCst`LDAR`
Load Relaxed`LDR`
Store Release`STLR`
Store SeqCst`STLR`
Store Relaxed`STR`
Acquire fence`DMB ISH LD`
Release fence`DMB ISH`
SeqCst fence`DMB ISH`

关键洞察:AArch64 用 LDAR/STLR 这对指令原生实现 acquire/release 语义,比"普通访存 + 全屏障"高效得多——这正是 C11 原子操作在 AArch64 上代码生成质量好的原因。所以实践建议很明确:交互场景优先用带 acquire/release 的原子操作,而不是加全屏障。

三种屏障写法的选择

如果确实需要显式屏障,GCC 提供了三种写法:

<span>/* 写法 1:__sync_synchronize() —— 全屏障
 * ⚠️ GCC 官方明确建议:新代码不要用它,请用 __atomic 系列。
 * 它会生成 DMB ISH,代价较高。 */</span>
__sync_synchronize();

<span>/* 写法 2:__atomic_thread_fence —— C11 序贯一致屏障(推荐)
 * 在 AArch64 上生成 DMB ISH。语义清晰,是标准做法。 */</span>
__atomic_thread_fence(__ATOMIC_SEQ_CST);

<span>/* 写法 3:手写内联汇编
 * ish = inner shareable(多核共享域),SMP 场景用这个;
 * 与外设/DMA 打交道要用 osh(outer shareable)——选错域是常见错误。
 * ⚠️ "memory" clobber 绝对不能省!否则编译器会把访存搬到屏障前面。 */</span>
__asm__ __volatile__(<span>"dmb ish"</span> ::: <span>"memory"</span>);

那三个 : 后面跟的 "memory"编译器屏障。没有它,编译器可以自由地把内存读写指令搬过这一行——你的硬件屏障就白加了。这是手写屏障最经典的错误。

内存序的选择阶梯

__atomic 提供了六个内存序,从弱到强:

内存序含义何时用
`__ATOMIC_RELAXED`无跨线程顺序约束只需要原子性,不需要顺序(如计数器)
`__ATOMIC_CONSUME`当前实现为 ACQUIRE实践中不用,直接用 ACQUIRE
`__ATOMIC_ACQUIRE`后续访存不能上移读侧同步
`__ATOMIC_RELEASE`之前访存不能下移写侧同步
`__ATOMIC_ACQ_REL`两者兼具读改写操作(如 CAS)
`__ATOMIC_SEQ_CST`全局总顺序不确定时用这个(最安全,也最慢)

实用建议:不确定就用 __ATOMIC_SEQ_CST,确认性能瓶颈真的在这里再降级到 acquire/release 或 relaxed——过早优化内存序是 bug 的高发区。

另一个受害者:双检锁

同样的根因还会毁掉经典的双检锁(DCLP)模式:

<span>/* 错误写法 —— 在 ARM 上可能返回一个"半初始化"的对象指针 */</span>
<span>static</span> Singleton *instance = <span>NULL</span>;

Singleton *<span>get_instance</span><span>(<span>void</span>)</span>
{
    <span>if</span> (instance == <span>NULL</span>) {              <span>/* 第一次检查 */</span>
        lock();
        <span>if</span> (instance == <span>NULL</span>) {          <span>/* 第二次检查 */</span>
            instance = new Singleton();  <span>/* ⚠️ 指针赋值可能先于构造函数内部的写
                                          *    被其他线程观察到! */</span>
        }
        unlock();
    }
    <span>return</span> instance;
}

问题在于:instance = new Singleton() 实际上包含"分配内存 → 构造对象 → 赋值指针"三步,而 ARM 可能让指针赋值先被其他线程看到,于是别的线程拿到一个指向未初始化内存的非空指针。

修法与上面一致——让指针本身成为原子,并使用 acquire/release:

<span>static</span> <span>_Atomic</span>(Singleton *) instance = <span>NULL</span>;

Singleton *<span>get_instance</span><span>(<span>void</span>)</span>
{
    <span>/* 用 acquire 读:看到非 NULL 就一定看到完整的构造结果 */</span>
    Singleton *p = atomic_load_explicit(&instance, memory_order_acquire);
    <span>if</span> (p == <span>NULL</span>) {
        lock();
        p = atomic_load_explicit(&instance, memory_order_acquire);
        <span>if</span> (p == <span>NULL</span>) {
            p = <span>malloc</span>(<span>sizeof</span>(Singleton));
            init_singleton(p);   <span>/* 先完成初始化 */</span>
            <span>/* 用 release 写:保证初始化对后续读者可见 */</span>
            atomic_store_explicit(&instance, p, memory_order_release);
        }
        unlock();
    }
    <span>return</span> p;
}

(注:C++11 起,函数内 static 局部变量的初始化本身就是线程安全的,可以直接用 static Singleton inst; return &inst;。上面的代码适用于 C 或需要延迟初始化的场景。)

迁移时的检查清单

代码里出现以下模式,都要在 ARM 上重点审查:

  • 用普通变量(哪怕加了 volatile)做跨线程就绪标志
  • 手写的无锁数据结构(队列、栈、环形缓冲)
  • 双检锁式的延迟初始化
  • 自定义的引用计数(非 std::shared_ptr / 非原子)
  • 依赖"先写数据、后写标志"顺序的任何模式

鲲鹏 DevKit 的亲和分析提供了两个专门的内存一致性检查子命令(完整参数见第 6 讲):

<span># 静态检查:扫描源码,给出插入内存屏障的建议</span>
devkit advisor mm-check -i /path/to/src -f /path/to/bc_files -o /path/to/out

<span># 动态检查:运行时分析,同样给出屏障建议</span>
devkit advisor dr-check -f /path/to/elf -em <span>true</span>

mm-check 需要先用 bc-gen 生成 BC 文件:

<span>cd</span> /home/test && cmake .
devkit advisor bc-gen -c make -o /home/test/bc_files

动手练习

  1. 在 ARM 机器上跑 1000 次 race_bug,统计 data = 0 出现的比例——比例不高恰恰是这类 bug 危险的原因。
  2. race_fixed.c 里的 acquire/release 改成 memory_order_relaxed 重跑,看你的平台能否复现错误。
  3. objdump -d 对比 race_bugrace_fixed 的汇编,找出 ldar/stlr 出现的位置。

进阶与拓展

  • 内存序的性能代价分层:relaxed 生成普通 LDR/STR,acquire/release 对应 LDAR/STLR;seq_cst 在 AArch64 上同为 LDAR/STLR(无需额外屏障),但在 x86 上 seq_cst store 要付 xchg(或 mov+mfence)的代价——所以"x86 上降级内存序收益明显,ARM64 上收益有限"。
  • 原子 RMW 的两代实现:ARMv8.0 上 CAS/交换是 ldxr/stxr LL/SC 循环,高竞争下会失败重试;ARMv8.1 的 LSE 扩展提供单指令 cas/swp/ldadd,确认 CPU 支持后可用 -march=armv8.1-a+lse 让编译器生成(鲲鹏 920 的 TaiShan v110 核为 ARMv8.2,含 LSE)。
  • RCU 与 seqlock 的选型:读多写少、读侧不能阻塞选 RCU,读侧只需 acquire 或地址依赖序、写侧由 synchronize_rcu() 承担回收时机(用户态可用 liburcu);数据小、要求读侧拿到一致快照选 seqlock,读侧循环重试 + acquire、写侧 release 加序号翻转。两者共同的坑:读到的共享数据要用原子读防撕裂。
  • TSO 对比的边界:x86 只允许 StoreLoad 重排(store buffer 所致),所以"x86 上验证通过"覆盖不了 store 间乱序、load-store 乱序类的 bug;ARM64 上四种重排都允许,审查面更宽。

参考来源