编译乱序(Compiler Reordering)
作者:smcdef?發布于:2019-1-23 22:59 分類:內核同步機制
?
編譯器(compiler)的工作就是優化我們的代碼以提高性能。這包括在不改變程序行為的情況下重新排列指令。因為compiler不知道什么樣的代碼需要線程安全(thread-safe),所以compiler假設我們的代碼都是單線程執行(single-threaded),并且進行指令重排優化并保證是單線程安全的。因此,當你不需要compiler重新排序指令的時候,你需要顯式告訴compiler,我不需要重排。否則,它可不會聽你的。本篇文章中,我們一起探究compiler關于指令重排的優化規則。
注:測試使用aarch64-linux-gnu-gcc版本:7.3.0
編譯器指令重排(Compiler Instruction Reordering)
compiler的主要工作就是將對人們可讀的源碼轉化成機器語言,機器語言就是對CPU可讀的代碼。因此,compiler可以在背后做些不為人知的事情。我們考慮下面的C語言代碼:
?
使用aarch64-linux-gnu-gcc在不優化代碼的情況下編譯上述代碼,使用objdump工具查看foo()反匯編結果:
我們應該知道Linux默認編譯優化選項是-O2,因此我們采用-O2優化選項編譯上述代碼,并反匯編得到如下匯編結果:
?
比較優化和不優化的結果,我們可以發現。在不優化的情況下,a 和 b 的寫入內存順序符合代碼順序(program order)。但是-O2優化后,a 和 b 的寫入順序和program order是相反的。-O2優化后的代碼轉換成C語言可以看作如下形式:
這就是compiler reordering(編譯器重排)。為什么可以這么做呢?對于單線程來說,a 和 b 的寫入順序,compiler認為沒有任何問題。并且最終的結果也是正確的(a == 1 && b == 0)。
這種compiler reordering在大部分情況下是沒有問題的。但是在某些情況下可能會引入問題。例如我們使用一個全局變量flag標記共享數據data是否就緒。由于compiler reordering,可能會引入問題。考慮下面的代碼(無鎖編程):
?
如果compiler產生的匯編代碼是flag比data先寫入內存。那么,即使是單核系統上,我們也會有問題。在flag置1之后,data寫45之前,系統發生搶占。另一個進程發現flag已經置1,認為data的數據已經準別就緒。但是實際上讀取data的值并不是45。為什么compiler還會這么操作呢?因為,compiler是不知道data和flag之間有嚴格的依賴關系。這種邏輯關系是我們人為強加的。我們如何避免這種優化呢?
顯式編譯器屏障(Explicit Compiler Barriers)
為了解決上述變量之間存在依賴關系導致compiler錯誤優化。compiler為我們提供了編譯器屏障(compiler barriers),可用來告訴compiler不要reorder。我們繼續使用上面的foo()函數作為演示實驗,在代碼之間插入compiler barriers。
barrier()就是compiler提供的屏障,作用是告訴compiler內存中的值已經改變,之前對內存的緩存(緩存到寄存器)都需要拋棄,barrier()之后的內存操作需要重新從內存load,而不能使用之前寄存器緩存的值。并且可以防止compiler優化barrier()前后的內存訪問順序。barrier()就像是代碼中的一道不可逾越的屏障,barrier前的 load/store 操作不能跑到barrier后面;同樣,barrier后面的 load/store 操作不能在barrier之前。依然使用-O2優化選項編譯上述代碼,反匯編得到如下結果:
我們可以看到插入compiler barriers之后,a 和 b 的寫入順序和program order一致。因此,當我們的代碼中需要嚴格的內存順序,就需要考慮compiler barriers。
隱式編譯器屏障(Implied Compiler Barriers)
除了顯示的插入compiler barriers之外,還有別的方法阻止compiler reordering。例如CPU barriers 指令,同樣會阻止compiler reordering。后續我們再考慮CPU barriers。
除此以外,當某個函數內部包含compiler barriers時,該函數也會充當compiler barriers的作用。即使這個函數被inline,也是這樣。例如上面插入barrier()的foo()函數,當其他函數調用foo()時,foo()就相當于compiler barriers。考慮下面的代碼:
?
fun()函數包含barrier(),因此foo()函數中fun()調用也表現出compiler barriers的作用。同樣可以保證 a 和 b 的寫入順序。如果fun()函數不包含barrier(),結果又會怎么樣呢?實際上,大多數的函數調用都表現出compiler barriers的作用。但是,這不包含inline的函數。因此,fun()如果被inline進foo(),那么fun()就不會具有compiler barriers的作用。如果被調用的函數是一個外部函數,其副作用會比compiler barriers還要強。因為compiler不知道函數的副作用是什么。它必須忘記它對內存所作的任何假設,即使這些假設對該函數可能是可見的。我么看一下下面的代碼片段,printf()一定是一個外部的函數。
?
同樣使用-O2優化選項編譯代碼,objdump反匯編得到如下結果。
compiler不能假設printf()不會使用或者修改 a 變量。因此在調用printf()之前會將 a 寫5,以保證printf()可能會用到新值。在printf()調用之后,重新從內存中load a 的值,然后賦值給變量 b。重新load a 的原因是compiler也不知道printf()會不會修改 a 的值。
因此,我們可以看到即使存在compiler reordering,但是還是有很多限制。當我們需要考慮compiler barriers時,一定要顯示的插入barrier(),而不是依靠函數調用附加的隱式compiler barriers。因為,誰也無法保證調用的函數不會被compiler優化成inline方式。
barrier()除了防止編譯亂序,還沒能做什么
barriers()作用除了防止compiler reordering之外,還有什么妙用嗎?我們考慮下面的代碼片段。
run是個全局變量,foo()在一個進程中執行,一直循環。我們期望的結果時foo()一直等到其他進程修改run的值為0才推出循環。實際compiler編譯的代碼和我們會達到我們預期的結果嗎?我們看一下匯編代碼。
匯編代碼可以轉換成如下的C語言形式。
compiler首先將run加載到一個寄存器reg中,然后判斷reg是否滿足循環條件,如果滿足就一直循環。但是循環過程中,寄存器reg的值并沒有變化。因此,即使其他進程修改run的值為0,也不能使foo()退出循環。很明顯,這不是我們想要的結果。我們繼續看一下加入barrier()后的結果。
我們可以看到加入barrier()后的結果真是我們想要的。每一次循環都會從內存中重新load run的值。因此,當有其他進程修改run的值為0的時候,foo()可以正常退出循環。為什么加入barrier()后的匯編代碼就是正確的呢?因為barrier()作用是告訴compiler內存中的值已經變化,后面的操作都需要重新從內存load,而不能使用寄存器緩存的值。因此,這里的run變量會從內存重新load,然后判斷循環條件。這樣,其他進程修改run變量,foo()就可以看得見了。
在Linux kernel中,提供了cpu_relax()函數,該函數在ARM64平臺定義如下:
?
我們可以看出,cpu_relax()是在barrier()的基礎上又插入一條匯編指令yield。在kernel中,我們經常會看到一些類似上面舉例的while循環,循環條件是個全局變量。為了避免上述所說問題,我們就會在循環中插入cpu_relax()調用。
?
?
當然也可以使用Linux 提供的READ_ONCE()。例如,下面的修改也同樣可以達到我們預期的效果。
當然你也可以修改run的定義為volatile int run,就會得到如下代碼。同樣可以達到預期目的。
?
?
關于volatile更多使用建議可以參考這里。
總結
以上是生活随笔為你收集整理的编译乱序(Compiler Reordering)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Linux 内核同步(二):自旋锁(Sp
- 下一篇: 浅谈栈和栈帧(一)