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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
; ------------------------------------------------------------------------------
; 1. lock() - 抢锁阶段
; ------------------------------------------------------------------------------
MOV X1, #0x1 ; X1 = 1(代表加锁状态)

Spin:
LDR X0, [<lock>] ; 步骤1(优化):普通 Load 轮询,避免频发总线/缓存竞争
CBNZ X0, Spin ; 若锁已被占用 (X0 != 0),跳回 Spin 继续等待

CASA X0, X1, [<lock>] ; 步骤2(抢锁):原子 Compare-and-Swap(带 Acquire 语义)
; - 若 [<lock>] == 0:写入 1 并返回 0 到 X0(抢锁成功)
; - 若 [<lock>] != 0:保持原值并返回最新锁状态到 X0(抢锁失败)

CBNZ X0, Spin ; 若 X0 != 0 (抢锁失败),跳回 Spin 重试;否则成功进入临界区

; ------------------------------------------------------------------------------
; 2. Critical Section - 临界区
; ------------------------------------------------------------------------------
... ; CASA 的 Acquire 语义保证此处的指令不会被重排序到抢锁成功前执行

; ------------------------------------------------------------------------------
; 3. unlock() - 释放锁阶段
; ------------------------------------------------------------------------------
STLR XZR, [<lock>] ; 将 [<lock>] 清零(带 Release 语义)
; 确保临界区内的所有写入在此指令生效前全网可见

无锁堆栈示例

软件通常使用无锁方式更新共享位置,以避免使用锁带来的开销。无锁代码通常先向共享位置加载数据,然后执行一些相关的计算,最后使用比较并交换 (CAS) 原子操作完成更新。最终 CAS 操作的成功取决于加载操作中观察到的位置值是否已被其他线程更改。以下是一个更新无锁链表的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Loop:
LDAR X2, [X0] ; 读取当前链表/栈的头节点地址(带 Acquire 语义,保证后续读写不乱序)
STR X2, [X1] ; 将旧头节点地址存入新节点的 next 指针位置(即新节点指向旧头节点)
MOV X3, X2 ; 备份期望的旧头节点值到 X3(用于后续判断 CAS 操作是否成功)

; 【原子更新头指针】
; CASAL 指令(带 Acquire-Release 双重语义):
; 比较 [X0](内存中的头指针)是否仍等于 X3(刚才读到的旧头节点)
; - 若相等:说明没有其他线程修改头节点,将 [X0] 更新为 X1(新节点地址),并在 X3 中返回旧值
; - 若不相等:说明头节点已被其他线程修改,不更新内存,并在 X3 中返回被修改后的新头节点地址
CASAL X3, X1, [X0]

CMP X3, X2 ; 比较 CAS 返回的实际旧值 (X3) 与备份的期望旧值 (X2)
B.ne Loop ; 如果 X3 != X2(说明 CAS 失败,头节点被他人改动),跳回 Loop 重试

如果用的是常见的 GCC 或 Clang 编译器,可以直接使用扩展函数,对应的 c code 可能是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void push_gcc(Node* new_node) {
Node* old_head = head;

do {
new_node->next = old_head;
// __atomic_compare_exchange_n 会被编译器直接编译为 CASAL 汇编指令
} while (!__atomic_compare_exchange_n(
&head,
&old_head,
new_node,
true, // weak 模式,性能更高
__ATOMIC_ACQ_REL, // 成功时: Acquire-Release 屏障
__ATOMIC_RELAXED)); // 失败时: Relaxed
}

线程屏障示例

线程屏障提供了一个同步点,确保所有线程在继续执行之前都已到达该同步点。线程屏障在各种应用程序中的并行代码段之间很常见。典型的线程屏障实现可能包含一个计数变量,用于检测所有线程何时都已到达同步点。

1
2
3
4
5
6
7
8
9
10
11
	MOV       W1, #-1        ; 设置增量值(负数 -1,用于执行减法)
LDADDAL W1, W1, [X0] ; 原子递减计数器(带 Acquire-Release 双重屏障):
; - 将内存地址 [X0] 的旧值存入 W1
; - 将内存地址 [X0] 的新值更新为 (旧值 - 1)

wait_loop: ; 自旋等待其他线程到达屏障点
LDAR W1, [X0] ; 读取当前计数值(带 Acquire 语义,保证后续读写不被重排序)
CBNZ W1, wait_loop ; 如果计数值不为 0(说明还有其他线程未到达),继续自旋等待

continue:
; 所有线程均已到达同步点,在此处同步恢复并继续向下执行

对应的 c code 可能是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdatomic.h>

_Atomic int barrier_count = NUM_THREADS; // 初始化为线程总数

void thread_barrier(void) {
// 1. 原子减 1,并获取旧值 (对应 LDADDAL)
int old_val = atomic_fetch_sub_explicit(&barrier_count, 1, memory_order_acq_rel);

// 2. 如果不是最后一个到达的线程,自旋等待 (对应 wait_loop)
if (old_val != 1) {
// 对应 LDAR + CBNZ:不断轮询,直到计数器减到 0
while (atomic_load_explicit(&barrier_count, memory_order_acquire) != 0);
}

// 3. 所有线程在此会合后继续 (对应 continue)
}

原子位操作示例

由于多线程访问和抢占式调度,线程安全状态标志的管理需要同步。在访问共享资源(例如 GPU 核心或 DMA 通道等硬件外设)之前,线程会原子性地设置一个状态位,以表明该共享资源已被分配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
; ==============================================================================
; A64 (Arm64) 原子位操作示例:原子设置状态标志位
; 假设 X2 寄存器指向 64 位状态寄存器的内存地址
; ==============================================================================

MOV X1, #0x4 ; 设置掩码:0b100(即第 2 位)

1:
; LDSETAL 指令(带 Acquire-Release 双重语义):
; 原子地读取 [X2] 中的旧值存入 X0,并将 [X2] 的第 2 位置位(按位或运算 OR 0x4)
LDSETAL X1, X0, [X2]

; TBNZ (Test and Branch if Non-Zero) 测试并跳转:
; 检查返回的旧值 X0 的第 2 位。若为 1(说明已经被其他线程占用),跳回 1b 重试(可选逻辑)
TBNZ X0, #2, 1b

1.3 原子访问地址的内存属性

原子指令不是对任何内存都能生效的,只有在带缓存、支持一致性的普通内存上,硬件才能保证“真原子”;且硬件会根据情况选择在 CPU 内部(L1 缓存)外部总线(L3/互连线) 完成原子操作。

哪种内存才支持原子指令?

不是所有内存地址都可以随便用原子指令(如 CASLDADD):

  • 支持真原子的内存类型:
    • 开启缓存的普通内存(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 独占权。

总结:需要返回旧值(如抢锁 CAS/LDADD)或数据已在 L1D 独占时走近原子,纯写入且不依赖返回值(如全局计数 STADD)时走远原子。

1.4 Linux 内核中的应用

典型的原子操作

Linux 内核中原子指令的定义位于 /arch/arm64/ include /asm/atomic_lse.h 头文件中。例如,以下代码是 FEAT_LSE 原子指令的典型实现。它生成了各种具有不同内存顺序的原子操作函数调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#define ATOMIC_FETCH_OP(name, mb, op, asm_op, cl...)        \
static __always_inline int \
__lse_atomic_fetch_##op##name(int i, atomic_t *v) \
{ \
int old; \
\
asm volatile( \
__LSE_PREAMBLE \ // #define __LSE_PREAMBLE ".arch_extension lse \n
" " #asm_op #mb " %w[i], %w[old], %[v]" \ // Template for inline assembler
: [v] "+Q" (v->counter), \ // Output:
[old] "=r" (old) \ // Output: old value
: [i] "r" (i) \
: cl); \ // Input
\ // Clobber list
return old; \ // The address of old value
}

#define ATOMIC_FETCH_OPS(op, asm_op) \
ATOMIC_FETCH_OP(_relaxed, , op, asm_op) \ // Relaxed semantic
ATOMIC_FETCH_OP(_acquire, a, op, asm_op, "memory") \ // Acquire semantic
ATOMIC_FETCH_OP(_release, l, op, asm_op, "memory") \ // Release semantic
ATOMIC_FETCH_OP( , al, op, asm_op, "memory") // Acquire-Release semantic

ATOMIC_FETCH_OPS(andnot, ldclr) // Generate the function atomic_fetch_andnot_*()
ATOMIC_FETCH_OPS(or, ldset) // Generate the function atomic_fetch_or_*()
ATOMIC_FETCH_OPS(xor, ldeor) // Generate the function atomic_fetch_xor_*()
ATOMIC_FETCH_OPS(add, ldadd) // Generate the function atomic_fetch_add_*()

#undef ATOMIC_FETCH_OP
#undef ATOMIC_FETCH_OPS

展开宏 ATOMIC_FETCH_OPS( add , ldadd) 后,将生成 4 个函数,其中一个如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
static __always_inline int
__lse_atomic_fetch_add_acquire(int i, atomic_t *v) {
int old;
asm volatile(
__LSE_PREAMBLE
"ldadda %w[i], %w[old], %[v]"
: [v] "+Q" (v->counter), [old] "=r" (old)
: [i] "r" (i)
: "memory"
);
return old;
}

这些函数可用于自旋锁/互斥锁、引用计数、无锁列表和读-复制-更新。

Linux 内核中的自旋锁

以下的 hyp_spin_lock 函数位于 arch /arm64/kvm/hyp/ include /nvhe/spinlock.h 中。它实现了基于 LDADD 原子指令优化的基于票据的自旋锁。该代码专为底层同步而设计,例如在虚拟机管理程序或内核环境中,当多个 CPU 或虚拟机访问共享数据结构时。

当请求使用票据锁时,票据锁决定了临界区用户的访问顺序。票据锁会在每个线程首次请求锁时分配一个票据号,然后将该票据号与当前锁的票据号进行比较。如果两者相同,则可以进入临界区。否则,线程将等待,直到当前票据号等于该线程的票据号。

读取当前锁号需要acquire语义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
static inline void hyp_spin_lock(hyp_spinlock_t *lock) {
u32 tmp;
hyp_spinlock_t lockval, newval;

asm volatile(
/* --- 1. 原子领号 --- */
" mov %w2, #(1 << 16) \n" // 准备增量值 65536(代表高 16 位的“分配票号” +1)
" ldadda %w2, %w0, %3 \n" // LSE 原子加(带 Acquire 语义):将 lock 高 16 位加 1,并把旧的票号存入 %w0
// 此时 lock->next += 1, %w0 高16位就是 my_ticket(next没加的值)

/* --- 2. 检查:我的票号 == 当前服务号? --- */
" eor %w1, %w0, %w0, ror #16 \n" // 比较高 16 位(我的票号)与低 16 位(当前服务号),也就是比较 next 没加前 和 own
" cbz %w1, 3f \n" // 若相等(异或为 0),说明抢锁成功,跳到 3f 进入临界区

/* --- 3. 未抢到锁:低功耗等待 --- */
" sevl \n" // 发送本地事件(防止错过解锁)
"2: wfe \n" // CPU 进入低功耗休眠(Wait For Event),等待唤醒
" ldaxrh %w2, %4 \n" // 被唤醒后,重新读取最新的当前服务号
" eor %w1, %w2, %w0, lsr #16 \n" // 再次比对
" cbnz %w1, 2b \n" // 仍未轮到自己,继续休眠

/* --- 4. 成功拿到锁 --- */
"3:"
: "=&r" (lockval), "=&r" (newval), "=&r" (tmp), "+Q" (*lock)
: "Q" (lock->owner)
: "memory"
);
}

等效 c code :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef struct {
uint16_t owner; // 当前服务号(当前叫到几号)
uint16_t next; // 下一个分配的票号(发到几号了)
} spinlock_t;

void hyp_spin_lock(spinlock_t *lock) {
// 1. 原子领号 (相当于汇编里的 ldadda)
// 领到属于自己的票号,同时把排号机(next)加 1
uint16_t my_ticket = __atomic_fetch_add(&lock->next, 1, __ATOMIC_ACQUIRE);

// 2. 检查:我的票号 == 当前服务号?(相当于汇编里的 eor + cbz)
// 如果轮到我了,直接跳过循环进入临界区
while (my_ticket != lock->owner) {

// 3. 没轮到我:进入低功耗等待 (相当于汇编里的 sevl + wfe + ldaxrh)
// 挂起 CPU 放弃轮询,等待 unlock 叫号唤醒
wait_for_event();
}

// 4. 拿到锁,进入临界区...
}

解除票锁只需将当前票号加一,然后使用store release功能即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static inline void hyp_spin_unlock(hyp_spinlock_t *lock) {
u64 tmp;

asm volatile(
/* --- 叫号:给低 16 位(当前服务号)+1 --- */
" mov %w1, #1 \n" // 准备增量 1
" staddlh %w1, %0 \n" // 远原子加法(带 Release 语义):
// 叫下一个号,并自动唤醒休眠的 CPU

: "=Q" (lock->owner), "=&r" (tmp)
:
: "memory"
);
}

等效 c code :

1
2
3
4
5
6
void hyp_spin_unlock(spinlock_t *lock) {
// 叫号 (相当于汇编里的 staddlh)
// 给当前服务号(owner)加 1,并带 Release 屏障,通知排队的 CPU
// 在硬件层会自动发送事件唤醒处于 wait_for_event() 的 CPU
__atomic_fetch_add(&lock->owner, 1, __ATOMIC_RELEASE);
}

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
2
3
4
5
6
atomic_inc:
LDXR W1, [W0] // 1. 独占读取当前内存值,并加独占标记
ADD W1, W1, #1 // 2. 将读取到的旧值 +1
STXR W2, W1, [X0] // 3. 尝试写回内存:W2=0 成功,W2!=0 失败
CBNZ W2, atomic_inc // 4. 若 W2 不为 0(写入失败),跳转重试
RET // 5. 成功完成原子自增,返回
  • 原子性保障原理:如果在这两步之间有任何其他线程/核心对该内存区域进行了写入操作,硬件会自动清除独占标记,导致 STXR 写入失败并触发重试,从而保证了“读-修改-写”的原子性。
  • 硬件支持:每个 CPU 核心内部由 独占监视器(Exclusive Monitor) 来维护与跟踪这一独占标记状态。

结合 Acquire/Release 内存语义的扩展指令

独占访问指令还支持隐式的内存屏障语义,确保多核共享数据时的可见性与顺序性:

  • **LDAXR (Load-Acquire Exclusive)**:带 Acquire(读获取) 语义。确保该加载指令之后的所有内存访问不会被重排序到它之前(常用于抢锁/获取资源)。
  • **STLXR (Store-Release Exclusive)**:带 Release(写释放) 语义。确保该存储指令之前的所有内存访问不会被重排序到它之后(常用于解锁/释放资源)。

2.2 独占监视器

独占监视器是一个简单的状态机,只有两种状态:打开和独占。为了支持处理器之间的同步,系统必须实现两组监视器:本地监视器和全局监视器。

1

2.2.1 本地监视器

每个核心都有自己的本地监视器。所有独占访问都会检查该核心的本地监视器。对标记为“不可共享”区域的访问只需检查本地监视器,而对标记为“共享”区域的访问则还需要检查全局监视器。

本地监视器的定位与路由

  • 归属:每个 CPU 核心拥有独立的 Local Monitor
  • 访问路由:
    • 非共享内存(Non-shareable):仅需检查 Local Monitor
    • 共享内存(Shared):同时检查 Local MonitorGlobal 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
    1. STXR永久返回失败(1),导致抢锁死循环。
    2. 硬件总线可能直接抛出异常,引发程序崩溃(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)。
  • 带超时的休眠(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
    11
    acquire_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 状态变更,从而产生相同的内存争用副作用。