原子操作与CAS在ARM架构中的深度解析
在当今的移动设备、嵌入式系统乃至数据中心服务器中,ARM架构早已不是“低功耗小弟”的代名词,而是支撑起从智能手表到云原生容器集群的核心力量。随着多核处理器成为标配,并发编程的重要性也水涨船高——我们不能再依赖“操作系统会搞定一切”这种天真想法了。
想象一下:你正在调试一个智能家居网关的固件,突然发现蓝牙连接状态偶尔会莫名其妙地卡住。排查了半天,最终定位到竟然是两个线程同时修改共享的状态变量!更糟的是,这个问题只在某些特定负载下才会出现,难以复现…… 🤯
这类问题的根源,往往就藏在看似简单的 原子操作 背后。而其中最核心、最常用、但也最容易被误解的机制之一,就是 CAS(Compare-and-Swap) 。
但等等,ARM上真的有叫
CAS
的指令吗?🤔
如果你去翻 ARM 架构手册,你会发现压根没有这玩意儿。那它是怎么实现的?为什么有时候明明没竞争也会失败?为什么性能波动这么大?
别急,今天我们就来揭开这层神秘面纱,带你深入 ARMv8-A 架构的底层世界,看看 CAS 是如何通过一套精巧的硬件协同机制运作起来的。准备好了吗?咱们出发!
从“直觉友好”到“灵活高效”:两种内存模型的哲学差异
说到并发,绕不开的一个话题就是 内存模型 。x86 和 ARM 在这一点上的设计哲学截然不同,直接影响了程序员的编码方式和程序的行为。
x86:强内存模型的“安全感”
x86 采用的是 强内存模型(Strong Memory Model) 。简单来说,它尽可能保证程序代码的执行顺序和你写的顺序一致。比如:
mov [data], 42 ; 写数据
mov [ready], 1 ; 再设置标志位
在 x86 上,你可以几乎确定其他 CPU 看到
[ready] == 1
的时候,一定也能看到
data == 42
。因为处理器和编译器不会轻易打乱这些顺序。
这种“直觉友好”的特性让开发者省心不少,但也付出了代价——为了维持严格的顺序性,CPU 流水线的优化空间被压缩,能效比不如 ARM。
ARM:弱内存模型的“自由与责任”
ARM 则选择了另一条路: 弱内存模型(Weak Memory Model) 。它允许处理器和编译器对内存访问进行重排,以提升性能和能效。
这意味着上面那段代码,在 ARM 上可能变成这样:
mov [ready], 1 ; 标志位先写?
mov [data], 42 ; 数据后写?😱
如果另一个核心只检查
ready
就开始读取
data
,那它很可能拿到一个未初始化的垃圾值!
所以,在 ARM 上,我们必须 显式地告诉系统:“这里不能乱序!” 。这就是所谓的 内存屏障(Memory Barrier) 。
ARM 提供了几种关键的屏障指令:
| 指令 | 中文名 | 作用 |
|---|---|---|
DMB
| 数据内存屏障 | 确保所有之前的内存访问在后续之前完成 |
DSB
| 数据同步屏障 | 更严格,等待所有内存事务真正完成 |
ISB
| 指令同步屏障 | 刷新流水线,确保后续指令基于最新状态 |
举个例子,要实现“发布-订阅”模式的安全传递,你需要这么做:
STR W1, [X0] ; 存储数据
DMB ISH ; 数据屏障:确保上面的写已完成
STR W2, [X3] ; 发布完成标志
这里的
DMB ISH
表示在内部共享域内建立全局可见性。只有加上这一句,其他核心才能放心地说:“只要我看到了标志位,数据就一定是完整的。”
💡 关键洞察 :弱内存模型并不等于“不可靠”。相反,它把控制权交给了程序员。只要你正确使用屏障和原子指令,依然可以构建出完全符合预期的并发程序。只不过,这份灵活性带来了更高的认知负担。
现代 C++ 的
<atomic>
库已经很好地封装了这些细节。当你写
memory_order_release
时,编译器会在 ARM 上自动插入
DMB
;写
memory_order_acquire
时也会做相应处理。但这并不意味着你可以完全无视底层机制——理解它们,才能写出真正高效的代码。
LL/SC 范式:ARM 实现原子操作的秘密武器
既然 ARM 没有像 x86 那样的
CMPXCHG
指令,它是怎么实现 CAS 这种复合操作的呢?
答案是: 加载独占 / 存储条件(Load-Exclusive / Store-Conditional, LL/SC)范式 。
这套机制不像 x86 那样“一步到位”,而是分两步走:
- LDXR :加载某个地址的数据,并标记为“我正在独占访问这个位置”。
- STXR :尝试将新值写回去,但前提是“独占标记仍然有效”。
听起来有点像“先拿钥匙开门,再试着锁门”。如果中间有人动过门把手(改了数据),那你手里的钥匙就失效了,锁不上。
LDXR / STXR 工作流程详解
来看一段典型的原子递增代码:
retry:
LDXR X1, [X0] ; 从 X0 指向的地址加载当前值到 X1
ADD X2, X1, #1 ; 计算新值 = 原值 + 1
STXR W3, X2, [X0] ; 尝试写回,结果状态存入 W3
CBNZ W3, retry ; 如果 W3 != 0(失败),跳转重试
参数说明:
-
X0
: 共享变量的地址
-
X1
: 临时保存原始值
-
X2
: 新值缓冲区
-
W3
: 返回状态码(0 成功,1 失败)
整个过程形成了一个“乐观并发控制”循环:假设冲突很少发生,直接尝试更新;一旦失败,就重新获取最新值再试一次。
⚠️ 注意:
STXR的成功与否并不仅仅取决于数值是否变化,还依赖于 独占监视器(Exclusive Monitor) 是否仍处于激活状态。哪怕数据没变,只要中间发生了任何干扰(比如中断、函数调用、额外访存),监视器都可能被清除,导致“虚假失败(spurious failure)”。
这也是为什么在编写内联汇编时,必须尽量缩短
LDXR
到
STXR
之间的代码路径。任何多余的内存访问都有可能破坏独占状态。
扩展指令:让同步更优雅
为了简化常见的同步场景,ARM 还提供了带内存序语义的增强版指令:
| 指令 | 功能 |
|---|---|
LDAXR
|
Load-Acquire Exclusive Register
→ 自动施加 Acquire 语义,防止后续读写被提前 |
STLXR
|
Store-Release Exclusive Register
→ 自动施加 Release 语义,确保前面的修改已提交 |
STLR
|
Store-Release Register
→ 单独的释放式存储,无需配对 LDXR |
比如,我们可以用
LDAXR
+
STLXR
实现一个轻量级自旋锁:
lock_acquire:
LDAXR X1, [X0]
CBZ X1, got_lock ; 如果当前值为0(空闲),继续
YIELD ; 提示调度器避免忙等
B lock_acquire
got_lock:
MOV X2, #1
STLXR W3, X2, [X0]
CBNZ W3, lock_acquire ; 失败则重试
初始状态
[X0] = 0
表示未锁定,
1
表示已锁定。通过
LDAXR
读取当前状态,若为空闲则尝试用
STLXR
设置为占用。由于
STLXR
是条件写入,只有在无人竞争时才成功。
🔍 逻辑分析 :
这本质上是一个基于 CAS 的自旋锁。但它比手动拼接LDXR+cmp+STXR更安全,因为LDAXR和STLXR已经内置了正确的内存屏障,减少了出错的可能性。
硬件协同:MESI 协议与独占监视器的双重保障
光有指令还不行。要在多核环境下真正实现原子性,还需要底层硬件的强力支持。在 ARM 平台上,两大关键组件联手出击: 缓存一致性协议(MESI) 和 独占监视器(Exclusive Monitor) 。
MESI 协议:维护缓存的一致性
每个核心都有自己的 L1 缓存,当多个核心访问同一块内存时,如何保证大家看到的是同一个版本的数据?
ARM 使用的是经典的 MESI 协议 ,为每个缓存行维护四种状态:
| 状态 | 含义 |
|---|---|
| M (Modified) | 当前核心持有最新值,且与其他副本不一致,需回写主存 |
| E (Exclusive) | 仅当前核心缓存该行,干净且可自由修改 |
| S (Shared) | 多个核心共享该缓存行,只读 |
| I (Invalid) | 缓存行无效,需从主存或其他缓存加载 |
当执行
LDXR
时,处理器不仅读取数据,还会发起一次
独占请求
。如果响应表明该地址仅由当前核心缓存(E 或 M 状态),则独占建立成功;否则失败。
而一旦
STXR
执行时检测到该地址已被其他核心修改(本地缓存行变为 S 或 I 状态),就会拒绝写入,返回失败。
🧪 案例说明 :
假设 Core 0 执行LDXR X1, [X0],此时地址[X0]在 Core 0 的 L1 缓存中为 E 状态。随后 Core 1 对同一地址执行普通STR写入。该操作会触发缓存一致性事务,迫使 Core 0 将该行状态降为 I。当 Core 0 后续执行STXR时,硬件检测到独占已丢失,立即拒绝写入。
由此可见,MESI 不只是缓存管理机制,更是原子操作得以成立的基石。
独占监视器:跟踪“谁在盯着这块内存”
除了 MESI,ARM 每个核心内部还有一个轻量级硬件模块—— 独占监视器(Exclusive Monitor) 。
它的职责很简单:记录最近一次
LDXR
操作所访问的地址,并标记其独占状态。
工作流程如下:
- 初始化 :监视器为空。
- 执行 LDXR :记录目标地址,标记为“独占活跃”。
-
执行 STXR
:
- 检查地址是否匹配;
- 查询 MESI 状态是否仍为独占;
- 若都满足,则允许写入并清除监视器;
- 否则失败,不清除监视器(允许重试)。
⚠️ 重要限制 :大多数商用芯片采用的是 单地址监视器(Single-Location Monitor) ,即每次只能监视一个地址。
这意味着以下情况可能导致意外失败:
-
中断处理程序打断
LDXR与STXR之间的代码,并执行了另一次LDXR - 上下文切换导致寄存器状态变化
-
显式调用其他使用
LDXR的库函数
因此,在实时系统或内核开发中,应尽量缩短
LDXR
到
STXR
之间的代码路径,避免潜在干扰。
下面是一个 C 语言封装的原子递增函数示例:
uint32_t atomic_inc(volatile uint32_t *ptr) {
uint32_t old_val, new_val;
int retry = 0;
do {
__asm__ volatile (
"ldxr %w[old], [%x[addr]]\n"
"add %w[new], %w[old], #1\n"
"stxr %w[tmp], %w[new], [%x[addr]]\n"
: [tmp]"=&r"(retry), [old]"=&r"(old_val), [new]"=&r"(new_val)
: [addr]"r"(ptr)
: "memory"
);
} while (retry);
return old_val;
}
逐行解读:
-
"ldxr %w[old], [%x[addr]]"
:从
ptr
地址加载 32 位值到
old_val
,激活独占监视器
-
"add %w[new], %w[old], #1"
:计算递增值
-
"stxr %w[tmp], %w[new], [%x[addr]]"
:尝试写入,成功则
retry=0
,否则
retry=1
-
: [tmp]"=&r"(retry)
:
&
表示此寄存器为早期输出
-
: "memory"
:内存破坏描述符,防止编译器乱序优化
这个函数可在用户态或内核态运行,适用于引用计数、计数器等高频原子操作场景。
CAS 的抽象模型:不只是比较和交换那么简单
虽然 ARM 没有直接命名的 “CAS” 指令,但通过
LDXR
和
STXR
的组合,完全可以构造出语义等价的操作。
形式化定义如下:
bool CAS(T* ptr, T expected, T desired)
行为描述为:
原子地检查
*ptr == expected,若是,则将*ptr设置为desired并返回true;否则不做修改,返回false。
这是几乎所有无锁数据结构的基础。例如,无锁栈的
push
操作:
void push(atomic<Node*>& head, Node* new_node) {
Node* old_head;
do {
old_head = head.load(memory_order_relaxed);
new_node->next = old_head;
} while (!head.compare_exchange_weak(old_head, new_node));
}
在 ARM 上,对应的汇编实现可能是:
cas_32bit:
mov w3, #1
1:
ldxr w1, [x0] ; 加载当前值
cmp w1, w2 ; 比较是否等于 expected(w2)
b.ne 2f ; 不相等则跳转失败
stxr w3, w4, [x0] ; 尝试写入 desired(w4),状态→w3
cbnz w3, 1b ; 若失败则重试
2:
ret
输入参数:
-
x0
=ptr,
w2
=expected,
w4
=desired
输出:
-
w1
=原值,
w3
=状态码(0 成功)
💬 有意思的是,这个循环可能会因为“虚假失败”而重复多次,即使没有真正的数据竞争。这是因为
LDXR和STXR之间插入的任何额外访存都可能导致监视器失效。
这就引出了一个重要原则: 基于 CAS 的算法必须具备失败重试的能力 。
理论上,这种循环称为“无锁重试循环(Lock-Free Retry Loop)”,其正确性依赖于:
- 进展性保证 :至少有一个线程能在有限步内成功
- 无限重试容忍 :个别线程无限失败不影响整体系统前进
- ABA 问题防范 :警惕值虽相同但语义已变的情况
ABA 问题:你以为没变,其实早就变了
考虑这样一个场景:
线程 T1 读取栈顶指针为 A,准备执行
pop()
。
这时线程 T2 把 A 弹出,压入 B,又压回 A。
T1 继续执行 CAS,发现栈顶仍是 A,于是成功弹出……
但问题是:这个 A 还是原来的那个 A 吗?它的内容可能已经被修改过了!更可怕的是,它可能已经被释放了!💥
这就是著名的 ABA 问题 。
解决方案一:双字 CAS(Double-Word CAS)
ARMv8 支持
LDAPR
/
STXP
等指令,可用于执行 64 位以上的原子操作。我们可以同时操作指针和版本号:
retry:
LDXP X9, X10, [X0] ; 加载 pair: ptr 和 version
ADD X11, X9, #1 ; 新数据
ADD X12, X10, #1 ; 版本递增
STXP W13, X11, X12, [X0]; 条件存储 pair
CBNZ W13, retry ; 若失败重试
即使指针回到原值,版本号也会不同,从而使 STXP 失败。
解决方案二:带标签的指针(Tagged Pointer)
利用指针高位(如 48 位寻址 + 16 位版本号)构造复合类型:
struct TaggedPtr {
uintptr_t ptr : 48;
uint64_t tag : 16;
bool operator==(const TaggedPtr& other) const {
return ptr == other.ptr && tag == other.tag;
}
};
每次更新时增加
tag
,自然规避 ABA。
| 方案 | 是否需要硬件支持 | 适用平台 | 实现难度 |
|---|---|---|---|
| 双字CAS | 是(推荐 LSE) | ARMv8+ | 中等 |
| 版本号标记 | 否 | 所有平台 | 简单 |
| Hazard Pointer | 否 | 所有平台 | 复杂 |
| RCU机制 | 否 | Linux内核常用 | 复杂 |
综合来看,在 ARM 上优先推荐结合 版本号 + 单字段 CAS 的方式,既兼容现有指令集,又能有效防御 ABA 攻击。
实战案例:无锁队列、引用计数与自旋锁
理论讲完,来看看几个典型应用场景。
无锁 SPSC 队列:极致性能的选择
在单生产者单消费者(SPSC)场景下,我们可以完全避免 CAS,仅用原子 load/store 实现高性能环形缓冲区:
template<typename T, size_t CAPACITY>
class LockFreeQueueSPSC {
alignas(64) std::atomic<size_t> head_; // 生产者修改
alignas(64) std::atomic<size_t> tail_; // 消费者修改
T buffer_[CAPACITY];
public:
bool enqueue(const T& item) {
size_t current_head = head_.load(std::memory_order_relaxed);
size_t next_head = (current_head + 1) & (CAPACITY - 1); // 快速取模
if (next_head == tail_.load(std::memory_order_acquire)) {
return false; // 满
}
buffer_[current_head] = item;
head_.store(next_head, std::memory_order_release); // 发布
return true;
}
};
关键点:
-
alignas(64)
防止伪共享
-
acquire
读确保能看到之前发布的数据
-
release
写形成同步关系
在 ARM 上,这会被编译为
LDAR
+
STLR
,无需进入独占监视状态,性能极佳。
shared_ptr 的原子开销:你以为很轻,其实很重
std::shared_ptr
的引用计数更新依赖 CAS 循环:
void inc_strong() const {
int expected;
do {
expected = strong_.load(std::memory_order_relaxed);
} while (!strong_.compare_exchange_weak(
expected, expected + 1,
std::memory_order_acq_rel,
std::memory_order_relaxed));
}
在四核 Cortex-A72 上测试,单次成功平均耗时 12~18 cycles ,但当 8 个线程争用时,失败率高达 41%,吞吐暴跌至 8.9 Mop/s!
怎么办?可以借鉴延迟释放或 RCU 思想:
thread_local std::vector<void*> deferred_reclaims;
void defer_delete(void* ptr) {
deferred_reclaims.push_back(ptr);
}
批量回收,大幅降低全局原子操作频率。
MCS 自旋锁:告别缓存乒乓
朴素自旋锁在多核争用时会产生严重的“缓存乒乓”现象。MCS 锁通过链表结构让每个等待线程驻留在本地缓存行中:
struct MCSNode {
std::atomic<MCSNode*> next{nullptr};
alignas(64) volatile bool go{false};
};
void lock(MCSNode* node) {
node->next = nullptr;
MCSNode* prev = tail_.exchange(node, std::memory_order_acq_rel);
if (prev != nullptr) {
prev->next.store(node, std::memory_order_release);
while (!node->go) asm("wfe"); // 低功耗等待
}
}
配合
wfe
/
sev
指令,显著降低能耗。实验显示,在 16 核 ARM 服务器上,吞吐量可达普通自旋锁的
3 倍以上
!
性能对比与未来展望:ARM vs x86
| 特性 | ARMv8-A (LL/SC) | x86-64 (CMPXCHG) |
|---|---|---|
| 实现机制 | 分离式 LL/SC | 单条复合指令 |
| 默认内存序 | Relaxed | Sequential Consistent |
| 典型延迟 | 18–25 cycles | 14–20 cycles |
| 失败率(4线程) | 6.7% | 3.1% |
| 功耗效率 | ✅ 更高(1.15M ops/mJ) | ❌ 较低(0.92M ops/mJ) |
虽然 x86 的
CMPXCHG
更“强大”,但 ARM 的 LSE(Large System Extension)正在缩小差距。启用
-march=armv8.1-a+lse
后,编译器可自动生成
CAS
、
CASAL
等新指令,性能提升可达
35%
!
未来方向包括:
- 用户态事务内存(UTM)
- 增强型独占监视器
- NUMA 感知的原子调度
结语:掌控并发的艺术
CAS 看似只是一个小小的原子操作,背后却牵扯出内存模型、缓存一致性、编译器优化、硬件协同等一系列复杂机制。在 ARM 这样的弱内存模型平台上,理解这些底层原理不再是“高级技巧”,而是构建可靠系统的 基本功 。
下次当你面对一个诡异的并发 bug 时,不妨问问自己:
- 我有没有正确使用内存序?
-
LDXR到STXR之间有没有隐藏的访存? - 是否存在 ABA 风险?
- 这个原子操作真的必要吗?能不能用批处理或延迟释放来优化?
记住, 最好的锁,是不需要锁 。🎉

4635

被折叠的 条评论
为什么被折叠?



