併發程式設計的起源
硬體裝置發展的核心矛盾:CPU、記憶體、I/O裝置三者間存在的速度差異。根據木桶原理,程式整體效能最終受制於速度最慢的I/O裝置。
為了平和三者速度差異,計算機體系結構、作業系統、編譯程式都做出了貢獻,主要體現為:
- CPU增加了快取,以均衡與記憶體的速度差異;
- 作業系統增加了程式、執行緒,以分時複用CPU,進而均衡CPU與I/O裝置的速度差異;
- 編譯程式優化指令執行順序,使得快取能夠得到更加合理地利用。
編髮程式設計出現問題的源頭
一:快取導致的可見性問題
單核時代,所有執行緒在同一CPU上雲析,CPU快取與記憶體的資料一致性容易解決。如下圖,執行緒A與B操作同一個CPU裡的快取,故A修改過變數V後,B再訪問變數V,得到的一定是最新值,即A修改過的值。
一個執行緒對共享變數的修改,另一個執行緒可以立即看到,稱之為 可見性 。
多核時代,每個CPU都有各自的快取,當多個執行緒在不同的CPU上執行時,這些執行緒操作的是不同的CPU快取,如下圖所示,執行緒A所修改的CPU-1快取中的變數V,這個操作對執行緒B則不具有可見性。
二:執行緒切換帶來的原子性問題
高階語言裡一條語句往往需要多條 CPU 指令完成,例如要完成count += 1,至少需要三條CPU指令。
- 指令1:把變數count從記憶體載入到CPU的暫存器中;
- 指令2:在暫存器中執行 +1 操作;
- 指令3:將結果寫入記憶體(快取機制導致可能寫入的是CPU快取而不是記憶體)
作業系統進行執行緒切換,可以發生在任何一條CPU指令執行完(不是高階語言中的一條語句)。如下圖所示,假設線上程A執行第一條CPU指令後發生了執行緒切換,A與B會以圖中順序執行。得到的count不是我們期望的2,而是1.
我們把一個或者多個操作在 CPU 執行的過程中不被中斷的特性成為原子性。CPU可以保證的原子操作是CPU指令級別,而高階語言層面保證操作的原子性。
三:編譯優化帶來的有序性問題
有序性指的是程式按照程式碼先後順序執行,而編譯器為了優化效能,有時候會改變程式中語句的先後順序。 舉一個Java中的一個經典案例,雙重檢查的單例模式。
pubic class Singleto {
static Singleto instance;
static Singleto getInstance(){
if (instance == null) {
synchronized(Singleto.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
}
return instance;
}複製程式碼
假設執行緒A、B同時呼叫getInstance()方法,乍一看上去,執行緒發現instance == null 後,會對Singleto.class加鎖,JVM保證只有一個執行緒可以獲得該鎖,則另一個執行緒會處於等待狀態。最後只有一個執行緒建立例項成功,另一個執行緒在鎖釋放後獲得鎖,然後檢查instance == null時,發現Singleto例項已經建立成功,所以不會再建立一個Singleto例項。 實際上,getInstance()方法是存在問題的,問題就在new操作上,我們預設任務new操作會以以下順序執行:
- 1.在堆上分配一塊記憶體M;
- 2.在記憶體M上初始化Singleto物件的例項;
- 3.把M的地址賦值給instance變數。
但經過優化後的執行順序可能是這樣的:
- 1.分配一塊記憶體M;
- 2.將M的地址賦值給instance變數;
- 3.最後在記憶體M上初始化Singleto物件。
假如執行緒A執行完指令2之後恰好發生了執行緒切換,切換到了執行緒B,B也執行getInstance()方法,則B會判斷instance != null,所以直接返回instance,而此時instance還沒有經過初始化,訪問該變數會觸發空指標異常。如下圖所示。
總結
併發程式經常出現的問題歸根結底是直覺欺騙了我們,要診斷併發Bug,需要深刻理解可見性、原子性、有序性在併發場景下的原理。
併發程式設計Bug源頭: 快取 帶來的可見性問題; 執行緒 切換帶來的原子性問題; 編譯 優化帶來的有序性問題。