这是迁移中最危险的一类问题
第 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 = 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:
<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
动手练习
- 在 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"的明确建议
适合做 ARM 迁移或并发调优的工程师精读。它把抽象的弱内存模型落到可复现的 bug 与反汇编验证上,并给出内存序选型阶梯,能直接用于代码审查与性能取舍。