你還不懂可見性、有序性和原子性?

王子發表於2020-11-16

 

前言

今天開始,王子準備開始一個新的專欄:併發程式設計專欄

併發程式設計無論在哪門語言裡,都屬於高階篇,面試中也嚐嚐會被問到。想要深入理解併發程式設計機制確實不是一件容易的事,因為它涉及到計算機底層和作業系統的相關知識,如果對這部分知識不是很清楚可能會導致理解困難。

在這個專欄裡,王子會盡量以白話和圖片的方式剖析併發程式設計本質,希望可以讓大家更容易理解。

今天我們就來談一談可見性、有序性和原子性都是什麼東西。

 

併發程式設計的幕後

進入主題之前,我們先來了解一下併發程式設計的幕後。

隨著CPU、記憶體和I/O裝置的不斷升級,它們之間一直存在著一個矛盾,就是速度不一致問題。CPU的速度高於記憶體,記憶體的速度又高於I/O裝置。

我們寫的程式碼中大多數內容都會經過記憶體處理,有些內容會去讀寫I/O裝置,根據木桶理論,整體的效能取決於最慢的操作,就是I/O裝置,所以單單提升CPU的效能是不夠的。

為了最大化體現出CPU的效能,計算機底層主要做了三部分優化:

1.CPU增加了快取,比記憶體速度更快,平衡記憶體的速度

2.作業系統增加了程式和執行緒,可以對CPU分時複用

3.編譯程式會進行指令的重排,使快取更好的發揮效能

我們平時的工作中其實一直都享受著這些優化後的成果,但同時他們也會導致一些很難找到原因的BUG。

 

什麼是可見性

首先我們就來看看什麼是可見性。

一個執行緒對共享變數的修改,另一個執行緒可以感知到,我們稱其為可見性

在單核時代,其實是不存在可見性問題的,因為所有的執行緒都是在一個CPU中工作的,一個執行緒的寫操作對於其他的執行緒一定是可見的。

 

但是多核CPU出現後,每個CPU都有自己的快取,多個執行緒在不同的CPU中處理資料就會導致不可見問題。

 

假設變數v的值是1, 兩個執行緒同時執行了v++操作,首先會從記憶體中讀取變數v的資料到各自的CPU快取中,這個時候兩個CPU快取中的v都是1,執行v++後,兩個變數v都變成了2,然後再寫回記憶體,記憶體中的變數v就變成了2。

但其實我們想看到的結果v最終應該是3才對。

在CPU1快取中執行v++後,CPU2快取無法感知的到,這就是可見性問題。而由於可見性問題導致的最終資料不正確,就是執行緒安全問題。

 

什麼是原子性

由於I/O的速度太慢,早期的作業系統發明了多程式,就是允許某個程式執行一小段時間後,重新選擇一個程式來執行,這個過程叫做任務切換,而這一小段的時間我們稱其為時間片。

 

現在作業系統的任務切換一般指的是更輕量級的執行緒切換,java的併發程式設計是基於多執行緒的,自然也會存線上程切換。

一般會在時間片結束的時候進行執行緒切換,java語言中執行的一段簡單的程式碼往往需要多條CPU的指令實現,比如count++這部分程式碼,至少需要三條CPU指令:

1.首先把count從記憶體中讀取到CPU的暫存器中

2.在暫存器中執行+1操作

3.最後將count的值寫入記憶體中(可能寫入到CPU的快取中)

而執行緒切換是可以發生在任意的一條CPU指令執行之後的,注意,這裡說的是CPU的指令,而不是java語言中的指令,對於上面的三條指令來說,我們假設 count=0,如果執行緒 A 在指令 1 執行完後做執行緒切換,執行緒 A 和執行緒 B 按照下圖的順序執行,那麼我們會發現兩個執行緒都執行了 count++ 的操作,但是得到的結果不是我們期望的 2,而是 1。

 

這就是執行緒切換導致的資料錯誤問題,我們把一個或者多個操作在 CPU 執行的過程中不被中斷的特性稱為原子性,CPU 能保證的原子操作是 CPU 指令級別的,而不是高階語言的操作符,這是違揹我們直覺的地方。因此,很多時候我們需要在高階語言層面保證操作的原子性。

 

什麼是有序性

有序性指的是程式按照程式碼的先後順序執行。編譯器為了優化效能,有時候會改變程式中語句的先後順序,例如程式中:“x=1;y=2;”編譯器優化後可能變成“y=2;x=1;”。

在這個例子中,編譯器調整了語句的順序,但是不影響程式的最終結果。不過有時候調整了語句的順序可能導致意想不到的 Bug。

在 Java 領域一個經典的案例就是利用雙重檢查建立單例物件,程式碼如下:

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 例項。

這個過程看上去是不是無懈可擊,沒有漏洞?

答案是否定的,問題就出在了new操作上,我們以為的new操作是這樣的:

1.分配一塊記憶體空間

2.在這塊記憶體空間上初始化Singleton例項物件

3.把這個物件的記憶體地址賦值給instance變數

但實際上由於指令重排,優化後的過程是這樣的:

1.分配一塊記憶體空間

2.把這快記憶體空間的記憶體地址賦值給instance變數

3.在這塊記憶體空間上初始化Singleton例項物件 

那麼這樣調換順序後會發生什麼呢?

我們假設執行緒 A 先執行 getInstance() 方法,當執行完指令 2 時恰好發生了執行緒切換,切換到了執行緒 B 上;如果此時執行緒 B 也執行 getInstance() 方法,那麼執行緒 B 在執行第一個判斷時會發現 instance != null ,所以直接返回 instance,而此時的 instance 是沒有初始化過的,如果我們這個時候訪問 instance 的成員變數就可能觸發空指標異常。

 

總結

使用併發程式設計開發,往往會出現很多難以找到原因的BUG,通過對可見性、有序性和原子性的分析,可以為我們排查併發導致的BUG提供一些思路。

CPU快取會導致可見性

指令重排會導致有序性

執行緒切換會導致原子性

以上就是本篇文章的三個核心內容,那我們下篇文章繼續。

 

往期文章推薦:

JVM專欄

訊息中介軟體專欄

 

相關文章