啃碎併發(11):內存模型之重排序

0 前言

在不少狀況下,訪問一個程序變量(對象實例字段,類靜態字段和數組元素)可能會使用不一樣的順序執行,而不是程序語義所指定的順序執行。具體幾種狀況,以下:java

  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 沒有數據依賴關係,編譯器和處理器也能夠對這兩個操做重排序。spa

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

因爲重排序和亂序執行的存在,若是在併發編程中,沒有作好共享數據的同步,很容易出現各類看似詭異的問題。

相關文章
相關標籤/搜索