啃碎併發(11):記憶體模型之重排序

猿碼道發表於2018-10-31

0 前言

在很多情況下,訪問一個程式變數(物件例項欄位,類靜態欄位和陣列元素)可能會使用不同的順序執行,而不是程式語義所指定的順序執行。具體幾種情況,如下:

  1. 編譯器 能夠自由的以優化的名義去改變指令順序;
  2. 在特定的環境下,處理器 可能會次序顛倒的執行指令;
  3. 資料可能在 暫存器、處理器緩衝區和主記憶體 中以不同的次序移動,而不是按照程式指定的順序;

例如,如果一個執行緒寫入值到欄位 a,然後寫入值到欄位 b,而且 b 的值不依賴於 a 的值,那麼,處理器就能夠自由的調整它們的執行順序,而且緩衝區能夠在 a 之前重新整理 b 的值到主記憶體。有許多潛在的重排序的來源,例如編譯器,JIT以及緩衝區

所以,從Java原始碼變成可以被機器(或虛擬機器)識別執行的程式,至少要經過編譯期和執行期。在這兩個期間,重排序分為兩類:編譯器重排序、處理器重排序(亂序執行),分別對應編譯時和執行時環境。由於重排序的存在,指令實際的執行順序,並不是原始碼中看到的順序。

1 編譯器重排序

編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序,在不改變程式語義的前提下,儘可能減少暫存器的讀取、儲存次數,充分複用暫存器的儲存值

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

另一種編譯器優化:在迴圈中讀取變數的時候,為提高存取速度,編譯器會先把變數讀取到一個暫存器中;以後再取該變數值時,就直接從暫存器中取,不會再從記憶體中取值了。這樣能夠減少不必要的訪問記憶體。但是提高效率的同時,也引入了新問題。如果別的執行緒修改了記憶體中變數的值,那麼由於暫存器中的變數值一直沒有發生改變,很有可能會導致迴圈不能結束。編譯器進行程式碼優化,會提高程式的執行效率,但是也可能導致錯誤的結果。所以程式設計師需要防止編譯器進行錯誤的優化。

2 處理器重排序

2.1 指令並行重排序

編譯器和處理器可能會對操作做重排序,但是要遵守資料依賴關係,編譯器和處理器不會改變存在資料依賴關係的兩個操作的執行順序。如果兩個操作訪問同一個變數,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在資料依賴性。資料依賴分下列三種型別:

名稱 程式碼示例 說明
寫後讀 a = 1;b = a; 寫一個變數之後,再讀這個位置。
寫後寫 a = 1;a = 2; 寫一個變數之後,再寫這個變數。
讀後寫 a = b;b = 1; 讀一個變數之後,再寫這個變數。

上面三種情況,只要重排序兩個操作的執行順序,程式的執行結果將會被改變。像這種有直接依賴關係的操作,是不會進行重排序的。特別注意:這裡說的依賴關係僅僅是在單個執行緒內

舉例:

class Demo {
    int a = 0;
    boolean flag = false;

    public void write() {
        a = 1; // 1
        flag = true; // 2
    }

    public void read() {
        if (flag) { // 3
            int i = a * a; // 4
        }
    }
}
複製程式碼

由於操作 1 和 2 沒有資料依賴關係,編譯器和處理器可以對這兩個操作重排序;操作 3 和操作 4 沒有資料依賴關係,編譯器和處理器也可以對這兩個操作重排序。

  1. 當操作 1 和操作 2 重排序時,可能會產生什麼效果?

    當操作 1 和操作 2 重排序時

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

  2. 當操作 3 和操作 4 重排序時,可能會產生什麼效果?(藉助這個重排序,可以順便說明控制依賴性)

    當操作 3 和操作 4 重排序時

    在程式中,操作 3 和操作 4 存在控制依賴關係。當程式碼中存在控制依賴性時,會影響指令序列執行的並行度。為此,編譯器和處理器會採用 猜測(Speculation)執行 來克服控制相關性對並行度的影響。以處理器的猜測執行為例:

    執行執行緒 B 的處理器可以提前讀取並計算 a * a,然後把計算結果臨時儲存到一個名為 重排序緩衝(reorder buffer ROB) 的硬體快取中。當接下來操作 3 的條件判斷為真時,就把該計算結果寫入變數 i 中。

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

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

    在多執行緒程式中,對存在控制依賴的操作重排序,可能會改變程式的執行結果。

2.2 指令亂序重排序

現在的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指令的亂序,編譯器的亂序才是真正對指令順序做了調整。但是編譯器的亂序也必須保證程式上下文的因果關係不發生改變。

由於重排序和亂序執行的存在,如果在併發程式設計中,沒有做好共享資料的同步,很容易出現各種看似詭異的問題。

相關文章