多線程之指令重排序

一、爲什麼會亂序

現在的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;

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

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

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

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

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

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

 

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

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

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

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

 

三、理解重排序

重排序通常是編譯器或運行時環境爲了優化程序性能而採取的對指令進行重新排序執行的一種手段。重排序分爲兩類:編譯期重排序和運行期重排序,分別對應編譯時和運行時環境。

在併發程序中,程序員會特別關注不同進程或線程之間的數據同步,特別是多個線程同時修改同一變量時,必須採取可靠的同步或其它措施保障數據被正確地修改,這裏的一條重要原則是:不要假設指令執行的順序,你無法預知不同線程之間的指令會以何種順序執行。

     但是在單線程程序中,通常我們容易假設指令是順序執行的,否則可以想象程序會發生什麼可怕的變化。理想的模型是:各種指令執行的順序是唯一且有序的,這個順序就是它們被編寫在代碼中的順序,與處理器或其它因素無關,這種模型被稱作順序一致性模型,也是基於馮·諾依曼體系的模型。當然,這種假設本身是合理的,在實踐中也鮮有異常發生,但事實上,沒有哪個現代多處理器架構會採用這種模型,因爲它是在是太低效了。而在編譯優化和CPU流水線中,幾乎都涉及到指令重排序。

1、編譯期重排序

     編譯期重排序的典型就是通過調整指令順序,在不改變程序語義的前提下,儘可能減少寄存器的讀取、存儲次數,充分複用寄存器的存儲值。

     假設第一條指令計算一個值賦給變量A並存放在寄存器中,第二條指令與A無關但需要佔用寄存器(假設它將佔用A所在的那個寄存器),第三條指令使用A的值且與第二條指令無關。那麼如果按照順序一致性模型,A在第一條指令執行過後被放入寄存器,在第二條指令執行時A不再存在,第三條指令執行時A重新被讀入寄存器,而這個過程中,A的值沒有發生變化。通常編譯器都會交換第二和第三條指令的位置,這樣第一條指令結束時A存在於寄存器中,接下來可以直接從寄存器中讀取A的值,降低了重複讀取的開銷。

2、重排序對於流水線的意義

     現代CPU幾乎都採用流水線機制加快指令的處理速度,一般來說,一條指令需要若干個CPU時鐘週期處理,而通過流水線並行執行,可以在同等的時鐘週期內執行若干條指令,具體做法簡單地說就是把指令分爲不同的執行週期,例如讀取、尋址、解析、執行等步驟,並放在不同的元件中處理,同時在執行單元EU中,功能單元被分爲不同的元件,例如加法元件、乘法元件、加載元件、存儲元件等,可以進一步實現不同的計算並行執行。

流水線架構決定了指令應該被並行執行,而不是在順序化模型中所認爲的那樣。重排序有利於充分使用流水線,進而達到超標量的效果。

3、確保順序性

     儘管指令在執行時並不一定按照我們所編寫的順序執行,但毋庸置疑的是,在單線程環境下,指令執行的最終效果應當與其在順序執行下的效果一致,否則這種優化便會失去意義。

通常無論是在編譯期還是運行期進行的指令重排序,都會滿足上面的原則。

4Java存儲模型中的重排序

     Java存儲模型(Java Memory Model, JMM)中,重排序是十分重要的一節,特別是在併發編程中。JMM通過happens-before法則保證順序執行語義,如果想要讓執行操作B的線程觀察到執行操作A的線程的結果,那麼AB就必須滿足happens-before原則,否則,JVM可以對它們進行任意排序以提高程序性能。

     volatile關鍵字可以保證變量的可見性,因爲對volatile的操作都在Main Memory中,而Main Memory是被所有線程所共享的,這裏的代價就是犧牲了性能,無法利用寄存器或Cache,因爲它們都不是全局的,無法保證可見性,可能產生髒讀。

volatile還有一個作用就是局部阻止重排序的發生,對volatile變量的操作指令都不會被重排序,因爲如果重排序,又可能產生可見性問題。

     在保證可見性方面,鎖(包括顯式鎖、對象鎖)以及對原子變量的讀寫都可以確保變量的可見性。但是實現方式略有不同,例如同步鎖保證得到鎖時從內存裏重新讀入數據刷新緩存,釋放鎖時將數據寫回內存以保數據可見,而volatile變量乾脆都是讀寫內存。

Happens-before法則

Java存儲模型有一個happens-before原則,就是如果動作B要看到動作A的執行結果(無論A/B是否在同一個線程裏面執行),那麼A/B就需要滿足happens-before關係。

在介紹happens-before法則之前介紹一個概念:JMM動作(Java MemeoryModel Action),Java存儲模型動作。一個動作(Action)包括:變量的讀寫、監視器加鎖和釋放鎖、線程的start()join()。後面還會提到鎖的的。

happens-before完整規則:

1)同一個線程中的每個Actionhappens-before於出現在其後的任何一個Action

2)對一個監視器的解鎖happens-before於每一個後續對同一個監視器的加鎖。

3)對volatile字段的寫入操作happens-before於每一個後續的同一個字段的讀操作。

4Thread.start()的調用會happens-before於啓動線程裏面的動作。

5Thread中的所有動作都happens-before於其他線程檢查到此線程結束或者Thread.join()中返回或者Thread.isAlive()==false

6)一個線程A調用另一個另一個線程Binterrupt()都happens-before於線程A發現BA中斷(B拋出異常或者A檢測到BisInterrupted()或者interrupted())。

7)一個對象構造函數的結束happens-before與該對象的finalizer的開始

8)如果A動作happens-beforeB動作,而B動作happens-beforeC動作,那麼A動作happens-beforeC動作。



 

5、首先爲何要指令重排序實例(instruction reordering)?

編譯器或運行時環境爲了優化程序性能而採取的對指令進行重新排序執行的一種手段。

也就是說,對於下面兩條語句:

int a = 10;

int b = 20;



    在計算機執行上面兩句話的時候,有可能第二條語句會先於第一條語句執行。所以,千萬不要隨意假設指令執行的順序。


6
、是不是所有的語句的執行順序都可以重排呢?

    答案是否定的。爲了講清楚這個問題,先講解另一個概念:數據依賴性

6.1、什麼是數據依賴性

     如果兩個操作訪問同一個變量,且這兩個操作中有一個爲寫操作,此時這兩個操作之間就存在數據依賴。數據依賴分下列三種類型:

名稱

代碼示例

說明

寫後讀

a = 1;b = a;

寫一個變量之後,再讀這個位置。

寫後寫

a = 1;a = 2;

寫一個變量之後,再寫這個變量。

讀後寫

a = b;b = 1;

讀一個變量之後,再寫這個變量。

     上面三種情況,只要重排序兩個操作的執行順序,程序的執行結果將會被改變。所以,編譯器和處理器在重排序時,會遵守數據依賴性,編譯器和處理器不會改變存在數據依賴關係的兩個操作的執行順序。也就是說:在單線程環境下,指令執行的最終效果應當與其在順序執行下的效果一致,否則這種優化便會失去意義。這句話有個專業術語叫做as-if-serialsemantics (as-if-serial語義)

7、重排序對多線程的影響

     現在讓我們來看看,重排序是否會改變多線程程序的執行結果。請看下面的示例代碼:

[java] view plain copy 

1.  class ReorderExample {  

2.      int a = 0;  

3.      boolean flag = false;  

4.    

5.      public void writer() {  

6.          a = 1;          // 1  

7.          flag = true;    // 2  

8.      }  

9.    

10.            public void reader() {  

11.                if (flag) {            // 3  

12.                    int i = a * a; // 4  

13.                }  

14.            }  

15.        }  


     
flag變量是個標記,用來標識變量a是否已被寫入。這裏假設有兩個線程ABA首先執行writer()方法,隨後B線程接着執行reader()方法。線程B在執行操作4時,能否看到線程A在操作1對共享變量a的寫入?

答案是:不一定能看到。

     由於操作1和操作2沒有數據依賴關係,編譯器和處理器可以對這兩個操作重排序;同樣,操作3和操作4沒有數據依賴關係,編譯器和處理器也可以對這兩個操作重排序。讓我們先來看看,當操作1和操作2重排序時,可能會產生什麼效果?請看下面的程序執行時序圖:

 

     上圖的執行順序是:2 -> 3 ->4 -> 1 (這是完全存在並且合理的一種順序,如果你不能理解,請先了解CPU是如何對多個線程進行時間分配的)

     如上圖所示,操作1和操作2做了重排序。程序執行時,線程A首先寫標記變量flag,隨後線程B讀這個變量。由於條件判斷爲真,線程B將讀取變量a。此時,變量a還根本沒有被線程A寫入,在這裏多線程程序的語義被重排序破壞了!

     下面再讓我們看看,當操作3和操作4重排序時會產生什麼效果。下面是操作3和操作4重排序後,程序的執行時序圖:


 在程序中,操作3和操作4存在控制依賴關係。當代碼中存在控制依賴性時,會影響指令序列執行的並行度。爲此,編譯器和處理器會採用猜測(Speculation)執行來克服控制相關性對並行度的影響。以處理器的猜測執行爲例,執行線程B的處理器可以提前讀取並計算a*a,然後把計算結果臨時保存到一個名爲重排序緩衝(reorder bufferROB)的硬件緩存中。當接下來操作3的條件判斷爲真時,就把該計算結果寫入變量i中。

從圖中我們可以看出,猜測執行實質上對操作34做了重排序。重排序在這裏破壞了多線程程序的語義!

在單線程程序中,對存在控制依賴的操作重排序,不會改變執行結果(這也是as-if-serial語義允許對存在控制依賴的操作做重排序的原因);但在多線程程序中,對存在控制依賴的操作重排序,可能會改變程序的執行結果。


四、爲什麼volatile禁止CPU指令的重排序  

 

class VolatileExample { 

  int x = 0; 

  volatile booleanv = false; 

 

  //in threadA 

  public voidwriter() { 

    x = 42; 

    v = true; 

  } 

 

  //in threadB 

  public voidreader() { 

    if (v == true){ 

      //uses x -can we see x is 42? 

    } 

  } 

}



    在這種情況下,當thread A 執行完後,在thread B中能看到x等於42嗎?(變量v肯定可以看到,根據可見性可以推斷出來)好吧,在舊的JMM模型下,答案是不一定。原因就是CPU指令在執行時的重排序。在舊的JMM模型下,只規定了volatile變量和volatile變量之間不能進行重排序,但是並沒有保證volatile變量和non-volatile變量之間不能進行重排序,所以,當在thread A中,指令的執行可能是:

 

Java代碼  

1.  v = true;  

2.  x = 42;  

 這樣,當thread B 看到vtrue的時候,x實際上還沒有執行,所以值不是42.

 

   慢!眼尖的同學可能看出來了,你說的這個跟重排序實際上沒有關係呀,這個應該算是變量x的可見性問題,因爲變量x不是聲明爲volatile的。

 

   好吧,我承認我偷懶了,在描述volatile變量可見性特質的時候,在新的JMM模型下,當對volatile變量進行寫的時候,該線程(這裏是thread A)所能看到變量(比如說變量x),都會一起刷新到主存中。這個也就是爲什麼我們會說對volatile變量的寫操作,實際上等價於使用了synchronized關鍵字後釋放monitor時產生的效果。在這個前提下,上面的問題的確是CPU指令重排序的問題。

 

   但是幸運的是,JMM隨後提出了happen-before原則來fix了這個問題(主要是volitale變量和non-volatile變量之間的重排序問題。)

   這裏我只挑跟這個問題相關的三條原則來進行講解,其餘的可以到官方文檔去查看。

 

   1. 單線程原則,在單線程執行的環境下,指令的執行是跟程序代碼的執行順序一致。對於上面的例子來說,在程序代碼順序上,x=42 先於 v=true那麼在內存指令執行的時候也是如此。

 

   2. volatile變量原則,對volatile變量的寫操作要優先於對volatile變量的讀操作。

   3. 轉遞性原則,如果A操作先於B操作,B操作先於C操作,那麼A操作肯定先於C操作。

 

   還是上面的例子,先用單線程原則,可以判斷出,在thread A的執行中, x=42肯定要優先於v=true進行執行,而在thread B的執行中,對v的讀取操作肯定要優先於對x的使用操作。

   接着再使用volatile變量原則,可以判斷,對v的寫肯定要先於對v的讀,最後再根據轉遞性原則,可以推出在thread Ax=42的賦值操作肯定要先於thread B中對x的使用,也就是說,當v讀取出來是爲true的時候,x肯定是42. 指令不會進行重排序。

 

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