爲什麼要指令重排序和屏障的作用

內存屏障是一個很神奇的東西,之前翻譯了Linux內核文檔memory-barriers.txt,對內存屏障有了一定有理解。現在用自己的方式來整理一下。
在我看來,內存屏障主要解決了兩個問題:單處理器下的亂序問題和多處理器下的內存同步問題。

爲什麼會亂序
現在的CPU一般採用流水線來執行指令。一個指令的執行被分成:取指、譯碼、訪存、執行、寫回、等若干個階段。然後,多條指令可以同時存在於流水線中,同時被執行。
指令流水線並不是串行的,並不會因爲一個耗時很長的指令在“執行”階段呆很長時間,而導致後續的指令都卡在“執行”之前的階段上。
相反,流水線是並行的,多個指令可以同時處於同一個階段,只要CPU內部相應的處理部件未被佔滿即可。比如說CPU有一個加法器和一個除法器,那麼一條加法指令和一條除法指令就可能同時處於“執行”階段, 而兩條加法指令在“執行”階段就只能串行工作。
相比於串行+阻塞的方式,流水線像這樣並行的工作,效率是非常高的。

然而,這樣一來,亂序可能就產生了。比如一條加法指令原本出現在一條除法指令的後面,但是由於除法的執行時間很長,在它執行完之前,加法可能先執行完了。再比如兩條訪存指令,可能由於第二條指令命中了cache而導致它先於第一條指令完成。
一般情況下,指令亂序並不是CPU在執行指令之前刻意去調整順序。CPU總是順序的去內存裏面取指令,然後將其順序的放入指令流水線。但是指令執行時的各種條件,指令與指令之間的相互影響,可能導致順序放入流水線的指令,最終亂序執行完成。這就是所謂的“順序流入,亂序流出”。

指令流水線除了在資源不足的情況下會卡住之外(如前所述的一個加法器應付兩條加法指令的情況),指令之間的相關性也是導致流水線阻塞的重要原因。
CPU的亂序執行並不是任意的亂序,而是以保證程序上下文因果關係爲前提的。有了這個前提,CPU執行的正確性纔有保證。比如:

a++; b=f(a); c--;

由於b=f(a)這條指令依賴於前一條指令a++的執行結果,所以b=f(a)將在“執行”階段之前被阻塞,直到a++的執行結果被生成出來;而c--跟前面沒有依賴,它可能在b=f(a)之前就能執行完。(注意,這裏的f(a)並不代表一個以a爲參數的函數調用,而是代表以a爲操作數的指令。C語言的函數調用是需要若干條指令才能實現的,情況要更復雜些。)

像這樣有依賴關係的指令如果捱得很近,後一條指令必定會因爲等待前一條執行的結果,而在流水線中阻塞很久,佔用流水線的資源。而編譯器的亂序,作爲編譯優化的一種手段,則試圖通過指令重排將這樣的兩條指令拉開距離, 以至於後一條指令進入CPU的時候,前一條指令結果已經得到了,那麼也就不再需要阻塞等待了。比如將指令重排爲:

a++; c--; b=f(a);

相比於CPU的亂序,編譯器的亂序纔是真正對指令順序做了調整。但是編譯器的亂序也必須保證程序上下文的因果關係不發生改變。

亂序的後果
亂序執行,有了“保證上下文因果關係”這一前提,一般情況下是不會有問題的。因此,在絕大多數情況下,我們寫程序都不會去考慮亂序所帶來的影響。
但是,有些程序邏輯,單純從上下文是看不出它們的因果關係的。比如:

*addr=5; val=*data;

從表面上看,addr和data是沒有什麼聯繫的,完全可以放心的去亂序執行。但是如果這是在某某設備驅動程序中,這兩個變量卻可能對應到設備的地址端口和數據端口。並且,這個設備規定了,當你需要讀寫設備上的某個寄存器時,先將寄存器編號設置到地址端口,然後就可以通過對數據端口的讀寫而操作到對應的寄存器。那麼這麼一來,對前面那兩條指令的亂序執行就可能造成錯誤。
對於這樣的邏輯,我們姑且將其稱作隱式的因果關係;而指令與指令之間直接的輸入輸出依賴,也姑且稱作顯式的因果關係。CPU或者編譯器的亂序是以保持顯式的因果關係不變爲前提的,但是它們都無法識別隱式的因果關係。再舉個例子:

obj->data = xxx; obj->ready = 1;

當設置了data之後,記下標誌,然後在另一個線程中可能執行:

if (obj->ready) do_something(obj->data);

雖然這個代碼看上去有些彆扭,但是似乎沒錯。不過,考慮到亂序,如果標誌被置位先於data被設置,那麼結果很可能就杯具了。因爲從字面上看,前面的那兩條指令其實並不存在顯式的因果關係,亂序是有可能發生的。

總的來說,如果程序具有顯式的因果關係的話,亂序一定會尊重這些關係;否則,亂序就可能打破程序原有的邏輯。這時候,就需要使用屏障來抑制亂序,以維持程序所期望的邏輯。

屏障的作用
內存屏障主要有:讀屏障、寫屏障、通用屏障、優化屏障、幾種。
以讀屏障爲例,它用於保證讀操作有序。屏障之前的讀操作一定會先於屏障之後的讀操作完成,寫操作不受影響,同屬於屏障的某一側的讀操作也不受影響。類似的,寫屏障用於限制寫操作。而通用屏障則對讀寫操作都有作用。而優化屏障則用於限制編譯器的指令重排,不區分讀寫。前三種屏障都隱含了優化屏障的功能。比如:

tmp = ttt; *addr = 5; mb(); val = *data;

有了內存屏障就了確保先設置地址端口,再讀數據端口。而至於設置地址端口與tmp的賦值孰先孰後,屏障則不做干預。

有了內存屏障,就可以在隱式因果關係的場景中,保證因果關係邏輯正確。

多處理器情況
前面只是考慮了單處理器指令亂序的問題,而在多處理器下,除了每個處理器要獨自面對上面討論的問題之外,當處理器之間存在交互的時候,同樣要面對亂序的問題。
一個處理器(記爲a)對內存的寫操作並不是直接就在內存上生效的,而是要先經過自身的cache。另一個處理器(記爲b)如果要讀取相應內存上的新值,先得等a的cache同步到內存,然後b的cache再從內存同步這個新值。而如果需要同步的值不止一個的話,就會存在順序問題。再舉前面的一個例子:
  <CPU-a>              <CPU-b>
  obj->data = xxx;
  wmb();               if (obj->ready)
  obj->ready = 1;          do_something(obj->data);

前面也說過,必須要使用屏障來保證CPU-a不發生亂序,從而使得ready標記置位的時候,data一定是有效的。但是在多處理器情況下,這還不夠。data和ready標記的新值可能以相反的順序更新到CPU-b上!
其實這種情況在大多數體系結構下並不會發生,不過內核文檔memory-barriers.txt舉了alpha機器的例子。alpha機器可能使用分列的cache結構,每個cache列可以並行工作,以提升效率。而每個cache列上面緩存的數據是互斥的(如果不互斥就還得解決cache列之間的一致性),於是就可能引發cache更新不同步的問題。
假設cache被分成兩列,而CPU-a和CPU-b上的data和ready都分別被緩存在不同的cache列上。
首先是CPU-a更新了cache之後,會發送消息讓其他CPU的cache來同步新的值,對於data和ready的更新消息是需要按順序發出的。如果cache只有一列,那麼指令執行的順序就決定了操作cache的順序,也就決定了cache更新消息發出的順序。但是現在假設了有兩個cache列,可能由於緩存data的cache列比較繁忙而使得data的更新消息晚於ready發出,那麼程序邏輯就沒法保證了。不過好在SMP下的內存屏障在解決指令亂序問題之外,也將cache更新消息亂序的問題解決了。只要使用了屏障,就能保證屏障之前的cache更新消息先於屏障之後的消息被髮出。
然後就是CPU-b的問題。在使用了屏障之後,CPU-a已經保證data的更新消息先發出了,那麼CPU-b也會先收到data的更新消息。不過同樣,CPU-b上緩存data的cache列可能比較繁忙,導致對data的更新晚於對ready的更新。這裏同樣會出問題。
所以,在這種情況下,CPU-b也得使用屏障。CPU-a上要使用寫屏障,保證兩個寫操作不亂序,並且相應的兩個cache更新消息不亂序。CPU-b上則需要使用讀屏障,保證對兩個cache單元的同步不亂序。可見,SMP下的內存屏障一定是需要配對使用的。
所以,上面的例子應該改寫成:
  <CPU-a>              <CPU-b>
  obj->data = xxx;     if (obj->ready)
  wmb();                   rmb();
  obj->ready = 1;          do_something(obj->data);

CPU-b上使用的讀屏障還有一種弱化版本,它不保證讀操作的有序性,叫做數據依賴屏障。顧名思義,它是在具有數據依賴情況下使用的屏障,因爲有數據依賴(也就是之前所說的顯式的因果關係),所以CPU和編譯器已經能夠保證指令的順序。
再舉個例子:

  <CPU-a>              <CPU-b>
  init(newval);        p = data;
  <write barrier>      <data dependency barrier>
  data = &newval;      val = *p;

這裏的屏障就可以保證:如果data指向了newval,那麼newval一定是初始化過的。

 

誤區
在SMP環境下,內存屏障保證的是“一個CPU的多個操作的順序”(被另一個CPU所觀察到的順序),而不保證“兩個CPU的操作順序”。
舉例來說,有如下事件序列:
CPU-0:a = 5; CPU-0:wmb(); CPU-1:rmb(); CPU-1: i = a;

假設從時間順序上看,CPU-0對內存a的寫操作“a = 5”發生於CPU-1的讀操作“ i = a”之前,並且中間使用了內存屏障,那麼在CPU-1上,i一定等於5麼?
未必!因爲內存屏障並不保證“兩個CPU的操作順序”。爲什麼會是這樣呢?
一方面,這樣的保證沒有必要。兩個CPU上執行的操作本身是沒有關聯的,程序沒有要求應該誰先誰後。有可能“a = 5”先執行,也有可能“i = a”先執行,這都符合程序邏輯。只是現在這個case恰好“a = 5”先執行而已。
另一方面,兩個CPU的操作孰先孰後,是無法通過外部時間來度量的。也就是說,“a = 5”先於“i = a”這件事情不能以它們發生的先後順序來度量。假設,CPU-0執行了“a = 5”,一個CPU主頻週期之後,CPU-1要執行“i = a”。這時候CPU-1如何知道“a = 5”這件事情已經發生了呢?它若想知道,唯一的辦法只能跟其他CPU同步一下緩存,但是緩存同步的時間顯然遠遠大於一個CPU主頻週期。同步完成之後呢?且不說緩存同步導致CPU性能變差。的確,現在CPU-1可以知道現在“a = 5”已經發生了,但是“a = 5”到底是發生在同步發起之前還是同步過程中呢?依然沒法知道。除非CPU在修改自己的cache的時候給每個內存單元打一個時間戳,並且時間戳層層傳遞到內存,並且記錄下來。(記錄時間戳花費的空間可能比元數據還大!)
更進一步,即便有時間戳,假設CPU-0執行“a = 5”、CPU-1執行“a = 3”,這兩個操作發生在同一個主頻週期,如何度量誰先誰後呢?從時間順序上顯然是沒法度量的,因爲兩個操作是同時發生的,沒有先後順序。但是又非得度量其先後順序不可,最後a到底等於幾總該有個結論吧。度量的標準只能是誰先搶到總線、把a的新值從cache更新到內存,誰就是先者。
所以度量內存操作的先後順序看的是誰先同步到內存(這一步是串行的,不可能同時發生),而不是看操作發生的時間順序。可能會這樣,CPU-0後執行操作,但是由於種種原因先搶到了總線而先把a更新到內存,那麼它就是先者。
那麼,CPU在看到內存屏障指令之後,是不是應該立馬flush cache,使得內存同步的順序跟時間順序更爲趨近呢?CPU也許可以這麼做。但是其實意義並不大,無論如何內存同步順序永遠不可能與時間順序完全一致,畢竟CPU是並行工作的,而內存同步是串行的。並且flush cache的開銷是巨大的,因爲內存屏障的作用範圍不是某次內存操作,而是屏障前的所有內存操作,所以要flush只能flush所有的cache。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章