內存屏障
首先需要明確的是,程序在運行起來,內存訪問的順序和程序員編寫的順序不一定一致,編譯器和CPU都可能對代碼優化導致亂序執行。
編譯器亂序
編譯器會做非常多的優化,指令重排序是其中一種,如下例
int a, b; void foo(void) { a = b + 1; b = 0; }
執行編譯命令
gcc -c -S test.c
編譯器生成的結果如下:
movl b(%rip), %eax addl $1, %eax movl %eax, a(%rip) movl $0, b(%rip)
先對a賦值,再對b賦值,和C源碼順序一致。
如果加上-O2,結果如下
movl b(%rip), %eax movl $0, b(%rip) addl $1, %eax movl %eax, a(%rip)
實際執行就變成了先對b賦值,再對a賦值,這就是compiler reordering(編譯器重排)。為什么可以這么做呢?對於單線程來說,a 和 b 的寫入順序,compiler認為沒有任何問題。並且最終的結果也是正確的(a == 1 && b == 0),因此編譯器才會這么做。
再看一個例子,我們擁有2個線程,一個用來更新數據,另一個讀數據,讀線程等待flag被置位,然后返回讀取的數據data
// global variables int flag, data; // thread1 void write_data(int value) { data = value; flag = 1; } // thread2 void read_data(void) {while (flag == 0);return data; }
如果compiler產生的匯編代碼是 flag 比 data先寫入內存,read_data()發現flag已經置1,錯誤認為data的數據已經准別就緒。
這是因為compiler是不知道data和flag之間有嚴格的依賴關系。為了解決上述變量之間存在依賴關系導致compiler錯誤優化,compiler為我們提供了編譯器屏障(compiler barriers),可用來告訴compiler不要reorder。我們繼續使用上面的foo()函數作為演示實驗,在代碼之間插入compiler barriers:
#define barrier() __asm__ __volatile__("": : :"memory") int a, b; void foo(void) { a = b + 1; barrier(); b = 0; }
此時,生成的匯編如下
movl b(%rip), %eax addl $1, %eax movl %eax, a(%rip) movl $0, b(%rip)
barrier()就是compiler提供的屏障,作用是告訴compiler內存中的值已經改變,之前對內存的緩存(緩存到寄存器)都需要拋棄,barrier()之后的內存操作需要重新從內存load,而不能使用之前寄存器緩存的值。並且可以防止compiler優化barrier()前后的內存訪問順序。barrier()就像是代碼中的一道不可逾越的屏障,barrier前的 load/store 操作不能跑到barrier后面;同樣,barrier后面的 load/store 操作不能在barrier之前。
除了顯式調用barrier()外,還有別的方法阻止compiler reordering。實際上,大多數的函數調用都表現出compiler barriers的作用,例如
int a, b; void foo(void) { a = b + 1; printf("hello\n"); b = 0; }
加上-O2編譯的匯編結果
movl b(%rip), %eax movl $.LC0, %edi addl $1, %eax movl %eax, a(%rip) call puts movl $0, b(%rip)
compiler不能假設printf()不會使用或者修改 a 變量。因此在調用printf()之前會將 a 賦值,以保證printf()可能會用到新值。
注意,inline函數不具有compiler barriers的作用,當我們需要考慮compiler barriers時,一定要顯示的插入barrier(),而不是依靠函數調用附加的隱式compiler barriers。因為,誰也無法保證調用的函數不會被compiler優化成inline方式。
再看一個例子
#include <stdlib.h> #include <stdio.h> #include <pthread.h> #include <unistd.h> int run = 1; void* foo(void* data) { while (run) ; } void* bar(void* data) { sleep(1); run = 0; } void test1() { printf("test1 ready\n"); pthread_t pt[2]; pthread_create(&pt[0], NULL, foo, NULL); pthread_create(&pt[1], NULL, bar, NULL); pthread_join(pt[0], NULL); pthread_join(pt[1], NULL); printf("test1 done\n"); } int main() { test1(); }
如果不使用編譯優化選項,運行是ok的,如果加上 -O2,foo() 函數會進入死循環,compiler首先將run加載到一個寄存器reg中,即使即使其他線程修改run的值為0,它也不會重新加載。
加上barrier()可以解決這個問題
#define barrier() __asm__ __volatile__("": : :"memory") int run = 1; void* foo(void* data) { while (run) barrier(); }
當然,通過volatile也可以解決
volatile int run = 1; void* foo(void* data) { while (run) ; }
CPU亂序
現在的CPU一般采用流水線(pipeline) 來執行指令 ,一條指令的執行被分成:取指(Fetch)、譯碼(Decode)、訪存、執行(Execute)、寫回(Write-back)、等若干個階段。
指令流水線並不是串行的,多條指令可以同時存在於流水線中,同時被執行。並不會因為一個耗時很長的指令在“執行”階段呆很長時間,而導致后續的指令都卡在“執行”之前的階段上。
比如說CPU有一個加法器和一個除法器,那么一條加法指令和一條除法指令就可能同時處於“執行”階段,而兩條加法指令在“執行”階段就只能串行工作。然而,這樣一來,亂序可能就產生了。比如一條加法指令原本出現在一條除法指令的后面,但是由於除法的執行時間很長,在它執行完之前,加法可能先執行完了。再比如兩條訪存指令,可能由於第二條指令命中了cache而導致它先於第一條指令完成。
一般情況下,指令亂序並不是CPU在執行指令之前刻意去調整順序。CPU總是順序的去內存里面取指令,然后將其順序的放入指令流水線。但是指令執行時的各種條件,指令與指令之間的相互影響,可能導致順序放入流水線的指令,最終亂序執行完成。這就是所謂的“順序流入,亂序流出”。
另外,CPU的亂序執行並不是任意的亂序,比如:
a++; b=a+1; c--;
由於b=a+1依賴於前一條指令a++的執行結果,所以b=a+1將在“執行”階段之前被阻塞,直到a++的執行結果被生成出來;而c--跟前面沒有依賴,它可能在b=a+1之前就能執行完。
像這樣有依賴關系的指令如果挨得很近,后一條指令必定會因為等待前一條執行的結果,而在流水線中阻塞很久,占用流水線的資源。
而編譯器的亂序,作為編譯優化的一種手段,則試圖通過指令重排將這樣的兩條指令拉開距離, 以至於后一條指令進入CPU的時候,前一條指令結果已經得到了,那么也就不再需要阻塞等待了。比如將指令重排為:
a++; c--; b=a+1;
分支預測
先看個例子,用256的模數隨機填充一個固定大小的大數組,然后統計該數組中值不小於128的元素個數,循環1w次
void test2(int sort) { const unsigned arraySize = 32768; int* data = malloc(sizeof(int) * arraySize); for (unsigned c = 0; c < arraySize; ++c) data[c] = rand() % 256; if (sort) qsort(data, arraySize, sizeof(int), comp); /* for (unsigned c = 0; c < arraySize; ++c) { printf("%d\t", data[c]); } printf("\n"); */ // loop begin clock_t start = clock(); long long sum = 0; for (unsigned i = 0; i < 10000; ++i) { for (unsigned c = 0; c < arraySize; ++c) { if (data[c] >= 128) sum ++; } } double elapsedTime = (double)(clock() - start) / CLOCKS_PER_SEC; // loop end free(data); printf("sort=%d, elapsedTime=%.3f seconds\n", sort, elapsedTime); } test2(0); test2(1);
運行結果:
sort=0, elapsedTime=2.434 seconds
sort=1, elapsedTime=0.680 seconds
我們比較數組在有序和無序的耗時對比,可以發現對有序數組的執行時間要比無序數組快4倍,從循環本身來看,遍歷次數應該是一樣的,這是為什么呢?
在CPU遇到 if (data[c] >= 128) sum ++; 這條命令時,並不能決定該如何往下走,該如何做?只能暫停運行,等待之前的指令運行結束。然后才能繼續沿着正確地路徑往下走。
要知道,現代編譯器是非常復雜的,運行時有着非常長的pipelines, 減速和熱啟動將耗費巨量的時間。那么,有沒有好的辦法可以節省這些狀態切換的時間呢?你可以猜測分支的下一步走向!
-
如果猜錯了,處理器要flush掉pipelines, 回滾到之前的分支,然后重新熱啟動,選擇另一條路徑。
-
如果猜對了,處理器不需要暫停,繼續往下執行。
如果每次都猜錯了,處理器將耗費大量時間在停止-回滾-熱啟動這一周期性過程里。如果僥幸每次都猜對了,那么處理器將從不暫停,一直運行至結束。
上述過程就是分支預測(branch prediction),那么處理器該采用怎樣的策略來用最小的次數來盡量猜對指令分支的下一步走向呢?答案就是分析歷史運行記錄,這樣的持續朝同一個方向切換的迭代對分支預測器來說是非常友好的。
亂序執行
從前面的 CPU緩存 看,每個CPU都會有自己私有L1 Cache,硬件工程師為了追求極致的性能,在CPU和L1 Cache之間又加入一級緩存,我們稱之為store buffer。store buffer和L1 Cache還有點區別,store buffer只緩存CPU的寫操作。store buffer訪問一般只需要1個指令周期,這在一定程度上降低了內存寫延遲。不管cache是否命中,CPU都是將數據寫入store buffer,store buffer大小一般只有幾十個字節。store buffer負責后續以FIFO次序寫入L1 Cache(TSO模型)。
也看個例子
static int x = 0, y = 0; static int r1, r2; static void int thread_cpu0(void){ x = 1; /* 1) */ r1 = y; /* 2) */ } static void int thread_cpu1(void){ y = 1; /* 3) */ r2 = x; /* 4) */ } static void check_after_assign(void){ printk("r1 = %d, r2 = %d\n", r1, r2); }
假設thread_cpu0在CPU0上執行,thread_cpu1在CPU1上執行。在多核系統上,我們知道兩個函數4條操作執行可以互相交錯。
我們就以1) 3) 2) 4)的執行次序說明問題。當CPU0執行x = 1
時,x的值會被寫入CPU0的store buffer。CPU1指令y = 1
操作,同樣y的值會被寫入CPU1的store buffer。接下來,r1 = y
執行時,CPU0讀取y的值,由於y的新值依然在CPU1的store buffer里面,所以CPU0看到y的值依然是0。所以r1的值是0。為什么CPU0看到r1的值是0呢?因為硬件MESI協議只會保證Cache一致性,只要值沒有被寫入Cache(依然躺在store buffer里面),MESI就管不着。同樣的道理,r2的值也會是0。此時我們看到了一個意外的結果。
這里有個注意點,雖然store buffer主要是用來緩存CPU的寫操作,但是CPU讀取數據時也會檢查私有store buffer是否命中,如果沒有命中才會查找L1 Cache。這主要是為了CPU自己看到自己寫進store buffer的值。所以CPU0是可以看到x值更新,但是CPU1不能及時看到x。所以說“單核亂序對程序員是透明的,只有其他核才會受到亂序影響”。
我們應該如何去理解這樣的結果呢?我們先簡單了解下一致性問題。一致性分為兩種:
- cache一致性,關注的是多個CPU看到一個地址的數據是否一致,比如通過MESI協議,主要由硬件保證;
- 內存一致性,關注的是多個CPU看到多個地址數據讀寫的次序。
回到上面的例子,如果以 2) 4) 1) 3) 的執行次序,在其他CPU看來,CPU0似乎是1)和2)指令互換,CPU1似乎是3)和4)指令互換,我們針對寫操作稱之為store,讀操作稱之為load。所以我們可以這么理解,CPU0的store-load操作,在別的CPU看來亂序執行了,變成了load-store次序。
store和load的組合有4種:store-store,store-load,load-load和load-store。
內存模型
SC模型(sequential consistency,順序一致性)
CPU會按照程序中順序依次執行store和load指令。
TSO 模型(Total Store Order,完全存儲定序)
在TSO模型中,我們說過store buffer會按照FIFO的順序將數據寫入L1 Cache。常見的PC處理器x86-64屬於TSO模型。
TSO模型允許store-load亂序;
Linux內核中提供了smp_mb()
宏對不同架構的指令進行封裝,smp_mb()
的作用是阻止它后面的讀寫操作不會亂序到宏前面的指令前執行。
如何fix以上的例子呢?我們只需要簡單的將
barrier()
替換成smp_mb()
操作即可
static void int thread_cpu0(void){
x = 1; /* 1) */ smp_mb(); r1 = y; /* 2) */ } static void int thread_cpu1(void){ y = 1; /* 3) */ smp_mb(); r2 = x; /* 4) */ }
現在的代碼我們可以這么理解,r1 = y
不會比x = 1
先執行。同樣r2 = x
不會在y = 1
前執行。
PSO 模型(Part Store Order,部分存儲定序)
對PSO模型此,store buffer不再以FIFO的次序寫入Cache。
PSO模型允許store-load、store-store亂序;
inux內核中提供了smp_wmb()
宏對不同架構的指令進行封裝,smp_wmb()
的作用是阻止它后面的寫操作不會亂序到宏前面的寫操作指令前執行。它就像是個屏障,寫操作不容逾越。
回到之前的一個例子:
// global variables int flag, data; // thread1 void write_data_cpu0(int value) { data = value; flag = 1; } // thread2 void read_data_cpu1(void) { while (flag == 0); return data; }
由於現在store buffer不以FIFO的次序更新Cache,所以可能導致CPU1讀取到data的舊值0,而flag新值true。
如果我們需要上述的示例代碼在PSO模型的處理器上正確運行(按照我們期望的結果運行),就需要做出如下修改
void write_data_cpu0(int value) { data = value; smp_wmb(); flag = 1; }
smp_wmb()
可以保證CPU1看到flag的值為1時,data的值一定是最新的值。
RMO 模型(Relaxed Memory Order)
RMO模型對4種操作都可以亂序。
inux內核中提供了smp_rmb()
宏對不同架構的指令進行封裝,smp_rmb()保證屏障后的讀操作不會亂序到屏障前的讀操作,只針對讀操作,不影響寫操作。
在RMO模型下,上述例子可以改成
// global variables int flag, data; // thread1 void write_data_cpu0(int value) { data = value; smp_wmb(); flag = 1; } // thread2 void read_data_cpu1(void) { while (flag == 0); smp_rmb(); return data; }
參考:
https://zhuanlan.zhihu.com/p/102370222
https://zhuanlan.zhihu.com/p/45808885