AArch64 同步原语
本文介绍AArch64的同步原语。
1. AArch64 原子指令
1.1 原子指令分组(LSE 与 FEAT_LSE128)
在 Arm 架构中,为了提升多处理器/多核系统中同步原语的性能与可扩展性:
- Armv8.1-A(LSE,Large System Extensions):引入了全新的原子指令集,能够在单条指令中完成不可中断的“读-修改-写”(Read-Modify-Write, RMW)操作,避免了传统独占加载/存储(
LDREX/STREX)重试循环带来的总线竞争。 - Armv9.4-A(FEAT_LSE128):进一步扩展 LSE,支持 128 位数据的原子操作,主要用于高效管理大尺寸数据结构(如 128 位的页表描述符/转换表描述符)。
A64 原子指令全景表
| 特性扩展 | 指令 | 语法 | 描述 / 操作细节 | 关键应用场景 |
|---|---|---|---|---|
| FEAT_LSE | CAS | CAS <Xs>, <Xt>, [<Xn>] |
原子地比较内存 [Xn] 的值与 Xs(32/64位)。若相等,将 Xt 写入 [Xn],并将原始值返回至 Xs。 |
无锁数据结构(Lock-free data structures) |
| FEAT_LSE | CASP | CASP <Xs>, <X(s+1)>, <Xt>, <X(t+1)>, [<Xn>] |
比较 [Xn] 处连续的两个 32/64 位值与 Xs, X(s+1)。若相等,将 [Xn] 替换为成对的 Xt, X(t+1),并返回旧值对。 |
成对数据(如带元数据的指针)的原子更新 |
| FEAT_LSE | LD |
LDADD / LDEOR / LDCLR <Xs>, <Xt>, [<Xn>] |
原子地从 [Xn] 加载数据,与 Xt 执行指定运算(ADD/EOR/CLR/SET/SMAX等),结果存回 [Xn] 并将旧值存入 Xs。 |
原子算术与位运算(如计数器、标志位) |
| FEAT_LSE | ST |
STADD / STEOR / STCLR <Xs>, [<Xn>] |
原子地对 [Xn] 的值与 Xs 执行指定运算(ADD/EOR/CLR/SET等)并写入结果。无返回值。 |
无需读取旧值结果的约束原子更新 |
| FEAT_LSE | SWP | SWP <Xs>, <Xt>, [<Xn>] |
原子地交换 [Xn] 处的值与 Xt,并将原始旧值存入 Xs。 |
简单互斥锁或数值交换 |
| FEAT_LSE128 | LDCLRP | LDCLRP <Xd>, <Xs>, [<Xn>] |
原子地加载 [Xn] 的 128 位四字(Quadword),按 Xs 指定掩码进行位清除(AND-NOT),存回结果并返回原始值至 Xd。 |
128位页表描述符操作、多核系统中位标志清除(如释放锁) |
| FEAT_LSE128 | LDSETP | LDSETP <Xd>, <Xs>, [<Xn>] |
原子地加载 [Xn] 的 128 位四字,按 Xs 指定掩码进行位置位(OR),存回结果并返回原始值至 Xd。 |
原子地设置状态标志(如任务调度) |
| FEAT_LSE128 | SWPP | SWPP <Xt1>, <Xt2>, [<Xn>] |
原子地交换 [Xn] 处的 128 位四字与寄存器对 Xt1, Xt2 的值,并将原始旧值存入 Xt1, Xt2。 |
无锁数据结构中交换带元数据的指针;对齐 128 位内存的双字原子性 |
获取与释放内存语义(Acquire & Release)
上述原子指令可通过在助记符后缀中添加语义修饰符,来保障多核/多线程下的内存可见性与顺序:
- A (Acquire):确保程序顺序中后续所有的加载/存储操作,不会被重排序到该 Acquire 操作之前生效。
- L (Release):确保程序顺序中此前所有的加载/存储操作,在该 Store-Release 操作生效前已全部完成并可见。
- **AL (Acquire-Release)**:同时具备 Acquire 与 Release 双重屏障语义(例如
LDADDAL X0, X1, [X2])。
注意:
ST<op>类型指令属于纯写/存储操作,因此不具备 Acquire(获取)语义。
1.2 原子指令示例
简单 lock()/unlock() 示例
1 | ; ------------------------------------------------------------------------------ |
无锁堆栈示例
软件通常使用无锁方式更新共享位置,以避免使用锁带来的开销。无锁代码通常先向共享位置加载数据,然后执行一些相关的计算,最后使用比较并交换 (CAS) 原子操作完成更新。最终 CAS 操作的成功取决于加载操作中观察到的位置值是否已被其他线程更改。以下是一个更新无锁链表的示例:
1 | Loop: |
如果用的是常见的 GCC 或 Clang 编译器,可以直接使用扩展函数,对应的 c code 可能是:
1 | void push_gcc(Node* new_node) { |
线程屏障示例
线程屏障提供了一个同步点,确保所有线程在继续执行之前都已到达该同步点。线程屏障在各种应用程序中的并行代码段之间很常见。典型的线程屏障实现可能包含一个计数变量,用于检测所有线程何时都已到达同步点。
1 | MOV W1, #-1 ; 设置增量值(负数 -1,用于执行减法) |
对应的 c code 可能是:
1 |
|
原子位操作示例
由于多线程访问和抢占式调度,线程安全状态标志的管理需要同步。在访问共享资源(例如 GPU 核心或 DMA 通道等硬件外设)之前,线程会原子性地设置一个状态位,以表明该共享资源已被分配。
1 | ; ============================================================================== |
1.3 原子访问地址的内存属性
原子指令不是对任何内存都能生效的,只有在带缓存、支持一致性的普通内存上,硬件才能保证“真原子”;且硬件会根据情况选择在 CPU 内部(L1 缓存) 或 外部总线(L3/互连线) 完成原子操作。
哪种内存才支持原子指令?
不是所有内存地址都可以随便用原子指令(如 CAS、LDADD):
- 支持真原子的内存类型:
- 开启缓存的普通内存(Normal Memory with Inner/Outer Write-Back):这类内存处于 CPU 的硬件缓存一致性协议保护下,多核并发访问时数据绝对安全。
- 不支持/有风险的内存类型:
- 设备内存(Device Memory,如显卡、外设寄存器)或 未开启缓存的内存(Non-cacheable)。
- 风险:对这些区域强行用原子指令,硬件可能无法保证原子性,甚至引发硬件异常或不可预测的副作用。
原子操作在哪里执行?(近原子 vs 远原子)
当 CPU 执行一条原子指令时,硬件有两种不同的处理策略:
近原子操作(Near Atomic)
- 执行路径:在 CPU 核心本地的 L1 数据缓存 内部完成“读-修改-写”全过程。
- 触发条件:目标地址已命中 L1D 缓存,且对应缓存行在 MESI/MOESI 协议中处于独占/唯一状态(Unique State)。
- 核心优势:无需经过外部总线,单次操作延迟最低。
- 典型应用场景:
- 自旋锁与无锁结构(
CAS/LDADD):例如线程尝试通过CAS抢占锁。由于算法依赖返回值判断是否抢锁成功,数据必须拉取到本地 L1 完成比较与更新,以追求最低的反馈延迟。
- 自旋锁与无锁结构(
远原子操作(Far Atomic)
- 执行路径:CPU 核心将原子操作指令打包,通过总线(如 Arm CHI 接口)转发给外部内存系统(如 L3 缓存、系统互连线 Interconnect 或内存控制器)代为执行。
- 触发条件与行为分流:
- 存储类原子指令(
ST<OP>,如STADD):默认走远原子。CPU 不需要读取返回值,直接将修改命令发往外部处理。 - 加载类原子指令(
LD<OP>,如LDADD):由于 CPU 急需返回值,硬件会优先尝试将目标数据拉取(Fetch)到本地 L1D 缓存,转换为“近原子”执行。
- 存储类原子指令(
- 核心优势:避免多核争抢同一变量时引发的缓存行颠簸(Cache Thrashing),显著提升高并发下的系统吞吐量。
- 典型应用场景:
- 全局多线程性能计数器:例如 16 个线程并发递增
total_requests_count。线程只负责写入、不依赖返回值,直接将STADD指令甩给共享 L3 缓存排队执行,CPU 核心无须阻塞等待或争抢 L1 Cache 独占权。
- 全局多线程性能计数器:例如 16 个线程并发递增
总结:需要返回旧值(如抢锁
CAS/LDADD)或数据已在 L1D 独占时走近原子,纯写入且不依赖返回值(如全局计数STADD)时走远原子。
1.4 Linux 内核中的应用
典型的原子操作
Linux 内核中原子指令的定义位于 /arch/arm64/ include /asm/atomic_lse.h 头文件中。例如,以下代码是 FEAT_LSE 原子指令的典型实现。它生成了各种具有不同内存顺序的原子操作函数调用。
1 |
|
展开宏 ATOMIC_FETCH_OPS( add , ldadd) 后,将生成 4 个函数,其中一个如下所示。
1 | static __always_inline int |
这些函数可用于自旋锁/互斥锁、引用计数、无锁列表和读-复制-更新。
Linux 内核中的自旋锁
以下的 hyp_spin_lock 函数位于 arch /arm64/kvm/hyp/ include /nvhe/spinlock.h 中。它实现了基于 LDADD 原子指令优化的基于票据的自旋锁。该代码专为底层同步而设计,例如在虚拟机管理程序或内核环境中,当多个 CPU 或虚拟机访问共享数据结构时。
当请求使用票据锁时,票据锁决定了临界区用户的访问顺序。票据锁会在每个线程首次请求锁时分配一个票据号,然后将该票据号与当前锁的票据号进行比较。如果两者相同,则可以进入临界区。否则,线程将等待,直到当前票据号等于该线程的票据号。
读取当前锁号需要acquire语义。
1 | static inline void hyp_spin_lock(hyp_spinlock_t *lock) { |
等效 c code :
1 | typedef struct { |
解除票锁只需将当前票号加一,然后使用store release功能即可。
1 | static inline void hyp_spin_unlock(hyp_spinlock_t *lock) { |
等效 c code :
1 | void hyp_spin_unlock(spinlock_t *lock) { |
2. 独占访问
2.1 概述
在 ARMv8.1 引入 LSE 单条原子指令(如 LDADD)之前,Arm 架构从 Armv6 开始就通过 加载独占(Load-Exclusive) 与 存储独占(Store-Exclusive) 指令对,实现了经典的 LL/SC(Load-Link / Store-Conditional) 机制。
核心指令对 (LDXR / STXR)
Arm64(A64 指令集)通过一对专属指令来实现 LL/SC 机制,用于对内存发起独占访问:
- **
LDXR(Load Exclusive)**:启动独占操作。- 作用:将目标内存地址的数据加载到寄存器,并将该内存位置标记为“被当前 CPU 核心独占访问”。
- **
STXR(Store Exclusive)**:尝试完成独占操作。- 作用:尝试将数据写入目标内存地址。
- 返回值:通过状态寄存器返回成功/失败标识。若写入成功返回
0(表示读写期间无其他核心修改过该地址);若失败返回非0,需要配合循环重试。
数据宽度支持:
LDXR/STXR支持 8 / 16 / 32 / 64 位数据操作;LDXP/STXP则支持 128 位原子操作。
典型的原子自增实现(以 atomic_inc 为例)
1 | atomic_inc: |
- 原子性保障原理:如果在这两步之间有任何其他线程/核心对该内存区域进行了写入操作,硬件会自动清除独占标记,导致
STXR写入失败并触发重试,从而保证了“读-修改-写”的原子性。 - 硬件支持:每个 CPU 核心内部由 独占监视器(Exclusive Monitor) 来维护与跟踪这一独占标记状态。
结合 Acquire/Release 内存语义的扩展指令
独占访问指令还支持隐式的内存屏障语义,确保多核共享数据时的可见性与顺序性:
- **
LDAXR(Load-Acquire Exclusive)**:带 Acquire(读获取) 语义。确保该加载指令之后的所有内存访问不会被重排序到它之前(常用于抢锁/获取资源)。 - **
STLXR(Store-Release Exclusive)**:带 Release(写释放) 语义。确保该存储指令之前的所有内存访问不会被重排序到它之后(常用于解锁/释放资源)。
2.2 独占监视器
独占监视器是一个简单的状态机,只有两种状态:打开和独占。为了支持处理器之间的同步,系统必须实现两组监视器:本地监视器和全局监视器。
2.2.1 本地监视器
每个核心都有自己的本地监视器。所有独占访问都会检查该核心的本地监视器。对标记为“不可共享”区域的访问只需检查本地监视器,而对标记为“共享”区域的访问则还需要检查全局监视器。
本地监视器的定位与路由
- 归属:每个 CPU 核心拥有独立的 Local Monitor。
- 访问路由:
- 非共享内存(Non-shareable):仅需检查 Local Monitor。
- 共享内存(Shared):同时检查 Local Monitor 和 Global Monitor。
可缓存区域(Cacheable)的同步特性
- 无总线开销:若目标内存已在当前核心的 L1 Cache 中命中,同步操作可在 CPU 内部完成,不产生外部总线事务。
- 延迟可见(Delayed Visibility):原子操作的结果在 Cache 中生效后,其他 CPU 核心需在主动发起 Read 操作读取该内存位置时才会感知到更新。
2.2.2 全局监视器
核心作用与运作机制
- 作用:专门负责监控标记为 Shareable(共享) 的内存区域。
- 判定标准:任何针对共享内存的独占存储指令(如
STXR),必须同时通过 Local Monitor 和 Global Monitor 的联合校验,才能写入成功。 - 应用场景:用于不同 CPU 核心之间(例如 Cortex-A 多核系统),甚至异构 CPU 之间(如 Cortex-A 与 Cortex-R 共享内存)通过互斥锁(Mutex)进行同步。
硬件实现方案(如何做出 Global Monitor?)
- 带 Cache 区域(常见 CPU 内存): 硬件不需要单独建电路,而是将 CPU 的 Local Monitor 与缓存一致性协议(Cache Coherency)结合。当一个核心修改了 Cache,硬件会自动使其他核心的 Cache Line 失效,从而间接“清除”其他核心的独占标记。
- 不带 Cache / 外设区域: 硬件会在 CPU 外部(如 SoC 的 RAM 控制器或系统总线 Interconnect 上)专门嵌入独立的 Global Monitor 硬件电路。
强制支持的内存属性与注意事项
- 必须支持独占访问的内存:系统标准的 Normal Memory(如 Inner/Outer Write-Back、带读写分配提示的共享内存),硬件承诺百分之百支持。
- 警告:若对不支持独占访问的区域(如 Device 寄存器或部分未做硬件监视器的 Non-cacheable 内存)强行执行
STXR:STXR会永久返回失败(1),导致抢锁死循环。- 硬件总线可能直接抛出异常,引发程序崩溃(Data Abort)。
总结:对于软件开发者,独占监视器(Monitors)是一个“只管结果、不问过程”的硬件黑盒。我们只需保证在合规的可缓存/共享内存(Normal Shareable Memory)上使用
LDXR/STXR,具体的跨核与硬件同步全权交给 Arm 芯片自行处理即可。
2.2.3 独占访问的内存监控范围(ERG)
独占监视器(Exclusive Monitor)在跟踪内存时并不会精准到每一个字节,而是只记录物理地址的高位比特。这段被硬件“打包监控”的内存区域被称为 ERG(Exclusive Reservation Granule),其大小可通过 CTR_EL0 寄存器查询。在硬件视角下,只要两个不同的内存地址落在同一个 ERG 范围内,就会被直接视为同一个地址。
这种非精确监控会导致多核并发时的伪竞争(误清除)问题:
- 理论场景:线程 0 操作变量 A,线程 1 操作变量 B,两者在逻辑上完全独立。
- 硬件后果:若 A 与 B 恰好处于同一个 ERG 内,线程 0 成功执行
STXR时会误伤清空线程 1 的监视器标记,导致线程 1 的STXR写入失败并被迫重试。
在绝大多数 Arm 芯片中,一个 ERG 的大小通常正好等于 一条缓存行(One Cache Line,通常为 64 字节)。
2.2.4 清除 ERET 和 CLREX 上的本地监视器
操作系统在上下文切换时必须重置本地监视器(Local Monitor),否则若进程在 Load-Exclusive 后、Store-Exclusive 前被调度切出,残留的独占标记会导致恢复运行后的写入失败或逻辑异常。
硬件在不同架构下的清理机制如下:
- Armv8+(硬件自动):执行
ERET(异常/中断返回)指令时,硬件会自动重置本地监视器,无需软件介入。 - Armv7 及更早(软件手动):操作系统必须在上下文切换时手动执行
CLREX指令,显式清空独占状态以防线程间标记冲突。
2.3 独占示例
在多核编程中,独占加载(LDAXR)与独占存储(STXR)常用于实现进入临界区的锁机制。通常用 0 表示锁空闲,用 1(或进程 ID)表示锁已被占用。加锁时需要具备获取语义(Acquire),而解锁时则需要释放语义(Release)。
加锁与解锁的具体实现及汇编示例如下:
加锁操作(Lock):必须结合独占指令。使用
LDAXR读取锁状态并设置独占标记;若锁被占用或后续STXR写入失败(期间有其他 CPU 写入了该地址),则循环重试,直至独占写入成功。1
2
3
4
5
6
7; void lock (lock_t *ptr)
Loop:
MOV W2, #1 ; 准备写入的值(1 表示加锁)
LDAXR W1, [X0] ; 带获取语义(Acquire)读取锁状态并标记独占
CBNZ W1, Loop ; 若锁已被占用 (W1 != 0),重新等待
STXR W1, W2, [X0] ; 尝试独占写入 1
CBNZ W1, Loop ; 若写入失败(有竞争),重新重试解锁操作(Unlock):无需使用独占指令。因为同一时刻仅持有锁的单个线程有权释放锁,直接使用带有释放语义的单条存储指令清除锁标记即可。
1
2; void unlock (lock_t *ptr)
STLR WZR, [X0] ; 带释放语义(Release)将锁写回 0 (WZR)
3. 使用WFE
在多核锁竞争中,当线程获取锁失败时,若不断死循环重试(Busy Polling)会白白消耗大量功耗。为了提高能效,Arm 提供了 WFE(Wait For Event) 机制:当加锁失败时,让 CPU 暂时关闭时钟并进入低功耗休眠状态,直至收到“唤醒事件”后再重新尝试抢锁。
WFE 的主要唤醒事件与扩展包括:
- 核心唤醒事件(Wake-up Events):
- 显式信号:多核系统中任意其他 CPU 执行了
SEV(Send Event)指令。 - 内存写事件:其他 CPU 写入了当前监视的共享内存(清除全局监视器标记时触发唤醒)。
- 系统中断:未被屏蔽的硬件中断(Unmasked Interrupt)。
- 定时器事件:通用定时器发出的事件流(Event Stream)。
- 显式信号:多核系统中任意其他 CPU 执行了
- 带超时的休眠(
WFET指令):Armv8.7-A 引入了WFET指令,允许设置最大等待超时时间,即便没有唤醒事件,达到指定时间后 CPU 也会自动唤醒重试,避免死锁或无限等待。
3.1 使用WFE的 lock() 示例
在实际开发中,WFE 指令可与普通自旋锁或票据锁(Ticket Lock)结合使用。核心逻辑是在尝试获取锁失败时执行 WFE 进入低功耗等待,当其他 CPU 释放锁或监视器状态改变时触发唤醒,再次重试抢锁。
典型实现及代码逻辑如下:
配合 Load-Exclusive 唤醒:
Load-Exclusive指令(如LDAXR/LDAXRH)在读取锁状态时会将监视器置于独占标记状态。当其他核心修改该内存时,监视器状态改变会自动触发唤醒事件,将处于低功耗模式的 CPU 唤醒。1
2
3
4
5
6
7
8
9
10
11acquire_lock:
LDAXR W1, [X0] ; 读取锁并设置独占标记(为 WFE 做准备)
CBNZ W1, wait_loop ; 若锁已被占用 (W1 != 0),进入休眠等待循环
MOV W2, #1 ; 准备写入加锁值 1
CASA W1, W2, [X0] ; 尝试 CAS 写入新值
CBNZ W1, wait_loop ; 若写入失败(存在竞争),重试休眠循环
RET
wait_loop:
WFE ; 休眠等待事件唤醒
B acquire_lock ; 被唤醒后重新尝试抢锁票据锁(Ticket Lock)等待场景:线程获取票据后,先对比自身票据与当前服务票据(Current Ticket)。若不匹配,则在
WFE循环中低功耗等待,直到持有锁的核心释放锁并更新票据触发唤醒。
3.2 失去独占标记引发的唤醒与内存争用
在基于 MESI 缓存一致性协议的多核系统中,WFE 机制虽然能够显著降低线程等待锁时的硬件功耗,但同时也可能引发额外的内存争用(Memory Contention)。由于监视器(Monitor)的独占状态依赖缓存行(Cache Line)的状态变化,对共享缓存行的任何写操作均会触发事件并唤醒处于休眠的核心。
引发内存争用与性能开销的主要原因如下:
- 误唤醒与重复加载:当 Core 0 修改共享缓存行中的数据时,硬件会使 Core 1 对应的缓存行失效并清除其独占标记,进而触发事件唤醒 Core 1。即便锁尚未释放(属于误唤醒),被唤醒的 Core 1 也必须重新发起独占加载(
LDXR)以重新校验锁状态,产生了不必要的总线开销。 - 惊群效应(Thundering Herd):当事件流(Event Stream)触发唤醒信号时,所有处于
WFE休眠状态的 CPU 核心会被同时唤醒。这些核心在同一时刻对同一内存地址发起独占访问,会导致内存总线瞬间陷入严重拥堵。 - 隐式写引发的失效:除了显式的写指令外,硬件/软件预取(Prefetching)以及缓存维护指令(Cache Maintenance)同样会导致 Cache Line 状态变更,从而产生相同的内存争用副作用。