並發編程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
等並發關鍵字,而了解並發編程的有序性、原子性、可見性等問題,就能更好的理解並發場景下的原理。