序
"atomic"同时出现在处理器手册、编程语言、操作系统和并发算法中。它们共享"不可分割"这一核心思想,却不处在同一个抽象层次。
在 Arm 体系结构中,atomic 首先描述内存访问的可观察性质 :一次访问是否会撕裂,一次写以怎样的方式进入多个观察者的视野。编程中的 atomic 则描述对象及其操作的并发语义:一次 Load、Store、CAS 或加法能否作为一个完整操作参与竞争,编译器和处理器是否共同维持了这种语义。
两者不能直接画等号。体系结构原子性为上层实现提供机制,编程原子性则规定最终必须呈现给程序的结果。本文以 Arm Architecture Reference Manual for A-profile architecture, Arm DDI 0487 Issue M.c 为主要依据,从这两个层次出发,分析它们各自解决的问题,以及它们如何连接起来。
1. 两个层次的 atomic
1.1 体系结构层:约束内存事件
处理器执行访存指令时,会产生一个或多个内存读写事件。Arm 所说的 atomic access,约束的是这些事件能够以什么形式被其他观察者看到。
这一层主要包含两类原子性:
- 单副本原子性(single-copy atomicity):一次访问的内容是否作为一个整体被观察,即是否可能撕裂。
- 多副本原子性(multi-copy atomicity):一次写传播给多个观察者时,哪些中间可见状态是允许的。
它们属于体系结构与内存模型的契约。契约规定可观察结果,却不要求处理器采用某一种内部实现。一条原子访问可以经历流水线停顿、推测执行或内部重放;实现也可以使用写缓冲、一致性互连以及其他微体系结构结构。只要对外结果符合规则,体系结构原子性就成立。
Arm 文档中的 atomic access 与 Atomic instruction 也不是同义词。前者是内存访问具有的性质,普通的对齐 LDR/STR 就可能产生单副本原子访问;后者通常特指 CAS、LDADD 等直接完成原子读改写的指令类别。是否原子是一项内存效果,是否属于 Atomic instruction 则是指令功能分类。
1.2 编程层:约束对象上的操作
编程语境中的 atomic 以一个原子对象为中心,并从操作的单线程定义出发。例如,递增操作在单线程中应当读取对象的当前值,将其加一后写回,并返回规定的结果;并发执行不能改变这项基本语义。
本文采用下面的判据定义编程原子操作:
如果一次并发执行的可观察结果,能够解释为其中各个已完成的完整操作按照某个先后顺序逐个发生,并且在这个顺序中,每个操作的返回值和对象状态变化仍然符合该操作的单线程定义,那么这些操作对该对象是原子的。
"按照某个先后顺序逐个发生"并不要求各操作在物理执行时间上互不重叠。它们可以同时读取、计算和尝试提交;原子性要求的是,最终结果必须等价于完整操作逐个执行的结果。
这个串行顺序不能任意安排。按照选定顺序逐个应用操作的单线程定义,必须能够得到实际执行中的返回值和对象状态。假设计数器初始为 0,两个并发操作都要求将计数器递增一次。如果两个操作完成后计数器最终为 1,这个结果就无法解释为两个完整递增依次执行:无论哪一个先执行,最终值都应当是 2。因此,普通的"读取---加一---写回"并不是原子递增。
两个操作在内部同时读到 0 本身并不违反原子性。关键在于,它们不能都基于这个旧值成功提交:一个操作改变对象后,另一个操作必须基于新的状态重新完成,或者按照该操作的定义报告失败。只有这样,所有成功操作才能排列成符合单线程定义的状态转换。
编程原子性不仅依赖硬件:
- 语言内存模型规定哪些并发访问合法,以及原子对象具有什么语义。
- 编译器必须保持访问的原子粒度,不能把操作变换成破坏语义的普通访问。
- 运行库或内核负责选择适合当前处理器的实现。
- 处理器和内存系统提供不可撕裂访问、独占访问或原子读改写等底层能力。
因此,编程中的 atomic 是一项跨越语言、编译器和硬件的端到端保证;处理器指令只是这条实现链的一部分。
原子性也不等于无锁性。某个编程原子操作可以由一条指令完成,可以由独占循环完成,也可以在运行库中借助隐藏的锁完成。它们的进展性质和性能不同,但只要都满足上层规定的不可分割语义,就可以实现同一个 atomic 接口。
2. 单副本原子性:内存访问的完整性
2.1 从不撕裂理解单副本原子性
single-copy atomicity 中的"single-copy"并不是说系统中只能存在一份数据副本。它表示一次内存访问具有不可再分的体系结构效果。
假设一个 64 位对象原来为:
text
0x00000000_00000000
处理器将它更新为:
text
0xFFFFFFFF_FFFFFFFF
如果该写与并发读取在相应范围内都是单副本原子的,并且没有其他重叠写入,那么读取可以得到旧值或新值,不能得到:
text
0xFFFFFFFF_00000000
这样的结果由新旧两次状态拼接而成,称为撕裂值。
因此,在最常见的同地址、同大小访问中,单副本原子性可以直接理解为:一次读取要么看到写入前的完整值,要么看到写入后的完整值,不会看到一次写入的部分效果。对于大小不同或地址部分重叠的访问,则需要按照 Arm ARM 的重叠访问规则单独分析,不能简单套用"旧值或新值"这一结论。
2.2 原子访问不等于不可中断执行
单副本原子性约束的是观察结果,而不是指令执行期间的控制流状态。一次访问可以在内部被拆分、停顿或重放,只要体系结构不允许其他观察者看到部分效果。
同样,一条指令也可能产生多个内存访问。对于按访问序列执行的指令,异常甚至可以出现在各子访问之间。因此,"一条汇编指令"与"一次不可分割的内存效果"是两个不同概念;原子粒度必须依据指令类型和 Arm ARM 的明确规则判断。
2.3 AArch64 的基本原子粒度
普通显式数据访问中,常见的基线规则如下:
| 访问形式 | 单副本原子性保证 |
|---|---|
| 单个通用寄存器的 Load/Store,地址按访问大小对齐 | 整次访问原子,通用寄存器访问最大为 64 位 |
LDP/STP 访问两个正确对齐的通用寄存器 |
每个寄存器对应的访问分别原子,不据此保证整个成对访问原子 |
| 单个不超过 64 位的 SIMD/FP 量,按访问大小对齐 | 整次访问原子 |
| 普通 128 位 SIMD/FP 访问,按 64 位对齐 | 基线规则把它视为两个 64 位原子访问 |
| 没有获得更大粒度保证的多字节访问 | 体系结构最低只保证每个字节的访问原子 |
例如:
asm
LDR X0, [X1] // X1 按 8 字节对齐:64 位单副本原子读
STR X2, [X3] // X3 按 8 字节对齐:64 位单副本原子写
对齐是保证的一部分。"未对齐访问能够执行"并不等于"未对齐访问整体不撕裂";执行许可和原子粒度是两项独立属性。
2.4 FEAT_LSE2 对原子粒度的扩展
FEAT_LSE2 加强了 AArch64 对 Load/Store 的单副本原子性与对齐保证。其中与普通代码关系最密切的规则包括:
LDP、LDNP或STP访问两个 64 位寄存器时,如果整体访问按 16 字节对齐,且目标是 Inner Write-Back、Outer Write-Back 的 Normal Cacheable Memory,那么整个 16 字节访问是单副本原子的。- 上述成对指令访问少于 16 字节时,如果所有字节位于同一个按 16 字节对齐的 16 字节块内,并满足相同的内存属性,整个访问是单副本原子的。
- 未按数据大小对齐的 Load/Store,如果所有字节仍位于同一个按 16 字节对齐的 16 字节块内,并满足相同的内存属性,也可以获得单副本原子性。
FEAT_LSE2 在 Armv8.4-A 中是必选特性;在更早的体系结构版本中不能无条件假定存在。因此,下面这条指令是否构成 128 位单副本原子写:
asm
STP X0, X1, [X2]
取决于处理器特性、整体对齐和目标内存属性,而不能仅从助记符推断。原子粒度始终是指令、访问大小、对齐、内存属性和体系结构特性共同决定的结果。
2.5 128 位访问的几种不同语义
128 位访问尤其能说明"指令宽度"与"原子宽度"并不相同:
| 访问方式 | 体系结构含义 |
|---|---|
基线条件下的LDP/STP,每个元素为 64 位 |
两个分别原子的 64 位访问 |
满足FEAT_LSE2、16 字节对齐及规定内存属性的 LDP/STP |
整个 128 位访问单副本原子 |
装载两个 64 位量的LDXP |
单独执行时不保证一次 128 位原子快照 |
成功写入两个 64 位量的STXP |
整个成对写入单副本原子 |
CASP |
对成对对象执行原子比较交换,受相应特性与对齐规则约束 |
需要 128 位编程原子操作时,编译器或运行库会根据目标处理器选择 LDP、独占序列、CASP 或锁辅助路径。程序所依赖的应当是上层接口承诺的语义,而不是某一种固定指令序列。
2.6 AArch32 中的 64 位访问
在 AArch32 状态下,对齐的普通字访问具有 32 位单副本原子性。LDRD/STRD 一般按两个 32 位字访问理解,不能仅凭它们在一条指令中传送 64 位数据,就推导出面向处理器观察者的普通 64 位原子保证。
对于按 8 字节对齐的地址,LDREXD 以及成功的 STREXD 所产生的双字访问具有 64 位单副本原子性。这使 32 位处理器能够以独占访问为基础实现 64 位原子对象和原子读改写。
3. 多副本原子性:写的传播约束
单副本原子性关注一次访问内部是否完整;多副本原子性关注一次写在多个观察者之间如何变得可见。前者约束访问的内容边界 ,后者约束写的传播边界。
3.1 同一位置的一致性顺序与严格 MCA
Arm ARM 对严格 multi-copy atomic 写给出两个条件:
- 同一位置的所有写被串行化,所有观察者以相同顺序观察这些写;某个观察者可以跳过部分中间写。
- 在所有观察者都观察到某次写之前,对该位置的读取不能返回该写的值。
第一条建立同一位置的共同写顺序,属于一致性的核心性质;第二条进一步限制写何时能够被读取返回。只满足第一条并不足以构成严格 MCA。
若两个处理器分别向同一位置写入 W1 和 W2,系统会为它们确定共同的一致性顺序。观察者可以只看到其中一部分写,却不能以彼此相反的顺序观察同一组写。这个性质保证了单个位置不会为不同处理器形成互相矛盾的历史,但它不决定并发写中哪一个在前,也不把依赖旧值的更新变成原子读改写。
3.2 Arm 的 Other-multi-copy atomic 模型

Arm Architecture Reference Manual for A-profile architecture, Arm DDI 0487 Issue M.c
Issue M.c 中的 Arm 内存模型是 Other-multi-copy atomic(O-MCA) 。它对一次写 W 的可见范围施加如下约束:
- 发出
W的观察者可以先于其他观察者读取到自己的写。 - 一旦某个不同的观察者已经观察到
W,所有以一致方式访问该位置的其他观察者也必须已经观察到W。
第二条就是 O-MCA 在同址一致性之外增加的传播约束。它排除了 W 已经对某个其他观察者可见,却仍然只因传播尚未到达而对其余一致性观察者不可见的状态。换句话说,从体系结构的可观察关系看,一次写在"写入者以外的观察者"中不能逐个传播;这些观察者对该写的可见性近似是全有或全无。
例如,处理器 A 写入 X=1,B、C、D 都以一致方式访问 X。O-MCA 允许 A 通过自身的写入路径提前读到 1,此时 B、C、D 仍未观察到该写。但是,一旦 B 已经观察到 X=1,C 和 D 就不能在一个被确定为更晚的观察点上,仅仅因为该写"还没有传播过来"而继续停留在 X=0。
这里的"已经观察到"不是要求每个处理器实际执行一次读取,也不是要求它们必须返回 1。如果随后还有 X=2,C 可以跳过 1 直接观察到 2;它只是不能仍停留在 X=1 之前。该约束描述的是内存模型中的可见关系,也不意味着所有缓存必须在同一个物理时钟沿更新。若 C 对 X=0 的读取发生在 B 观察到 X=1 之前,则仍然是允许的。
O-MCA 因而保留了写入者提前观察自身写入的空间,同时排除了写在多个"其他观察者"之间任意、逐个传播的行为。它强于只规定同一位置写顺序的模型,又不同于没有写入者例外的严格 MCA。
同址一致性、O-MCA 与严格 MCA 的关系可以概括为:
text
同一位置的一致性顺序:规定多次写的共同先后
O-MCA:规定一次写对其他观察者不能只传播给其中一部分
严格 MCA:进一步排除写入者提前观察自身写的例外
多副本原子性仍然只讨论传播性质。它不会自动建立不同地址之间的发布关系,也不会使普通的复合更新具备编程原子性。
4. 编程中的 atomic:对象与操作
4.1 原子对象是语言层的抽象
程序不是直接对"内存事件"编程,而是对变量、对象和操作编程。语言或内核把某个对象声明为 atomic,意味着并发代码必须通过规定的原子接口访问它;这些访问参与该对象的原子语义,并由编译器和底层实现共同维持。
以 C/C++ 为例,普通对象上的冲突并发访问可能构成数据竞争;底层硬件即使保证一次对齐读取不撕裂,也不能使这种程序自动合法。volatile 只约束部分编译器行为,同样不提供语言级原子对象语义。
因此,体系结构的单副本原子访问通常是实现原子对象的必要材料,却不是完整的语言契约。语言级 atomic 还必须处理编译器转换、并发访问规则以及所请求的内存顺序。
4.2 原子 Load/Store 与原子读改写
编程原子操作可以分成两类。
第一类是单向访问:
text
atomic load 读取一个原子对象
atomic store 写入一个原子对象
当对象大小、对齐和目标平台满足条件时,它们往往可以直接映射为普通的单副本原子 Load/Store,再由编译器保证访问不会被拆分、合并或替换为不符合语言语义的形式。
第二类是读改写(read-modify-write,RMW):
text
compare-and-swap
exchange
fetch-add
fetch-or
RMW 不仅要求读取和写入各自完整,还要求二者之间不能被竞争者插入一个会导致更新丢失的操作。它必须以一个整体参与目标对象的修改顺序。
4.3 单次访问原子不能构成原子加法
下面三条指令中的 Load 和 Store 可以各自具有单副本原子性:
asm
LDR X0, [X1]
ADD X0, X0, #1
STR X0, [X1]
但整个序列不是原子读改写。两个处理器可以同时读到 10,分别计算出 11,再先后写回 11,从而丢失一次递增。
单副本原子性排除的是"半个旧值与半个新值"的撕裂;原子 RMW 排除的是竞争操作插入读取与写回之间。两者面对的是不同的并发失效模式。
4.4 CAS 的编程语义
CAS(Compare-And-Swap)描述以下不可分割的状态转换:
c
old = *p;
if (old == expected)
*p = desired;
return old;
这段代码只表达语义,逐行执行并不原子。真正的 CAS 要求读取、比较和条件写入共同构成一次 RMW:若目标仍等于 expected,则将它替换为 desired;无论是否成功,都返回操作所观察到的旧值。
CAS 因此首先是一种上层操作语义。它可以直接映射为同名硬件指令,也可以由另一种体系结构原语实现。理解这一点,才能准确说明 CAS 与 LL/SC 的关系:它们不处在完全相同的层次。
5. 从编程语义到 Arm 指令
5.1 LSE:用一条指令完成原子读改写
FEAT_LSE 为 AArch64 提供 CAS、SWP、LDADD、LDCLR、LDEOR、LDSET 等原子读改写指令。例如:
asm
CAS X1, X2, [X0] // [X0] == X1 时写入 X2;X1 返回旧值
这条指令直接生成原子比较交换的内存效果,使上层 CAS 的读取、判断和条件写入作为一次完整的状态转换发生。类似地,LDADD 能原子地取得旧值并提交加法结果。
FEAT_LSE 与 FEAT_LSE2 分工不同:LSE 增加原子 RMW 指令,LSE2 主要加强普通 Load/Store 的单副本原子性与对齐规则。二者都与 atomic 有关,但解决的不是同一个问题。
5.2 LL/SC:用条件提交构造原子操作
在没有 LSE 指令或实现选择不使用 LSE 时,AArch64 可以通过 LDXR/STXR 独占访问实现同样的上层语义。下面的代码直接构造一个 64 位 CAS:
asm
// X0 = 对象地址,X1 = expected,X2 = desired
// 返回时 X0 = 操作观察到的旧值
cas_u64:
1: LDXR X3, [X0]
CMP X3, X1
B.NE 2f
STXR W4, X2, [X0]
CBNZ W4, 1b
MOV X0, X3
RET
2: CLREX
MOV X0, X3
RET
LDXR 读取目标并建立独占监视。STXR 只有在监视仍然有效时才提交;若竞争访问或其他事件使监视失效,它会报告失败而不修改目标,代码随后重新读取并重试。
CAS 的判定条件是"当前值是否等于期望值",Store-Conditional 的判定条件是"独占监视是否仍然有效"。LL/SC 循环把这两种条件组合起来,从而实现 CAS 语义。成功的 STXR 提交这次状态更新;此前的失败尝试没有向目标对象提交部分结果。
这段序列可以在 LDXR 与 STXR 之间被中断,也可能重试多次,但它仍能实现一个原子操作。它再次说明:编程原子性要求的是结果不可分割,而不是实现过程不可中断。
AArch32 中的 LDREX/STREX、LDREXD/STREXD 承担相同角色。编译器、运行库或 Linux 内核可以在 LSE 指令与独占循环之间选择,对调用方维持同一个原子操作接口。
5.3 Linux AArch32 atomic_t:一个真实的 LL/SC 实现
Linux 的 AArch32 实现给出了一个典型的工程实例。在 ARMv6 及更高版本的 arch/arm/include/asm/atomic.h 中,整数原子操作由下面的宏生成。具体函数名和封装方式会随内核版本演进,但独占循环的核心结构保持一致:
c
#define ATOMIC_OP(op, c_op, asm_op) \
static inline void arch_atomic_##op(int i, atomic_t *v) \
{ \
unsigned long tmp; \
int result; \
\
prefetchw(&v->counter); \
__asm__ __volatile__("@ atomic_" #op "\n" \
"1: ldrex %0, [%3]\n" \
" " #asm_op " %0, %0, %4\n" \
" strex %1, %0, [%3]\n" \
" teq %1, #0\n" \
" bne 1b" \
: "=&r" (result), "=&r" (tmp), "+Qo" (v->counter) \
: "r" (&v->counter), "Ir" (i) \
: "cc"); \
}
源码随后通过 ATOMIC_OPS(add, +=, add) 生成加法接口;其中 ATOMIC_OP 接收到 add, +=, add 这组参数后,得到的 arch_atomic_add(i, v) 在语义上相当于:
c
do {
old = LDREX(&v->counter);
new = old + i;
} while (STREX(&v->counter, new) != 0);
实际汇编直接复用 %0:ldrex 将旧值读入 result,add 在寄存器中把它更新为新值,strex 再尝试把新值写回。strex 的状态写入 %1:返回 0 表示提交成功,非 0 表示本次条件写入没有发生。teq 和 bne 1b 因此会在失败时回到 ldrex,重新读取当前值并重新计算。
假设计数器初始为 0,两个核心同时执行加一。它们可能都通过 ldrex 读到 0,也都会在各自的寄存器中算出 1;但其中一个核心成功执行 strex 后,另一个核心的条件写入不能再基于原来的独占状态提交。失败的一方重新读取 1,计算出 2,然后再次尝试提交。最终结果是 2,等价于两个完整的加一操作依次执行,而不是两个普通的"读、改、写"相互覆盖。
这段代码也划清了几个容易混淆的边界。prefetchw() 只是写预取优化,不提供原子性;__asm__ __volatile__ 和操作数约束用于约束编译器处理这段代码的方式,也不产生处理器之间的原子提交。真正使更新成为 atomic 的,是 LDREX/STREX、独占监视以及失败后的重试。至于该操作与其他内存访问之间需要何种顺序,仍属于内存顺序问题,而不是这个独占循环本身所表达的全部内容。
这个实例表明,一个编程原子操作可以由多条能够被中断、交错和重试的指令组成。实现过程不是"一次执行完且不可打断",但只有成功提交的完整更新才会成为对象的新状态,所以并发结果仍然符合该操作的单线程定义。
5.4 映射关系决定正确性,不决定固定指令形态
同一个上层 atomic 操作可以有不同实现:
text
64 位 atomic load -> 对齐的 LDR,或带顺序语义的 LDAR
64 位 atomic store -> 对齐的 STR,或带顺序语义的 STLR
atomic fetch-add -> LDADD,或 LDXR/STXR 重试循环
compare-and-swap -> CAS,或 LDXR/STXR 重试循环
128 位 atomic Load/Store -> 满足条件的 LSE2 访问、独占序列或运行库路径
128 位 compare-and-swap -> CASP、独占序列或运行库路径
反过来,同一条机器指令在不同对齐、内存属性或体系结构特性下,也可能具有不同的原子粒度。于是,可靠的实现必须同时满足两端:上层先给出所需语义,下层再证明选定的指令序列确实实现了它。
6. 原子性与内存顺序
原子性规定一次操作会不会以部分状态出现;内存顺序规定这个操作与其他内存事件之间具有怎样的关系。二者相互独立,又经常在同步算法中组合。
一个 relaxed 原子加法仍然是不可分割的 RMW,只是它通常不承担其他地址的发布或获取。Release、Acquire 和更强的顺序语义,则在原子对象之外建立程序所需的跨地址关系。
因此,在普通 LDR、ADD、STR 序列中插入屏障,不能把它变成原子加法:屏障可以约束这些访问与其他访问的顺序,却不能阻止竞争者进入 Load 与 Store 之间。反过来,使用 LDXR/STXR 或 LDADD 完成了目标对象的原子更新,也不代表周围数据自动获得所需顺序。
Arm 为独占访问提供 LDAXR、STLXR,并为 LSE 原子指令提供 Acquire、Release 及 Acquire-Release 变体,正是为了让实现分别选择:
text
目标对象的不可分割更新 + 操作前后所需的内存顺序
完整的同步原语通常同时包含这两部分,但分析时必须把它们分开。
7. 一条完整的理解路径
Armv8 原子性可以沿着下面的层次理解:
text
编程语义
原子对象、Load/Store、CAS、RMW、串行执行语义、内存顺序
|
| 编译器、运行库或内核进行映射
v
体系结构机制
单副本原子访问、独占访问、LSE 原子指令、O-MCA
|
| 处理器与内存系统实现
v
可观察的内存行为
不撕裂、条件提交、共同写顺序、受约束的写传播
这条路径中的每一层都有自己的问题:
- 在编程层,先确定对象需要普通原子 Load/Store,还是不可分割的读改写,并选择所需内存顺序。
- 在映射层,确认编译器或内核使用了能保持该语义的接口与实现,不能用普通对象或
volatile替代。 - 在体系结构层,检查指令产生的访问数量、原子粒度、对齐、内存属性和可选特性。
- 在多处理器层,区分单次访问的完整性、同一位置的一致性顺序和写传播约束。
最终,体系结构 atomic 与编程 atomic 的关系可以概括为:
体系结构原子性规定硬件能够提供哪些不可分割的内存效果;编程原子性规定并发程序必须看到怎样的完整操作。前者是实现机制和证明基础,后者是软件真正依赖的语义契约。
只有把这两个层次连接起来,才能准确判断一条指令是否足以实现某个原子操作,也才能理解为什么多条可被中断的指令仍然可以构成一次可靠的 atomic 更新。
主要参考资料
- Arm Architecture Reference Manual for A-profile architecture, Arm DDI 0487 Issue M.c,重点参见 B2.2 "Atomicity in the Arm architecture"、B2.2.1 "Requirements for single-copy atomicity"、B2.2.2 "Properties of single-copy atomic accesses"、B2.2.3 "Multi-copy atomicity"、B2.2.4 "Requirements for multi-copy atomicity",以及 AArch32 对应的单副本原子性要求。
- Arm A-profile A64 Instruction Set Architecture, Arm DDI 0602, 2026-03,用于查阅
LDXR、STXR、CAS、CASP、LDADD等具体指令语义;原子性概念与内存模型仍以 DDI 0487M.c 为准。 - Sequential Consistency in Armv8
