iOS汇编教程(六)CPU 指令重排与内存屏障

3,756 阅读8分钟

系列文章

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

前言

具有 ARM 体系结构的机器拥有相对较弱的内存模型,这类 CPU 在读写指令重排序方面具有相当大的自由度,为了保证特定的执行顺序来获得确定结果,开发者需要在代码中插入合适的内存屏障,以防止指令重排序影响代码逻辑[1]。

本文会介绍 CPU 指令重排的意义和副作用,并通过一个实验验证指令重排对代码逻辑的影响,随后介绍基于内存屏障的解决方案,以及在 iOS 开发中有关指令重排的注意事项。

指令重排

简介

以 ARM 为体系结构的 CPU 在执行指令时,在遇到写操作时,如果未获得缓存段的独占权限,需要基于缓存一致性协议与其他核协商,等待直到获得独占权限时才能完成这条指令的执行;再或者在执行乘法指令时遇到乘法器繁忙的情况,也需要等待。在这些情况下,为了提升程序的执行速度,CPU 会优先执行一些没有前序依赖的指令。

一个例子

看下面一段简单的程序:

; void acc(int *counter, int *flag);
_acc:
ldr x8, [x0]
add x8, x8, #1
str x8, [x0]
ldr x9, [x1]
mov x9, #1
str x9, [x1]
ret

这段代码将 counter 的值 +1,并将 flag 置为 1,按照正常的代码逻辑,CPU 先从内存中读取 counter (x0) 的值累加后回写,随后读取 flag (x1) 的值置位后回写。

但是如果 x0 所在的内存未命中缓存,会带来缓存载入的等待,再或者回写时无法获取到缓存段的独占权,为了保证多核的缓存一致性,也需要等待;此时如果 x1 对应的内存有缓存段,则可以优先执行 ldr x9, [x1],同时由于对 x9 的操作和对 x1 所在内存的操作不依赖于对 x8 和 x0 所在内存的操作,后续指令也可以优先执行,因此 CPU 乱序执行的顺序可能变成如下这样:

ldr x9, [x1]
mov x9, #1
str x9, [x1]
ldr x8, [x0]
add x8, x8, #1
str x8, [x0]

甚至如果写操作都需要等待,还可能将写操作都滞后:

ldr x9, [x1]
mov x9, #1
ldr x8, [x0]
add x8, x8, #1
str x9, [x1]
str x8, [x0]

再或者如果加法器繁忙,又会带来全新的执行顺序,当然这一切都要建立在被重新排序的指令之间不能相互他们依赖执行的结果。

副作用

指令重排大幅度提升了 CPU 的执行速度,但凡事都有两面性,虽然在 CPU 层面重排的指令能保证运算的正确性,但在逻辑层面却可能带来错误。比如常见的自旋锁场景,我们可能设置一个 bool 类型的 flag 来自旋等待某异步任务的完成,在这种情况下,一般是在任务结束时对 flag 置位,如果置位 flag 的语句被重排到异步任务语句的中间,将会带来逻辑错误。下面我们会通过一个实验来直观展示指令重排带来的副作用。

一个实验

在下面的代码中我们设置了两个线程,一个执行运算,并在运算结束后置位 flag,另一个线程自旋等待 flag 置位后读取结果。

我们首先定义一个保存运算结果的结构体。

typedef struct FlagsCalculate {
    int a;
    int b;
    int c;
    int d;
    int e;
    int f;
    int g;
} FlagsCalculate;

为了更快的复现重排带来的错误,我们使用了多个 flag 位,存储在结构体的 e, f, g 三个成员变量中,同时 a, b, c, d 作为运算结果的存储变量:

int getCalculated(FlagsCalculate *ctx) {
    while (ctx->e == 0 || ctx->f == 0 || ctx->g == 0);
    return ctx->a + ctx->b + ctx->c + ctx->d;
}

为了更快的触发未命中缓存,我们使用了多个全局变量;为了模拟加法器和乘法器繁忙,我们采用了密集的运算:

int mulA = 15;
int mulB = 35;
int divC = 2;
int addD = 20;

void calculate(FlagsCalculate *ctx) {
    ctx->a = (20 * mulA - mulB) / divC;
    ctx->b = 30 + addD;
    for (NSInteger i = 0; i < 10000; i++) {
        ctx->a += i * mulA - mulB;
        ctx->a *= divC;
        ctx->b += i * mulB / mulA - mulB;
        ctx->b /= divC;
    }
    ctx->c = mulA + mulB * divC + 120;
    ctx->d = addD + mulA + mulB + 5;
    ctx->e = 1;
    ctx->f = 1;
    ctx->g = 1;
}

接下来我们将他们封装在 pthread 线程的执行函数内:

void* getValueThread(void *arg) {
    pthread_setname_np("getValueThread");
    FlagsCalculate *ctx = (FlagsCalculate *)arg;
    int val = getCalculated(ctx);
    assert(val == -276387);
    return NULL;
}

void* calValueThread(void *arg) {
    pthread_setname_np("calValueThread");
    FlagsCalculate *ctx = (FlagsCalculate *)arg;
    calculate(ctx);
    return NULL;
}

void newTest() {
    FlagsCalculate *ctx = (FlagsCalculate *)calloc(1, sizeof(struct FlagsCalculate));
    pthread_t get_t, cal_t;
    pthread_create(&get_t, NULL, &getValueThread, (void *)ctx);
    pthread_create(&cal_t, NULL, &calValueThread, (void *)ctx);
    pthread_detach(get_t);
    pthread_detach(cal_t);
}

每次调用 newTest 即开始一轮新的实验,在 flag 置位未被乱序执行的情况下,最终的运算结果是 -276387,通过短时间内不断并发执行实验,观察是否遇到断言即可判断是否由重排引发了逻辑异常:

while (YES) {
    newTest();
}

笔者在一个 iOS Empty Project 中添加上述代码,并将其运行在一台 iPhone XS Max 上,约 10 分钟后,遇到了断言错误:

显然这是由于乱序执行导致的 flag 全部被提前置位,从而导致异步线程获取到的执行结果错误,通过实验我们验证了上面的理论。

答疑解惑

看到这里你可能惊出一身冷汗,开始回忆起自己职业生涯中写过的类似逻辑,也许线上有很多正在运行,但从来没出过问题,这又是为什么呢?

在 iOS 开发中,我们常使用 GCD 作为多线程开发的框架,这类 High Level 的多线程模型本身已经提供好了天然的内存屏障来保证指令的执行顺序,因此可以大胆的去写上述逻辑而不用在意指令重排,这也是我们使用 pthread 来进行上述实验的原因。

到这里你也应该意识到,如果采用 Low Level 的多线程模型来进行开发时,一定要注意指令重排带来的副作用,下面我们将介绍如何通过内存屏障来避免指令重排对逻辑的影响。

内存屏障

简介

内存屏障是一条指令,它能够明确地保证屏障之前的所有内存操作均已完成(可见)后,才执行屏障后的操作,但是它不会影响其他指令(非内存操作指令)的执行顺序[3]。

因此我们只要在 flag 置位前放置内存屏障,即可保证运算结果全部写入内存后才置位 flag,进而也就保证了逻辑的正确性。

放置内存屏障

我们可以通过内联汇编的形式插入一个内存屏障:

void calculate(FlagsCalculate *ctx) {
    ctx->a = (20 * mulA - mulB) / divC;
    ctx->b = 30 + addD;
    for (NSInteger i = 0; i < 10000; i++) {
        ctx->a += i * mulA - mulB;
        ctx->a *= divC;
        ctx->b += i * mulB / mulA - mulB;
        ctx->b /= divC;
    }
    ctx->c = mulA + mulB * divC + 120;
    ctx->d = addD + mulA + mulB + 5;
    __asm__ __volatile__("dmb sy");
    ctx->e = 1;
    ctx->f = 1;
    ctx->g = 1;
}

随后继续刚才的试验可以发现,断言不会再触发异常,内存屏障限制了 CPU 乱序执行对正常逻辑的影响。

volatile 与内存屏障

我们常常听说 volatile 是一个内存屏障,那么它的屏障作用是否与上述 DMB 指令一致呢,我们可以试着用 volatile 修饰 3 个 flag,再做一次实验:

typedef struct FlagsCalculate {
    int a;
    int b;
    int c;
    int d;
    volatile int e;
    volatile int f;
    volatile int g;
} FlagsCalculate;

结果最后触发了断言异常,这是为何呢?因为 volatile 在 C 环境下仅仅是编译层面的内存屏障,仅能保证编译器不优化和重排被 volatile 修饰的内容,但是在 Java 环境下 volatile 具有 CPU 层面的内存屏障作用[4]。不同环境表现不同,这也是 volatile 让我们如此费解的原因。

在 C 环境下,volatile 常常用来保证内联汇编不被编译优化和改变位置,例如我们通过内联汇编放置一个编译层面的内存屏障时,通过 __volatile__ 修饰汇编代码块来保证内存屏障的位置不被编译器改变:

__asm__ __volatile__("" ::: "memory");

总结

到这里,相信你对指令重排和内存屏障有了更加清晰的认识,同时对 volatile 的作用也更加明确了,希望本文能对大家有所帮助,欢迎大家关注我的公众号,公众号将同步更新 iOS 底层系列文章。

参考资料

  1. 缓存一致性(Cache Coherency)入门
  2. CPU Reordering – What is actually being reordered?
  3. ARM Information Center - DMB, DSB, and ISB
  4. volatile 与内存屏障总结