日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當前位置: 首頁 > 编程资源 > 编程问答 >内容正文

编程问答

编译乱序(Compiler Reordering)

發布時間:2025/4/5 编程问答 24 豆豆
生活随笔 收集整理的這篇文章主要介紹了 编译乱序(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語言代碼:

  • int a, b;
  • ?
  • void foo(void)
  • {
  • a = b + 1;
  • b = 0;
  • }
  • ?

    使用aarch64-linux-gnu-gcc在不優化代碼的情況下編譯上述代碼,使用objdump工具查看foo()反匯編結果:

  • <foo>:
  • ...
  • ldr w0, [x0] // load b to w0
  • add w1, w0, #0x1
  • ...
  • str w1, [x0] // a = b + 1
  • ...
  • str wzr, [x0] // b = 0
  • 我們應該知道Linux默認編譯優化選項是-O2,因此我們采用-O2優化選項編譯上述代碼,并反匯編得到如下匯編結果:

  • <foo>:
  • ...
  • ldr w2, [x0] // load b to w2
  • str wzr, [x0] // b = 0
  • add w0, w2, #0x1
  • str w0, [x1] // a = b + 1
  • ...
  • ?

    比較優化和不優化的結果,我們可以發現。在不優化的情況下,a 和 b 的寫入內存順序符合代碼順序(program order)。但是-O2優化后,a 和 b 的寫入順序和program order是相反的。-O2優化后的代碼轉換成C語言可以看作如下形式:

  • int a, b;
  • ?
  • void foo(void)
  • {
  • register int reg = b;
  • ?
  • b = 0;
  • a = reg + 1;
  • }
  • 這就是compiler reordering(編譯器重排)。為什么可以這么做呢?對于單線程來說,a 和 b 的寫入順序,compiler認為沒有任何問題。并且最終的結果也是正確的(a == 1 && b == 0)。

    這種compiler reordering在大部分情況下是沒有問題的。但是在某些情況下可能會引入問題。例如我們使用一個全局變量flag標記共享數據data是否就緒。由于compiler reordering,可能會引入問題。考慮下面的代碼(無鎖編程):

  • int flag, data;
  • ?
  • void write_data(int value)
  • {
  • data = value;
  • flag = 1;
  • }
  • ?

    如果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。

  • #define barrier() __asm__ __volatile__("": : :"memory")
  • ?
  • int a, b;
  • ?
  • void foo(void)
  • {
  • a = b + 1;
  • barrier();
  • b = 0;
  • }
  • barrier()就是compiler提供的屏障,作用是告訴compiler內存中的值已經改變,之前對內存的緩存(緩存到寄存器)都需要拋棄,barrier()之后的內存操作需要重新從內存load,而不能使用之前寄存器緩存的值。并且可以防止compiler優化barrier()前后的內存訪問順序。barrier()就像是代碼中的一道不可逾越的屏障,barrier前的 load/store 操作不能跑到barrier后面;同樣,barrier后面的 load/store 操作不能在barrier之前。依然使用-O2優化選項編譯上述代碼,反匯編得到如下結果:

  • <foo>:
  • ...
  • ldr w2, [x0] // load b to w2
  • add w2, w2, #0x1
  • str w2, [x1] // a = a + 1
  • str wzr, [x0] // b = 0
  • ...
  • 我們可以看到插入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。考慮下面的代碼:

  • int a, b, c;
  • ?
  • void fun(void)
  • {
  • c = 2;
  • barrier();
  • }
  • ?
  • void foo(void)
  • {
  • a = b + 1;
  • fun(); /* fun() call act as compiler barriers */
  • b = 0;
  • }
  • ?

    fun()函數包含barrier(),因此foo()函數中fun()調用也表現出compiler barriers的作用。同樣可以保證 a 和 b 的寫入順序。如果fun()函數不包含barrier(),結果又會怎么樣呢?實際上,大多數的函數調用都表現出compiler barriers的作用。但是,這不包含inline的函數。因此,fun()如果被inline進foo(),那么fun()就不會具有compiler barriers的作用。如果被調用的函數是一個外部函數,其副作用會比compiler barriers還要強。因為compiler不知道函數的副作用是什么。它必須忘記它對內存所作的任何假設,即使這些假設對該函數可能是可見的。我么看一下下面的代碼片段,printf()一定是一個外部的函數。

  • int a, b;
  • ?
  • void foo(void)
  • {
  • a = 5;
  • printf("smcdef");
  • b = a;
  • }
  • ?

    同樣使用-O2優化選項編譯代碼,objdump反匯編得到如下結果。

  • <foo>:
  • ...
  • mov w2, #0x5 // #5
  • str w2, [x19] // a = 5
  • bl 640 <__printf_chk@plt> // printf()
  • ldr w1, [x19] // reload a to w1
  • ...
  • str w1, [x0] // b = a
  • 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之外,還有什么妙用嗎?我們考慮下面的代碼片段。

  • int run = 1;
  • ?
  • void foo(void)
  • {
  • while (run)
  • ;
  • }
  • run是個全局變量,foo()在一個進程中執行,一直循環。我們期望的結果時foo()一直等到其他進程修改run的值為0才推出循環。實際compiler編譯的代碼和我們會達到我們預期的結果嗎?我們看一下匯編代碼。

  • 0000000000000748 <foo>:
  • 748: 90000080 adrp x0, 10000
  • 74c: f947e800 ldr x0, [x0, #4048]
  • 750: b9400000 ldr w0, [x0] // load run to w0
  • 754: d503201f nop
  • 758: 35000000 cbnz w0, 758 <foo+0x10> // if (w0) while (1);
  • 75c: d65f03c0 ret
  • 匯編代碼可以轉換成如下的C語言形式。

  • int run = 1;
  • ?
  • void foo(void)
  • {
  • register int reg = run;
  • ?
  • if (reg)
  • while (1)
  • ;
  • }
  • compiler首先將run加載到一個寄存器reg中,然后判斷reg是否滿足循環條件,如果滿足就一直循環。但是循環過程中,寄存器reg的值并沒有變化。因此,即使其他進程修改run的值為0,也不能使foo()退出循環。很明顯,這不是我們想要的結果。我們繼續看一下加入barrier()后的結果。

  • 0000000000000748 <foo>:
  • 748: 90000080 adrp x0, 10000
  • 74c: f947e800 ldr x0, [x0, #4048]
  • 750: b9400001 ldr w1, [x0] // load run to w0
  • 754: 34000061 cbz w1, 760 <foo+0x18>
  • 758: b9400001 ldr w1, [x0] // load run to w0
  • 75c: 35ffffe1 cbnz w1, 758 <foo+0x10> // if (w0) goto 758
  • 760: d65f03c0 ret
  • 我們可以看到加入barrier()后的結果真是我們想要的。每一次循環都會從內存中重新load run的值。因此,當有其他進程修改run的值為0的時候,foo()可以正常退出循環。為什么加入barrier()后的匯編代碼就是正確的呢?因為barrier()作用是告訴compiler內存中的值已經變化,后面的操作都需要重新從內存load,而不能使用寄存器緩存的值。因此,這里的run變量會從內存重新load,然后判斷循環條件。這樣,其他進程修改run變量,foo()就可以看得見了。

    在Linux kernel中,提供了cpu_relax()函數,該函數在ARM64平臺定義如下:

  • static inline void cpu_relax(void)
  • {
  • asm volatile("yield" ::: "memory");
  • }
  • ?

    我們可以看出,cpu_relax()是在barrier()的基礎上又插入一條匯編指令yield。在kernel中,我們經常會看到一些類似上面舉例的while循環,循環條件是個全局變量。為了避免上述所說問題,我們就會在循環中插入cpu_relax()調用。

    ?

  • int run = 1;
  • ?
  • void foo(void)
  • {
  • while (run)
  • cpu_relax();
  • }
  • ?

    當然也可以使用Linux 提供的READ_ONCE()。例如,下面的修改也同樣可以達到我們預期的效果。

  • int run = 1;
  • ?
  • void foo(void)
  • {
  • while (READ_ONCE(run)) /* similar to while (*(volatile int *)&run) */
  • ;
  • }
  • 當然你也可以修改run的定義為volatile int run,就會得到如下代碼。同樣可以達到預期目的。

    ?

  • volatile int run = 1;
  • ?
  • void foo(void)
  • {
  • while (run)
  • ;
  • }
  • ?

    關于volatile更多使用建議可以參考這里。

    總結

    以上是生活随笔為你收集整理的编译乱序(Compiler Reordering)的全部內容,希望文章能夠幫你解決所遇到的問題。

    如果覺得生活随笔網站內容還不錯,歡迎將生活随笔推薦給好友。