並發編程Bug起源:可見性、有序性和原子性問題

以前古老的DOS操作系統,是單進行的系統。系統每次只能做一件事情,完成了一個任務才能繼續下一個任務。每次只能做一件事情,比如在聽歌的時候不能打開網頁。所有的任務操作都按照串行的方式依次執行。

這類服務器缺點也很明顯,等待操作的過長,無法同時操作多個任務,執行效率很差。

現在的操作系統都是多任務的操作系統,比如聽歌的時候可以做打開網頁,還能打開微信和朋友聊天。這幾個任務可以同時進行,大大增加執行效率。

並發提高效率

一個完整服務器,都有CPU內存IO,三者之間的運行速度存在明顯的差異:

  • CPU相關的操作,執行指令以及讀取CPU緩存等操作,基本都是納秒級別的。
  • CPU讀取內存,耗時是CPU相關操作的千倍,基本都是微秒級別。CPU和內存之間的速度差異。
  • IO操作基本是毫秒的級別,是內存操作的千倍,內存IO之間存在速度的差異。

CPU -> 內存 -> SSD -> 磁盤 -> 網絡
納秒 -> 微秒 -> 毫秒 -> 毫秒 -> 秒

程序中大部分的語句都要訪問內存,有些還要訪問的IO讀寫。為了合理的利用CPU的高性能,高效的平衡三者的速度差異,操作系統、編譯器主要做了以下改進:

  • CPU增加了CPU緩存,用來均衡CPU內存的速度差異。
  • 操作系統增加了多進程、多線程,用來分時復用CPU,從而均衡CPUIO設備之間的差異。
  • 編譯優化程序執行順序,充分利用緩存。

做了以上操作之後,CPU讀取或者修改數據之後,將數據緩存在CPU緩存中,CPU不需要每次都從內存中獲取數據,極大的提高了CPU的運行速度。多線程是將時間段切成一個個小段,多個線程在上下文切換中,執行完任務,而不用等前面的線程都執行完畢之後再執行。比如做一個計算,CPU耗時1納秒,而從內存讀取數據要1微秒,沒有多線程的話,N個線程要耗時N微秒,此時CPU高效性就無法體現出來。有了多線程之後,操作系統將CPU時間段切成一個一個小段,多線程上下文切換,線程執行計算操作,無需等待內存讀取操作

雖然並發可以提高程序的運行效率,但是凡事有利也有弊,並發程序也有很多詭異的bug,根源有以下幾個原因。

緩存導致可見性問題

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

在單核時代,所有的線程都是在同一個CPU上運行,所有的線程都是操作同一個線程的CPU緩存,一個線程修改緩存,對另外一個線程來說一定是可見的。比如在下圖中,線程A線程B都是操作同一個CPU緩存,所以線程A更新了變量V的值,線程B再訪問變量V的值,獲取的一定是V的最新值。所以變量V對線程都是可見的

在多核CPU下,每個CPU都有自己的緩存。當多個線程執行在不同的CPU時,這些線程的操作也是在對應的CPU緩存上。這時候就會出現問題了,在下圖中,線程A運行在CPU_1上,首先從CPU_1緩存獲取變量V,獲取不到就獲取內存的值,然後操作變量V線程B也是同樣的方式在CPU_2緩存中獲取變量V

線程A操作的是CPU_1的緩存,線程B操作的是CPU_2的緩存,此時線程A變量V的操作對於線程B不可見的。多核CPU一方面提高了運行速度,但是另一方面也可能會造成線程不安全的問題。

下面使用一段代碼來測試多核場景下的可見性。首先創建一個累加的方法add10k方法,循環10000count+=1的操作。然後在test方法裏面創建兩個線程,每個線程都調用add10k方法,結果是多少呢?

public class VisibilityTest {

	private  static int count = 0;

	private void add10k() {
		int index = 0;
		while (index++ < 10000) {
			count += 1;
		}
	}

	@Test
	public void test() throws InterruptedException {
		VisibilityTest test = new VisibilityTest();
		Thread thread1 = new Thread(() -> test.add10k());
		Thread thread2 = new Thread(() -> test.add10k());
		// 啟動兩個線程
		thread1.start();
		thread2.start();
		// 等待兩個線程執行結束
		thread1.join();
		thread2.join();
		System.out.println(count);
	}
}

按照直覺來說結果是20000,因為在每個線程累加10000,兩個線程就是20000。但是實際結果是介於10000~20000的之間,每次執行結果都是這個範圍內的隨機數。

因為線程A和線程B同時開始執行,第一次都會將count=0緩存到自己的CPU緩存中,執行完count += 1之後,寫入自己對應的CPU緩存中,同時寫入內存中,此時內存中的數是1,而不是期望的2。之後CPU再取到自己的CPU緩存再進行計算,最後計算出來的count值都是小於20000,這就是緩存的可見性問題。

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

上面提到,由於CPU內存IO之間的速度存在很大的差異,在單進程系統中,需要等速度最慢的IO操作完成之後,才能接着完成下一個任務,CPU的高性能也無法體現出來。但操作系統有了多進程之後,操作系統將CPU切成一個一個小片段,在不同的時間片段內執行不同的進程的,而不需要等待速度慢的IO操作,在單核或者多核的CPU上可以一邊的聽歌,一邊的聊天。

操作系統將時間切成很小片,比例20毫秒,開始的20毫秒執行一個進程,下一個20毫秒切換執行另外一個線程,20毫秒成為時間片,如下圖所示:

線程A線程B來回的切換任務。

如果一個進行IO操作,例如讀取文件,這個時候該進程就把自己標記為休眠狀態並讓出CPU的使用權,等完成IO操作之後,又需要使用CPU時又會把休眠的進程喚醒,喚醒的進程就可以等待CPU的調用了。讓出CPU的使用權之後,CPU就可以對其他進程進行操作,這樣CPU的使用率就提高上了,系統整體的運行速度也快了很多。

並發程序大多數都是基於多線程的,也會涉及到線程上下文的切換,線程的切換都是在很短的時間片段內完成的。比如上面代碼中count += 1雖然有一行語句,但這裏面就有三條CPU指令。

  • 指令 1:把變量V從內存加載到CPU寄存器中。
  • 指令 2:在寄存器中執行+1操作。
  • 指令 3:將結果寫入內存(也可能是寫入CPU緩存中)。

任何一條CPU指令都可能發生線程切換。如果線程A在指令1執行完後做線程切換,線程A和線程B按照下圖順序執行,那麼我們會發現兩個線程都執行count += 1的操作,但是最後結果卻是1,而不是2

編譯優化帶來的有序性問題

有序性是指程序按照代碼的先後順序執行,編譯器為了優化性能,在不影響程序的最終結果的情況下,編譯器調整了語句的先後順序,比如程序中:

a = 2;
b = 5;

編譯器優化後可能變成:

b = 5;
a = 2;

雖然不影響程序的最後結果,但是也會引起一些意想不到的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;
  }
}

在獲取實例getInstance方法中,首先判斷instance是否為空,如果為空,則鎖定Singleton.class並再次檢查instance是否為空,如果還為空就創建一個Singleton實例。

假設兩個線程,線程A線程B同時調用getInstance方法。此時instance == null,同時對Singleton.class加鎖,JVM保證只有一個線程能加鎖成功,假設是線程A加鎖成功,另一個線程就會處於等待狀態,線程A會創建一個實例,然後釋放鎖,線程B被喚醒,再次嘗試加鎖,此時成功加鎖,而此時instance != null,已經創建過實例,所以線程B就不會創建實例了。

看起來沒有什麼問題,但實際上也有可能問題出現在new操作上,本來new操作應該是:

  • 1、分配一塊內存。
  • 2、在內存上初始化對象。
  • 3、內存的地址賦值給instance變量。

但實際優化後的執行順序卻是如下:

  • 1、分配一塊內存。
  • 2、將內存地址賦值給instance變量。
  • 3、在內存上初始化對象。

優化之後會發生什麼問題呢?首先假設線程A先執行getInstance方法,也就是先執行new操作,當執行完指令2時發生了線程切換,切換到線程B上,此時線程B執行getInstance方法,執行判斷時會發現instance != null,所以就返回instance,而此時的instance是沒有初始化的,如果這時訪問instance就可能會觸發空指針異常。

總結

操作系統進入多核、多進程、多線程時代,這些升級會很大的提高程序的執行效率,但同時也會引發可見性原子性有序性問題。

  • 多核CPU,每個CPU都有各自的CPU緩存,每個線程更新變量會先同步在CPU緩存中,而此時其他線程,無法獲取最新的CPU緩存值,這就是不可見性。
  • count += 1含有多個CPU指令。當發生線程切換,會導致原子問題。
  • 編譯優化器會調整程序的執行順序,導致在多線程環境,線程切換帶來有序的問題。

開始學習並發,經常會看到volatilesynchronized等並發關鍵字,而了解並發編程的有序性、原子性、可見性等問題,就能更好的理解並發場景下的原理。

參考

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