可見性、原子性和有序性問題:并發(fā)編程Bug的源頭

如果你細(xì)心觀察的話,你會發(fā)現(xiàn),不管是哪一門編程語言,并發(fā)類的知識都是在高級篇里。換句話說,這塊知識點(diǎn)其實(shí)對于程序員來說,是比較進(jìn)階的知識。

你我都知道,編寫正確的并發(fā)程序是一件極困難的事情,并發(fā)程序的 Bug 往往會詭異地出現(xiàn),然后又詭異地消失,很難重現(xiàn),也很難追蹤,很多時(shí)候都讓人很抓狂。但要快速而又精準(zhǔn)地解決“并發(fā)”類的疑難雜癥,你就要理解這件事情的本質(zhì),追本溯源,深入分析這些 Bug 的源頭在哪里。

那為什么并發(fā)編程容易出問題呢?它是怎么出問題的?今天我們就重點(diǎn)聊聊這些 Bug 的源頭。

并發(fā)程序幕后的故事

CPU、內(nèi)存、I/O設(shè)備都在不斷的迭代,但是在快速發(fā)展的過程中,有一個(gè)核心矛盾一直存在,就是這三者的速度差異。CPU和內(nèi)存的速度差異可以形象的描述為:CPU是天上一天,內(nèi)存是地上一年(假設(shè)CPU執(zhí)行一條普通指令需要一天,那么CPU讀寫內(nèi)存得等待一年的時(shí)間)。內(nèi)存和I/O設(shè)備的速度差異就更大了,內(nèi)存是天上一天,I/O設(shè)備是地上十年。

為了合理利用CPU的高性能,平衡這三者的速度差異,計(jì)算機(jī)體系機(jī)構(gòu)、操作系統(tǒng)、編譯程序作出了貢獻(xiàn),主要體現(xiàn)為:

1. CPU增加了緩存,以均衡與內(nèi)存的速度差異;

2. 操作系統(tǒng)增加了進(jìn)程、線程,以分時(shí)復(fù)用CPU,進(jìn)而均衡CPU與I/O設(shè)備的速度差異;

3. 編譯程序優(yōu)化指令次序,使得緩存能夠得到更加合理地利用

我們在享受這些成果的同時(shí),并發(fā)程序很多詭異的問題的根源也在這里

源頭之一:緩存導(dǎo)致可見行問題

在單核時(shí)代,所有的線程都是在一顆CPU上執(zhí)行,CPU緩存與內(nèi)存的數(shù)據(jù)一致性容易解決。因?yàn)樗械木€程都是操作同一個(gè)CPU的緩存,一個(gè)線程對緩存的寫,對另一個(gè)線程來說一定是可見的。

如圖,線程A和線程B都是操作同一個(gè)CPU里面的緩存,所以線程A更新了變了V的值,那么線程B之后在訪問,得到的一定是最新值(線程A寫過的值)。

一個(gè)線程對共享變量的修改,另一個(gè)線程能夠立刻看到,我們稱為可見行。

多核時(shí)代,每顆CPU都有自己的緩存,這時(shí)CPU的緩存與內(nèi)存的數(shù)據(jù)一致性就沒有那么容易解決了,當(dāng)多個(gè)線程在不同的CPU上執(zhí)行時(shí),這些線程操作的是不同的CPU緩存。

如圖,線程A操作的是CPU-1上的緩存,而線程B操作的是CPU-2上的緩存,很明顯這個(gè)時(shí)候線程A對變量V的操作對于線程B而言就不具備可見行了。

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();
    // 創(chuàng)建兩個(gè)線程,執(zhí)行 add() 操作
    Thread th1 = new Thread(()->{
      test.add10K();
    });
    Thread th2 = new Thread(()->{
      test.add10K();
    });
    // 啟動兩個(gè)線程
    th1.start();
    th2.start();
    // 等待兩個(gè)線程執(zhí)行結(jié)束
    th1.join();
    th2.join();
    return count;
  }
}

如上面程序,直覺告訴我們應(yīng)該是20000,因?yàn)樵趩尉€程里調(diào)用兩次add1OK()方法,但實(shí)際的結(jié)果是10000-20000之間的隨機(jī)數(shù)。為什么呢?

如圖,我們假設(shè)線程AB同時(shí)執(zhí)行,第一次都會將count=0讀到各自的CPU緩存里,執(zhí)行完count+=1之后,各自CPU緩存里的值都是1,同時(shí)寫入內(nèi)存后,我們發(fā)現(xiàn)內(nèi)存中是1,而不是我們期待的2,之后由于各自的CPU緩存里都有了count的值,兩個(gè)線程都是基于CPU緩存里的count值來計(jì)算,所以最終count的值是小于20000的。這就是緩存的可見行問題。

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

由于IO太慢,操作系統(tǒng)發(fā)明了多進(jìn)程和后來的多線程,因此在單核的CPU上我們也可以一遍聽著歌,一遍謝Bug,這就是多進(jìn)程多線程的功勞。

操作系統(tǒng)允許某個(gè)進(jìn)程執(zhí)行一小段實(shí)際,例如50毫秒,過了50毫秒操作系統(tǒng)就會重新選擇一個(gè)進(jìn)程來執(zhí)行(我們稱為任務(wù)切換,也就是線程切換),這個(gè)50毫秒稱為“時(shí)間片“。

java并發(fā)程序都是基于多線程的,自然也就會涉及到任務(wù)切換,任務(wù)切換的時(shí)機(jī)大多數(shù)都是在時(shí)間片結(jié)束的時(shí)候,我們現(xiàn)在基本都使用高級語言編程,高級語言里的一條語句往往需要多條CPU指令完成,如count += 1,至少需要三條CPU指令。

  • 指令1:首先,需要把變量count從內(nèi)存加載到CPU寄存器;

  • 指令2:之后,在寄存器中執(zhí)行+1操作;

  • 指令3:最后,將結(jié)果寫入內(nèi)存(緩存機(jī)制導(dǎo)致可能寫入的是CPU緩存而不是內(nèi)存)。

操作系統(tǒng)做任務(wù)切換,可以發(fā)生在任何一條CPU指令執(zhí)行完,注意是CPU指令,而不是高級語言里的一條語句。對于上面三條指令來說,我們假設(shè)count=0,如果線程A在指令1執(zhí)行完后做線程切換,線程A和線程B按照下圖的序列執(zhí)行,那么兩個(gè)線程都執(zhí)行了count +=1的操作,得到的結(jié)果不是我們期望的2,而是1。

我們潛意識覺得count +=1這個(gè)操作是一個(gè)不可分割的整體,就像原子一樣,線程的切換可以在count +=1之前,或之后,但是就不會發(fā)生在中間。我們把一個(gè)或多個(gè)操作在CPU執(zhí)行過程中不被中斷的特性稱為原子性。CPU能保證的原子操作是CPU指令級別的,而不是高級語言級別的。因此我們很多時(shí)候需要在高級語言層面保證操作的原子性。

源頭之三:編譯優(yōu)化帶來的有序性問題

有序性是指程序按照帶嗎的先后次序執(zhí)行。編譯器為了優(yōu)化性能,有時(shí)候會改變程序中語句的先后順序,如“a = 6; b=7",編譯器優(yōu)化后可能編程“b = 7; a = 6",該調(diào)整不影響程序的最終結(jié)果,不過有時(shí)候編譯器及解釋器的優(yōu)化可能導(dǎo)致意想不到的Bug。

在Java領(lǐng)域一個(gè)經(jīng)典的案例就是利用雙重檢查創(chuàng)建單例對象。

public class Singleton {
  static Singleton instance; // volatile 可以解決可見性和有序性問題
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

假設(shè)有兩個(gè)線程A、B同時(shí)調(diào)用getInstance()方法,他們會同時(shí)發(fā)現(xiàn)instance == null,于是同時(shí)對Singleton.class加鎖,此時(shí)JVM保證只有一個(gè)線程加鎖成功(假設(shè)線程A),線程B處于等待狀態(tài);線程A創(chuàng)建一個(gè)實(shí)例,之后釋放鎖,線程B被喚醒,加鎖,檢查instance == null時(shí),發(fā)現(xiàn)實(shí)例已創(chuàng)建。

這看上去一切很完美,但實(shí)際上并不完美,問題出現(xiàn)在new操作上,我們以為的new操作應(yīng)該是:

1. 分配一塊內(nèi)存M;

2. 在內(nèi)存M上初始化Singleton對象;

3. 然后M的地址賦值給instance變量

但實(shí)際上優(yōu)化后的執(zhí)行路徑卻是這樣的:

1. 分配一塊內(nèi)存M;

2. 將M的地址賦值為instance變量;

3. 最后在內(nèi)存M上初始化Singleton對象。

優(yōu)化后導(dǎo)致的問題是:我們假設(shè)線程A先執(zhí)行了getInstance()方法,當(dāng)執(zhí)行到指令2時(shí),發(fā)生了線程切換,此時(shí)線程B也執(zhí)行了getInstance()方法,那么線程B發(fā)現(xiàn)insurance != null,所以直接返回instance,而此時(shí)instance是沒有初始化的,如果我們訪問instance的成員變量就可能發(fā)生空指針異常

總結(jié)

要寫好并發(fā)程序,首先要知道并發(fā)程序的問題在哪里,問題的深究不外乎就是直覺欺騙了我們,只要我們能夠深刻理解可見性、原子性、有序性在并發(fā)場景下的原理,很多并發(fā)Bug都是可以理解、可以診斷的。

思考

常聽人說,在 32 位的機(jī)器上對 long 型變量進(jìn)行加減操作存在并發(fā)隱患,到底是不是這樣呢?現(xiàn)在相信你一定能分析出來。

公眾號 架構(gòu)道與術(shù)(ToBeArchitecturer),歡迎關(guān)注、學(xué)習(xí)更多干貨~

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容