iOS彙編教程(六)CPU 指令重排與記憶體屏障

Soulghost發表於2019-10-05

系列文章

  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 分鐘後,遇到了斷言錯誤:

iOS彙編教程(六)CPU 指令重排與記憶體屏障

顯然這是由於亂序執行導致的 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 底層系列文章。

iOS彙編教程(六)CPU 指令重排與記憶體屏障

參考資料

  1. 快取一致性(Cache Coherency)入門
  2. CPU Reordering – What is actually being reordered?
  3. ARM Information Center - DMB, DSB, and ISB
  4. volatile 與記憶體屏障總結

相關文章