弄明白這三個問題,併發編程不再難

編寫正確的程序難,編寫正確的併發程序則是難上加難。既然這麼難爲什麼還要併發,單線程執行不好嗎?爲了快呀,點個鏈接你願意等1分鐘嗎?,別說等一分鐘了,要是有個網頁讓我等超過10秒鐘,我就馬上要關掉了。

我們編寫的代碼在計算機中運行,那麼它肯定會用到計算機中的資源,一般都逃不過cpu、內存以及I/O(文件I/O或者網絡I/O等)。但是這三者速度上有極大的差異。

CPU的速度遠遠快於內存,而內存的速度又遠遠遠快於I/O。

比喻: CPU速度相當於 火箭,內存速度相當於 高鐵,I/O速度相當於 步行。

而我們的程序運行的快慢實際上是取決於最慢的那個操作–I/O操作,彷彿在這個時候CPU再快都沒啥作用。

我們一般都說盡可能少的查詢數據庫(batch的方式更好),就是爲了較少I/O操作

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

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

緩存導致的可見性問題

單核CPU的時候,所有線程操作的都是同一個CPU的緩存,一個線程對另緩存的寫,對另一個線程來說一定是可見的。例如在下面的圖中,線程 A 和線程 B 都是操作同一個 CPU 裏面的緩
存,所以線程 A 更新了變量 V 的值,那麼線程 B 之後再訪問變量 V,得到的一定是 V 的最新值(線程 A 寫過的值)。

access-singele-cpu-cache.png

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

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

access-different-cpu-cache.png

public class Counter {

  int v = 0;

  public void add() {
      for(int i = 0; i < 10000; i++) {
          v += 1;
      }
  }


  public static void main(String[] args) throws InterruptedException {
      Counter c = new Counter();

      Thread t1 = new Thread(() -> {
          c.add();
      });

      Thread t2 = new Thread(() -> {
          c.add();
      });

      // 啓動線程
      t1.start();
      t2.start();

      // 等待兩個線程執行結束
      t1.join();
      t2.join();

      System.out.println(c.v);
  }
}

比如上面的代碼,每次執行的結果都不一樣,執行結果也是介於10000和20000之間。

CPU cache中的值什麼時候刷新到內存(主存)中是不確定的,所以有可能某個後啓動的線程讀取到的值不一定是1,而是其他值(代碼所示的兩個線程啓動是存在時間差的)。

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

你可知道電腦中的進程是交替運行的,你能一邊聽歌一邊看電影都歸功於這個進程切換。操作系統允許某個進程執行一小段時間,例如 50 毫秒,過了 50 毫秒操作系統就會重新選
擇一個進程來執行(我們稱爲“任務切換”),這個 50 毫秒稱爲“時間片”。

cpu-switch.png

Java 併發程序都是基於多線程的,自然也會涉及到任務切換,也許你想不到,任務切換竟然也是併發編程裏詭異 Bug 的源頭之一。任務切換的時機大多數是在時間片結束的時候,
我們現在基本都使用高級語言編程,高級語言裏一條語句往往需要多條 CPU 指令完成,例如上面代碼中的v += 1,至少需要三條 CPU 指令。

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

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

編譯優化(指令重排)帶來的有序性問題

我們都知道編譯器爲了優化性能,是會調整語句順序的。比如下面的代碼


int a = 1;

long b = 2L;

編譯器優化之後可能會變成

long b = 2L;
int a = 1;

雖然優化後不影響執行結果,不過有時候編譯器以及解釋器的優化會帶來意想不到的結果。

還記得java中獲取單例對象的雙重檢查嗎?

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

實際上不能保證上面的代碼有效,當我們通過返回的Singleton對象訪問其成員變量,就有可能觸發空指針異常。
instance = new Singleton(); 不是原子操作,它由分配空間,初始化對象的字段以及爲instance分配地址的多條指令組成。

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

爲了顯示實際發生的情況,我使用一些僞代碼擴展instance = new Singleton();並內聯對象初始化代碼。

public class Singleton {
  static Singleton instance;
  static Singleton getInstance() {
    if(instance == null) {
      synchronized(Singleton.class) {
        if(instance == null)
          pointer = allocate();
          pointer.field1 = initField1();
          pointer.field2 = initField2();
          instance = pointer;
        }
    }
      return instance;
  }
}

爲了提高整體性能,某些編譯器,內存系統或處理器可能會對指令進行重新排序,例如在初始化對象的字段之前移動 instance = pointer。那麼代碼就會變成下面這樣

public class Singleton {
  static Singleton instance;
  static Singleton getInstance() {
    if(instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          pointer = allocate();
          instance = pointer;
          pointer.field1 = initField1();
          pointer.field2 = initField2();
        }
    }
      return instance;
  }
}

這種重新排序是合法的,因爲instance = pointer;與初始化字段的指令之間沒有數據依賴性。
但是,這種重新排序(以某些執行順序)可能導致其他線程看到instance的非null值,但訪問了該對象的未初始化字段就會出錯。

broken-double-check.png

寫到最後

只要在寫代碼的時候充分考慮上面說的三種情況,那麼一定可以幫助你抽絲剝繭的排查多線程下遇到的問題。

巨人肩膀: 極客時間–<java併發編程實戰>

關注我不迷路

你的關注是對我最大的顧慮,是兄弟就關注我(狗頭保命)
gzh.png

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