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 與內存屏障總結
相關文章
相關標籤/搜索