併發編程的Bug源頭:可見性、原子性和有序性問題

學習極客時間上的《Java併發編程實戰》課程之餘,結合自己的理解整理一部分筆記以鞏固知識。

併發編程的起源

  • 1.硬件設備發展的核心矛盾:CPU、內存、I/O設備三者間存在的速度差異。根據木桶原理,程序整體性能最終受制於速度最慢的I/O設備。
  • 2.爲了平和三者速度差異,計算機體系結構、操作系統、編譯程序都做出了貢獻,主要體現爲:
  • (1)CPU增加了緩存,以均衡與內存的速度差異;
  • (2)操作系統增加了進程、線程,以分時複用CPU,進而均衡CPU與I/O設備的速度差異;
  • (3)編譯程序優化指令執行順序,使得緩存能夠得到更加合理地利用。

併發編程出現問題的源頭

一:緩存導致的可見性問題

單核時代,所有線程在同一CPU上雲析,CPU緩存與內存的數據一致性容易解決。如下圖,線程A與B操作同一個CPU裏的緩存,故A修改過變量V後,B再訪問變量V,得到的一定是最新值,即A修改過的值。

單核CPU緩存與內存的關係

 

一個線程對共享變量的修改,另一個線程可以立即看到,稱之爲可見性

 

多核時代,每個CPU都有各自的緩存,當多個線程在不同的CPU上執行時,這些線程操作的是不同的CPU緩存,如下圖所示,線程A所修改的CPU-1緩存中的變量V,這個操作對線程B則不具有可見性。

多核CPU的緩存與內存關係

 

 

二:線程切換帶來的原子性問題

高級語言裏一條語句往往需要多條 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源頭:緩存帶來的可見性問題;線程切換帶來的原子性問題;編譯優化帶來的有序性問題。

參考:https://time.geekbang.org/column/article/83682

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