第 5 讲 · 内存模型:复现一个"x86 能跑、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

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

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

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

void *consumer(void *arg)
{
    (void)arg;

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

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

    return NULL;
}

int main(void)
{
    pthread_t t1, t2;

    pthread_create(&t1, NULL, producer, NULL);
    pthread_create(&t2, NULL, consumer, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);

    return 0;
}

编译运行:

bash 复制代码
gcc -O2 -pthread -o race_bug race_bug.c
for i in $(seq 1 100); do ./race_bug; done | sort | uniq -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

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

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

void *producer(void *arg)
{
    (void)arg;

    data = 42;

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

    return NULL;
}

void *consumer(void *arg)
{
    (void)arg;

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

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

    return NULL;
}

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

用反汇编验证它真的变了

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

bash 复制代码
# 看修复版的消费者线程生成什么
gcc -O2 -pthread -S -o race_fixed.s race_fixed.c
grep -A5 -E "ldar|stlr" 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 提供了三种写法:

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

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

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

那三个 : 后面跟的 "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)模式:

c 复制代码
/* 错误写法 ------ 在 ARM 上可能返回一个"半初始化"的对象指针 */
static Singleton *instance = NULL;

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

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

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

c 复制代码
static _Atomic(Singleton *) instance = NULL;

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

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

迁移时的检查清单

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

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

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

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

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

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

bash 复制代码
cd /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 上四种重排都允许,审查面更宽。

参考来源

相关推荐
rebornace1 小时前
用 Go 实现轻量级 AI Agent:白泽的架构设计与取舍
人工智能
AI酒旅实战开发1 小时前
30 Sundays:印度旅行科技黑马
人工智能
美林数据Tempodata1 小时前
智能体技术应用专业(510219)怎么建:先定岗位能力链,再定课程与实训
大数据·人工智能·智能体·产教融合·职业教育
橘和柠1 小时前
YOLO26实战全流程:从数据标注到部署上线
人工智能
倔强的石头1061 小时前
【深度学习】残差连接_解决深层网络梯度消失问题
人工智能·深度学习
嘟嘟嘟95271 小时前
Spotify Honk 的启示:AI 编程日常
人工智能·程序员·ai编程
西柚小萌新1 小时前
【论文阅读】--Trust Before Fusion:QIMG‑7 与面向污染多模态 RAG 的源感知信任
论文阅读·人工智能
Beyond9781 小时前
AI 代码的验证:一次"测试全绿、上线就崩"之后的改造
人工智能