[併發理論基礎] 01 | 可見性、原子性和有序性問題

[併發理論基礎] 01 | 可見性、原子性和有序性問題:併發編程Bug的源頭

核心矛盾:CPU、內存、I/O 設備的速度差異。根據木桶理論,程序整體的性能取決於最慢的操作,所以單方面提高 CPU 性能是無效的。

爲了合理利用 CPU 的高性能,平衡這三者的速度差異,計算機體系機構、操作系統、編譯程序都做出了貢獻,主要體現爲:

  1. CPU 增加了緩存,以均衡與內存的速度差異;
  2. 操作系統增加了進程、線程,以分時複用 CPU,進而均衡 CPU 與 I/O 設備的速度差異;
  3. 編譯程序優化指令執行次序,使得緩存能夠得到更加合理地利用。

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

一個線程對共享變量的修改,另外一個線程能夠立刻看到,我們稱爲可見性

在單核時代,所有的線程都是在一顆 CPU 上執行,CPU 緩存與內存的數據一致性容易解決。因爲所有線程都是操作同一個 CPU 的緩存,一個線程對緩存的寫,對另外一個線程來說一定是可見的。例如在下面的圖中,線程 A 和線程 B 都是操作同一個 CPU 裏面的緩存,所以線程 A 更新了變量 V 的值,那麼線程 B 之後再訪問變量 V,得到的一定是 V 的最新值(線程 A 寫過的值)。
在這裏插入圖片描述

多核時代,每顆 CPU 都有自己的緩存,這時 CPU 緩存與內存的數據一致性就沒那麼容易解決了,當多個線程在不同的 CPU 上執行時,這些線程操作的是不同的 CPU 緩存。比如下圖中,線程 A 操作的是 CPU-1 上的緩存,而線程 B 操作的是 CPU-2 上的緩存,很明顯,這個時候線程 A 對變量 V 的操作對於線程 B 而言就不具備可見性了。

在這裏插入圖片描述

多核場景下的可見性問題的驗證。下面的代碼,每執行一次 add10K() 方法,都會循環 10000 次 count+=1 操作。在 calc() 方法中我們創建了兩個線程,每個線程調用一次 add10K() 方法。

public class Test {
  private long count = 0;
  private void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
      count += 1;
    }
  }
  public static long calc() {
    final Test test = new Test();
    // 創建兩個線程,執行add()操作
    Thread th1 = new Thread(()->{
      test.add10K();
    });
    Thread th2 = new Thread(()->{
      test.add10K();
    });
    // 啓動兩個線程
    th1.start();
    th2.start();
    // 等待兩個線程執行結束
    th1.join();
    th2.join();
    return count;
  }
}

在單線程裏調用兩次 add10K() 方法,count 的值就是 20000,但在多線程中,calc() 的執行結果是個 10000 到 20000 之間的隨機數。

我們假設線程 A 和線程 B 同時開始執行,那麼第一次都會將 count=0 讀到各自的 CPU 緩存裏,執行完 count+=1 之後,各自 CPU 緩存裏的值都是 1,同時寫入內存後,我們會發現內存中是 1,而不是我們期望的 2。之後由於各自的 CPU 緩存裏都有了 count 的值,兩個線程都是基於 CPU 緩存裏的 count 值來計算,所以導致最終 count 的值都是小於 20000 的。這就是緩存的可見性問題。

循環 10000 次 count+=1 操作如果改爲循環 1 億次,你會發現效果更明顯,最終 count 的值接近 1 億,而不是 2 億。如果循環 10000 次,count 的值接近 20000,原因是兩個線程不是同時啓動的,有一個時差。
1

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

早期的操作系統基於進程來調度 CPU,不同進程間是不共享內存空間的,所以進程要做任務切換就要切換內存映射地址,而一個進程創建的所有線程,都是共享一個內存空間的,所以線程做任務切換成本就很低了。現代的操作系統都基於更輕量的線程來調度,現在我們提到的“任務切換”都是指“線程切換”。

count += 1,至少需要三條 CPU 指令。

  • 指令 1:首先,需要把變量 count 從內存加載到 CPU 的寄存器;
  • 指令 2:之後,在寄存器中執行 +1 操作;
  • 指令 3:最後,將結果寫入內存(緩存機制導致可能寫入的是 CPU 緩存而不是內存)。

操作系統做任務切換,可以發生在任何一條CPU 指令執行完,而不是高級語言裏的一條語句。對於上面的三條指令來說,我們假設 count=0,如果線程 A 在指令 1 執行完後做線程切換,線程 A 和線程 B 按照下圖的序列執行,那麼我們會發現兩個線程都執行了 count+=1 的操作,但是得到的結果不是我們期望的 2,而是 1。

在這裏插入圖片描述

我們把一個或者多個操作在 CPU 執行的過程中不被中斷的特性稱爲原子性。CPU 能保證的原子操作是 CPU 指令級別的,而不是高級語言的操作符。因此,很多時候我們需要在高級語言層面保證操作的原子性。

源頭三:編譯優化帶來的有序性問題

有序性指的是程序按照代碼的先後順序執行。編譯器爲了優化性能,有時候會改變程序中語句的先後順序。在 Java 領域一個經典的案例就是利用雙重檢查創建單例對象,例如下面的代碼:在獲取實例 getInstance() 的方法中,我們首先判斷 instance 是否爲空,如果爲空,則鎖定 Singleton.class 並再次檢查 instance 是否爲空,如果還爲空則創建 Singleton 的一個實例。

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

假設有兩個線程 A、B 同時調用 getInstance() 方法,他們會同時發現 instance == null,於是同時對 Singleton.class 加鎖,此時 JVM 保證只有一個線程能夠加鎖成功(假設是線程 A),另外一個線程則會處於等待狀態(假設是線程 B);線程 A 會創建一個 Singleton 實例,之後釋放鎖,鎖釋放後,線程 B 被喚醒,線程 B 再次嘗試加鎖,此時是可以加鎖成功的,加鎖成功後,線程 B 檢查 instance == null 時會發現,已經創建過 Singleton 實例了,所以線程 B 不會再創建一個 Singleton 實例。

但實際上這個 getInstance() 方法並不完美。問題出在 new 操作上,我們以爲的 new 操作應該是:

  1. 分配一塊內存 M;
  2. 在內存 M 上初始化 Singleton 對象;
  3. 然後 M 的地址賦值給 instance 變量。

但是實際上優化後的執行路徑卻是這樣的:

  1. 分配一塊內存 M;
  2. 將 M 的地址賦值給 instance 變量;
  3. 最後在內存 M 上初始化 Singleton 對象。

優化後會導致什麼問題呢?我們假設線程 A 先執行 getInstance() 方法,當執行完指令 2 時恰好發生了線程切換 (時間片切換),切換到了線程 B 上,但是 A 並沒有釋放鎖;如果此時線程 B 也執行 getInstance() 方法,那麼線程 B 在執行第一個判斷時會發現 instance != null ,所以直接返回 instance,不用去獲取鎖,而此時的 instance 是沒有初始化過的,如果我們這個時候訪問 instance 的成員變量就可能觸發空指針異常。
在這裏插入圖片描述
解決辦法

① 對instance進行volatile語義聲明,就可以禁止指令重排序,避免有序性問題發生。

對CPU緩存和內存的疑問:CPU緩存不存在於內存中的,它是一塊比內存更小、讀寫速度更快的芯片,至於什麼時候把數據從緩存寫到內存,沒有固定的時間,同樣地,對於有volatile語義聲明的變量,線程A執行完後會強制將值刷新到內存中,線程B進行相關操作時會強制重新把內存中的內容寫入到自己的緩存,這就涉及到了volatile的寫入屏障問題,當然也就是所謂happen-before問題。注意volatile保證可見性,不保證原子性。

② 採用靜態內部類的方式來實現

public class MySingleton {
	//內部類
	private static class MySingletonHandler{
		private static MySingleton instance = new MySingleton();
	}
		private MySingleton(){}	
	public static MySingleton getInstance() {
		return MySingletonHandler.instance;
	}
}

Tips總結

  • 可見性問題:
    • 可見性:一個線程對共享變量的修改,另外一個線程能夠立刻看到
    • 併發問題往往都是綜合證,這裏即使是單核CPU,只要出現線程切換就會有原子性問題,
  • CPU緩存刷新到內存的時機
    • CPU將緩存寫入內存的時機是不確定的。除非調用cpu相關指令強刷
  • 雙重鎖問題
    • 如果A線程與B線程如果同時進入第一個分支,那麼這個程序就沒有問題
    • 如果A線程先獲取鎖並出現指令重排序時,B線程未進入第一個分支,那麼就可能出現空指針問題,這裏說可能出現問題是因爲當把內存地址賦值給共享變量後,CPU將數據寫回緩存的時機是隨機的
  • synchronized
    • 線程在synchronized塊中,發生線程切換,鎖是不會釋放的
  • 指令優化
    • 除了編譯優化,有一部分可以通過看彙編代碼來看,但是CPU和解釋器在運行期也會做一部分優化,所以很多時候都是看不到的,也很難重現。
  • JMM模型和物理內存、緩存等關係
    • 內存、cpu緩存是物理存在,jvm內存是軟件存在的。
    • 關於線程的工作內存和寄存器、cpu緩存的關係 可以參考這篇文章線程的工作內存
  • IO操作
    • io操作不佔用cpu,讀文件,是設備驅動乾的事,cpu只管發命令。發完命令,就可以幹別的事情了。
  • 寄存器切換
    • 寄存器是共用的,A線程切換到B線程的時候,寄存器會把操作A的相關內容會保存到內存裏,切換回來的時候,會從內存把內容加載到寄存器。可以理解爲每個線程有自己的寄存器
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章