iOS汇编教程(七)ARM Exclusive - 互斥锁与读写一致性的底层实现原理

5,765 阅读8分钟

系列文章

  1. iOS汇编入门教程(一)ARM64汇编基础
  2. iOS汇编入门教程(二)在Xcode工程中嵌入汇编代码
  3. iOS汇编入门教程(三)汇编中的 Section 与数据存取
  4. iOS汇编教程(四)基于 LLDB 动态调试快速分析系统函数的实现
  5. iOS汇编教程(五)Objc Block 的内存布局和汇编表示
  6. iOS汇编教程(六)CPU 指令重排与内存屏障

简介

在多线程编程中,我们常常使用互斥锁来保证全局变量的线程安全,例如 pthread 中的 pthread_mutex,mach 中的 semaphore。他们通过 lock & unlock 或是 up & down 的方式来维护资源的状态,保证只有特定个数的线程能获得特定个数的资源。

那么单单从软件层面能否真正的实现互斥锁呢?答案是否定的,因为无论如何,程序的互斥状态都需要存储在内存中,在多线程操作互斥状态时,是无法保证互斥状态的线程安全的。也就是说,必须通过硬件支持,同时在处理器指令集层面提供原语,才能实现真正的互斥,也就是题目中提到的 Exclusive。

本文将介绍 ARM 指令集中与 Exclusive 相关的指令,通过学习这些指令,你不仅能够理解锁的本质和实现原理,还能掌握在汇编层面保证读写一致性的方法。

基本概念

Acquire and Release

上一篇文章 中,我们介绍了用于限制 CPU 乱序执行的内存屏障指令。类似的,Acquire 和 Release 也属于内存屏障,也是为了防止乱序执行带来逻辑错误。

Read-Acquire

Acquire 用于修饰内存读取指令,一条 read-acquire 的读指令会禁止它后面的内存操作指令被提前执行,即后续内存操作指令重排时无法向上越过屏障,下图[1]直观的描述了这一功能:

Write-Release

Release 用于修饰内存写指令,一条 write-release 的写指令会禁止它上面的内存操作指令被滞后到写指令完成后才执行,即写指令之前的内存操作指令重排时无法向下越过屏障,下图[2]直观描述了这一功能:

Exclusive Monitor

工作过程

为了在硬件层面支持读写互斥,就需要判断一个地址是否已被其他处理器或核心修改,在 ARM 处理器中包含了被称为 Exclusive Monitor 的状态机来维护内存的互斥状态,从而保证读写一致性[2]。

状态机的起始状态为 Open,对于某地址的 Load-Exclusive 读操作会将读取的地址标记为 Exclusive 状态;在对同一地址进行 Store-Exclusive 写操作会先检查 Monitor 是否处于 Exclusive 状态,若处于该状态则将内容写入,并将状态置为 Open,如果在写入前发现 Monitor 已经处于 Open 状态,说明有其他处理器或核心已经写入内容,本次写入失败。

简言之,Load-Exclusive 读指令将读取的地址标记为 Exclusive,Store-Exclusive 执行只能写入状态为 Exclusive 的地址,并在成功后将地址重新标记为 Open,通过这种方式即可保证多读单写,即保证了读取效率,又防止了多写带来的一致性问题。

硬件实现

对于多核的体系结构,ARM 将 Exclusive Monitor 分为 Local 和 Global 两种。

Local Monitor

如果内存地址被标记为 Nonshareable,则它的可见性被局限在处理器内,对于这类内存的互斥状态只需要维护在处理其内部的 Local Monitor 中。

Local Monitor 只在处理器内维护了状态,由于不涉及多多处理器的状态共享,不需要对真正的内存进行标记,因此它的硬件既可以通过对内存地址进行标记实现,也可以通过追踪指令的执行实现。这也要求不进行内存共享的代码在使用 Local Monitor 编程时不能以 Local Monitor 会对地址进行检查为前提[2]。

Global Monitor

对于多处理器并发编程,可以通过在被标记为 Shareable 的内存单元中定义一个 mutex 信号量实现,为了保证 mutex 的多读单写,需要借助于所有处理器共享的硬件结构 Global Monitor,他会记录特定处理器对共享内存的 Exclusive 状态,从而保证多处理器并发时的多读单写。

Compare and Swap

Compare and Swap 简称为 CAS,是无锁编程中最常用的方式,它在修改某个共享的值 a 时,首先读取 a 的值,拷贝两份,分别存储为 pre_anew_a,将 new_a 的值进行修改,在将 new_a 写回到内存之前,先检查内存中的 a 是否等于 pre_a,若等于则说明 a 的值未被他人修改,此时可以将 new_a 写入内存,否则说明当前读到的 a 已经不是最新的,写入失败。

显然,通过 CAS 和自旋锁搭配即可实现无锁的互斥写,但是 CAS 中的关键步骤 Compare & Swap 必须具有原子性,否则可能 Compare 时发现值未变化,但在 Compare 和 Swap 的间隙中有他人修改了值,从而导致多写。

Exclusive 指令

上述基本概念中我们介绍了三种概念,分别是 Acquire and Release, Exclusive MonitorCompare and Swap,在汇编层面,他们都有特定的指令支持。

LDXR & STXR

LDXR 即 LDR 的 Exclusive 版本,它的用法与 LDR 完全一致,区别在于它含有 Load-Exclusive 语义,即将读取的内存单元状态置为 Exclusive。

STXR 即 STR 的 Exclusive 版本,由于需要是否 Store 成功,他相比于 STR 多了一个 32 位寄存器的参数用于接收执行结果,用法为:

STXR  Ws, Xt, [Xn|SP{,#0}]

即尝试将 Xt 写入 [Xn|SP{,#0}],如果写入成功则将 0 写入 Ws,否则将非 0 写入,它常常和 CBZ 指令搭配,如果写入失败则跳回到 LDXR,重新执行一遍 LDXR & STXR 操作,直至成功。

下面的例子给出了使用 LDXR & STXR 实现原子加一的过程:

; extern int atom_add(int *val);
_atom_add:
mov x9, x0 ; 备份 x0,为了失败时恢复
ldxr w0, [x9] ; 从val所在的内存中读取一个 int,并标记 Exclusive
add w0, w0, #1 
stxr w8, w0, [x9] ; 尝试写回 val 位置,写入结果保存在 w8
cbz w8, atom_add_done ; 如果 w8 为 0 说明成功,跳到程序结束
mov x0, x9 ; 恢复备份的 x0,重新执行 atom_add
b _atom_add
atom_add_done:
ret

同样的例子存在于 libkern 提供的 OSAtomicAdd32 函数:

;int32_t OSAtomicAdd32(int32_t __theAmount, volatile int32_t *__theValue);
ldxr    w8, [x1]
add     w8, w8, w0
stxr    w9, w8, [x1]
cbnz    w9, _OSAtomicAdd32
mov     x0, x8
ret     lr

除了 Exclusive 语义外,LDXR & STXR 还有其 Acquire-Release 语义的 LDAXR & STLXR 版本,用于保证执行顺序。对于单纯的 Atomic Add 操作,前者已经足够;如果涉及到类似于 上一篇文章 提到的读写等待操作,则需要通过后者强保证不被乱序执行干扰。

CAS

ARM 提供了多条指令直接完成 Compare and Swap 操作,其中 CAS 是最基础的版本,它的参数如下[4]:

CAS Xs, Xt, [Xn|SP{,#0}] ; 64-bit, no memory ordering

image.png

image.png

这里的用法有点绕,Xs 要比较的值,Xt 是要更新的目标值,需要注意的是在 CAS 执行执行之后,无论成功还是失败 Xs 永远指向 CAS 尝试写之前内存中的值,因此我们通过比较 Xs 在执行前后有没有变化即可知道 CAS 有没有写入成功,因为如果 Xs 变了说明在 CAS 尝试写入前有其他代码对内存做了修改,CAS 写入是失败的。(感谢 @杨津 大佬指正)。

注意:为了在 iOS 系统上编译包含 CAS 指令的内容,需要给 .s 文件添加一个 Compile Flag: -march=armv8.1-a[5]。

同样的,CAS 也有其含有 Acquire-Release 语义的版本,分别是含有 Acquire 语义的 CASA, 含有 Release 语义的 CASL,和同时包含 Acquire-Release 两种语义的 CASAL。

实验代码

大家如果想亲自实践和验证这些指令,可以复用下面给出的实验代码,本文上述代码大部分出自这些代码。

main.m 中的代码,可新建一个 iOS Project 并在 main.m 中添加这些代码:

// main.m
#include <pthread.h>

#define N 100

extern int atom_add(int *val);
extern int cas_add(int *val);
int as[10000] = {0};
int flags[10000] = {0};
int counter = 0;

void* pthread_add(void *arg1) {
    int idx = *(int *)arg1;
    // in this way will break the assert
//    as[idx] += 1;
    cas_add(as + idx);
    __asm__ __volatile__("dmb sy");
    atom_add(flags + idx);
    return NULL;
}

void* pthread_end(void *arg1) {
    int idx = *(int *)arg1;
    while (flags[idx] != N);
    assert(as[idx] == N);
    printf("a = %d\n", as[idx]);
    return NULL;
}

void test(int idx) {
    printf("begin test %d\n", idx);
    int n = N;
    pthread_t threads[n + 1];
    for (NSInteger i = 0; i < n; i++) {
        int *copyIdx = calloc(1, 4);
        *copyIdx = idx;
        pthread_create(threads + i, NULL, &pthread_add, (void *)copyIdx);
    }
    for (NSInteger i = 0; i < n; i++) {
        pthread_detach(threads[i]);
    }
    pthread_create(threads + n, NULL, (void *)pthread_end, (void *)(&idx));
    pthread_detach(threads[n]);
}

int main(int argc, char * argv[]) {
    printf("atom_add at %p\n", atom_add);
    int round = 0;
    while (true) {
        test(round++);
    }
    
    // omit codes...
}

参考资料

  1. Preshing on Programming. Acquire and Release Semantics
  2. ARM Info Center. Exclusive monitors
  3. Stack Overflow. ARM64: LDXR/STXR vs LDAXR/STLXR
  4. ARM Info Center. CASA, CASAL, CAS, CASL, CASAL, CAS, CASL
  5. GCC, the GNU Compiler Collection .AArch64 Options