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