並行程式設計Bug起源:可見性、有序性和原子性問題

2022-09-01 09:01:13

以前古老的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的源頭