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

系列文章

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

前言

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

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

指令重排

简介

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

一个例子

看下面一段简单的程序:多线程

; 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]
复制代码

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

反作用

指令重排大幅度提高了 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 与内存屏障总结
相关文章
相关标签/搜索