理解 ARM 弱内存模型与 x86 TSO 强内存模型的差异;复现一个跨架构失效的并发 bug,从 volatile 的错误尝试走到
__atomicacquire/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 = 42 和 ready = 1 两个 store 的可见顺序没有保证。消费者可能先观察到 ready == 1,而 data 的写还在 store buffer 里。
原因二(消费者侧) :编译器可能把 while (!ready) 优化成死循环,或者把 data 的读取提前到循环之前。volatile 只能挡住一部分编译器优化,它不产生任何硬件屏障。
先纠正一个常见误解
很多人第一反应是"加个 volatile 就好了"------上面的代码里 ready 和 data 都已经是 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
动手练习
- 在 ARM 机器上跑 1000 次
race_bug,统计data = 0出现的比例------比例不高恰恰是这类 bug 危险的原因。 - 把
race_fixed.c里的 acquire/release 改成memory_order_relaxed重跑,看你的平台能否复现错误。 - 用
objdump -d对比race_bug和race_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/stxrLL/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 上四种重排都允许,审查面更宽。
参考来源
- C/C++11 mappings to processors --- C11 原子操作到 AArch64 指令(LDAR/STLR/DMB ISH)的精确映射表
- Linux kernel · arch/arm64/include/asm/barrier.h ---
dmb(ish)/dsb(sy)宏定义、ish/osh/sy内存域的区别 - GCC · __atomic Builtins --- 六个内存序的官方定义与函数签名
- GCC · __sync Builtins --- GCC 官方"新代码应改用 __atomic"的明确建议