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

 因爲CPU、內存、I/O 設備的速度差別,爲了合理利用 CPU 的高性能,平衡這三者的速度差別,計算機體系機構、操做系統、編譯程序都作出如下處理:java

1. CPU 增長了緩存,以均衡與內存的速度差別;
2. 操做系統增長了進程、線程,以分時複用 CPU,進而均衡 CPU 與 I/O 設備的速度差別;
3. 編譯程序優化指令執行次序,使得緩存可以獲得更加合理地利用。 
 
源頭之一:緩存致使的可見性問題
 
在單核時代,全部的線程都是在一顆 CPU 上執行,CPU 緩存與內存的數據一致性容易解決。由於全部線程都是操做同一個 CPU 的緩存,一個線程對緩存的寫,對另一個線程來講必定是可見的。
 
一個線程對共享變量的修改,另一個線程可以馬上看到,咱們稱爲 可見性
 
多核時代,每顆 CPU 都有本身的緩存,這時 CPU 緩存與內存的數據一致性就沒那麼容易解決了,當多個線程在不一樣的 CPU 上執行時,這些線程操做的是不一樣的 CPU 緩存。好比下圖中,線程 A 操做的是 CPU-1 上的緩存,而線程 B 操做的是 CPU-2 上的緩存,很明顯,這個時候線程 A 對變量 V 的操做對於線程 B 而言就不具有可見性了。這個就屬於硬件程序員給軟件程序員挖的「坑」。
 
下面咱們再用一段代碼來驗證一下多核場景下的可見性問題。下面的代碼,每執行一次add10K() 方法,都會循環 10000 次 count+=1 操做。在 calc() 方法中咱們建立了兩個線程,每一個線程調用一次 add10K() 方法,咱們來想想執行 calc() 方法獲得的結果應該是多少呢? 
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;
    }
}

  

直覺告訴咱們應該是 20000,由於在單線程裏調用兩次 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,緣由是兩個線程不是同時啓動的,有一個時差。 
變量 count 在 CPU 緩存和內存的分佈圖
 
源頭之二:線程切換帶來的原子性問題 
因爲 IO 太慢,早期的操做系統就發明了多進程,即使在單核的 CPU 上咱們也能夠一邊聽着歌,一邊寫 Bug,這個就是多進程的功勞。 操做系統容許某個進程執行一小段時間,例如 50 毫秒,過了 50 毫秒操做系統就會從新選擇一個進程來執行(咱們稱爲「任務切換」),這個 50 毫秒稱爲「時間片」。在一個時間片內,若是一個進程進行一個 IO 操做,例如讀個文件,這個時候該進程能夠把本身標記爲「休眠狀態」並出讓 CPU 的使用權,待文件讀進內存,操做系統會把這個休眠的進程喚醒,喚醒後的進程就有機會從新得到 CPU 的使用權了。這裏的進程在等待 IO 時之因此會釋放 CPU 使用權,是爲了讓 CPU 在這段等待時間裏能夠作別的事情,這樣一來 CPU 的使用率就上來了;此外,若是這時有另一個進程也讀文件,讀文件的操做就會排隊,磁盤驅動在完成一個進程的讀操做後,發現有排隊的任務,就會當即啓動下一個讀操做,這樣 IO 的使用率也上來了。
 
是否是很簡單的邏輯?可是,雖然看似簡單,支持多進程分時複用在操做系統的發展史上卻具備里程碑意義,Unix 就是由於解決了這個問題而名噪天下的。 早期的操做系統基於進程來調度 CPU,不一樣進程間是不共享內存空間的,因此進程要作任務切換就要切換內存映射地址,而一個進程建立的全部線程,都是共享一個內存空間的,因此線程作任務切換成本就很低了。現代的操做系統都基於更輕量的線程來調度,如今咱們提到的「任務切換」都是指「線程切換」。 
 
Java 併發程序都是基於多線程的,天然也會涉及到任務切換,也許你想不到,任務切換居然也是併發編程裏詭異 Bug 的源頭之一。任務切換的時機大多數是在時間片結束的時候,咱們如今基本都使用高級語言編程,高級語言裏一條語句每每須要多條 CPU 指令完成,例如上面代碼中的count += 1,至少須要三條 CPU 指令。 
指令 1:首先,須要把變量 count 從內存加載到 CPU 的寄存器;
指令 2:以後,在寄存器中執行 +1 操做;
指令 3:最後,將結果寫入內存(緩存機制致使可能寫入的是 CPU 緩存而不是內存)。

  

操做系統作任務切換,能夠發生在任何一條CPU 指令執行完,是的,是 CPU 指令,而不是高級語言裏的一條語句。對於上面的三條指令來講,咱們假設 count=0,若是線程 A在指令 1 執行完後作線程切換,線程 A 和線程 B 按照下圖的序列執行,那麼咱們會發現兩個線程都執行了 count+=1 的操做,可是獲得的結果不是咱們指望的 2,而是 1。 
                                                               非原子操做的執行路徑示意圖
 
咱們潛意識裏面以爲 count+=1 這個操做是一個不可分割的總體,就像一個原子同樣,線程的切換能夠發生在 count+=1 以前,也能夠發生在 count+=1 以後,但就是不會發生在中間。 咱們把一個或者多個操做在 CPU 執行的過程當中不被中斷的特性稱爲原子性
 
CPU 能保證的原子操做是 CPU 指令級別的,而不是高級語言的操做符,這是違背咱們直覺的地方。所以,不少時候咱們須要在高級語言層面保證操做的原子性。
 
源頭之三:編譯優化帶來的有序性問題 
那併發編程裏還有沒有其餘有違直覺容易致使詭異 Bug 的技術呢?有的,就是有序性。顧名思義,有序性指的是程序按照代碼的前後順序執行。編譯器爲了優化性能,有時候會改變程序中語句的前後順序,例如程序中:「a=6;b=7;」編譯器優化後可能變成「b=7;a=6;」,在這個例子中,編譯器調整了語句的順序,可是不影響程序的最終結果。不過有時候編譯器及解釋器的優化可能致使意想不到的 Bug。在 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 上;若是此時線程 B 也執行 getInstance() 方法,那麼線程 B 會發現instance != null,因此直接返回 instance,而此時的
instance 是沒有初始化過的,若是咱們這個時候訪問 instance 的成員變量就可能觸發空指針異常。 
雙重檢查建立單例的異常執行路徑
 
總結
要寫好併發程序,首先要知道併發程序的問題在哪裏,只有肯定了「靶子」,纔有可能把問題解決,畢竟全部的解決方案都是針對問題的。併發程序常常出現的詭異問題看上去很是無厘頭,可是深究的話,無外乎就是直覺欺騙了咱們,只要咱們可以深入理解可見性、原子性、有序性在併發場景下的原理,不少併發 Bug 都是能夠理解、能夠診斷的。在介紹可見性、原子性、有序性的時候,特地提到緩存致使的可見性問題,線程切換帶來的原子性問題,編譯優化帶來的有序性問題,其實緩存、線程、編譯優化的目的和咱們寫併發程序的目的是相同的,都是提升程序性能。可是技術在解決一個問題的同時,必然會帶來另一個問題,因此在採用一項技術的同時,必定要清楚它帶來的問題是什麼,以及如何規避。
 
常聽人說,在 32 位的機器上對 long 型變量進行加減操做存在併發隱患,究竟是不是這樣呢?
long類型64位,因此在32位的機器上,對long類型的數據操做一般須要多條指令組合出來,沒法保證原子性,因此併發的時候會出問題
相關文章
相關標籤/搜索