並發編程Bug起源:可見性、有序性和原子性問題
- 2022 年 9 月 1 日
- 筆記
以前古老的DOS
作業系統,是單進行的系統。系統每次只能做一件事情,完成了一個任務才能繼續下一個任務。每次只能做一件事情,比如在聽歌的時候不能打開網頁。所有的任務操作都按照串列的方式依次執行。
這類伺服器缺點也很明顯,等待操作的過長,無法同時操作多個任務,執行效率很差。
現在的作業系統都是多任務的作業系統,比如聽歌的時候可以做打開網頁,還能打開微信和朋友聊天。這幾個任務可以同時進行,大大增加執行效率。
並發提高效率
一個完整伺服器,都有CPU
、記憶體
、IO
,三者之間的運行速度存在明顯的差異:
CPU
相關的操作,執行指令以及讀取CPU
快取等操作,基本都是納秒
級別的。CPU
讀取記憶體,耗時是CPU
相關操作的千倍,基本都是微秒
級別。CPU
和記憶體之間的速度差異。IO
操作基本是毫秒的級別,是記憶體操作的千倍,記憶體
和IO
之間存在速度的差異。
CPU -> 記憶體 -> SSD -> 磁碟 -> 網路
納秒 -> 微秒 -> 毫秒 -> 毫秒 -> 秒
程式中大部分的語句都要訪問記憶體,有些還要訪問的IO
讀寫。為了合理的利用CPU
的高性能,高效的平衡三者的速度差異,作業系統、編譯器主要做了以下改進:
CPU
增加了CPU快取
,用來均衡CPU
和記憶體
的速度差異。- 作業系統增加了多進程、多執行緒,用來分時復用
CPU
,從而均衡CPU
與IO
設備之間的差異。 - 編譯優化程式執行順序,充分利用快取。
做了以上操作之後,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
方法,循環10000
次count+=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
指令。當發生執行緒切換,會導致原子問題。- 編譯優化器會調整程式的執行順序,導致在多執行緒環境,執行緒切換帶來有序的問題。
開始學習並發,經常會看到volatile
、synchronized
等並發關鍵字,而了解並發編程的有序性、原子性、可見性等問題,就能更好的理解並發場景下的原理。