以前古老的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
指令。
CPU
暫存器中。+1
操作。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
操作應該是:
instance
變數。但實際優化後的執行順序卻是如下:
instance
變數。優化之後會發生什麼問題呢?首先假設執行緒A
先執行getInstance
方法,也就是先執行new
操作,當執行完指令2
時發生了執行緒切換,切換到執行緒B
上,此時執行緒B執行getInstance
方法,執行判斷時會發現instance != null
,所以就返回instance
,而此時的instance
是沒有初始化的,如果這時存取instance
就可能會觸發空指標異常。
作業系統進入多核、多程序、多執行緒時代,這些升級會很大的提高程式的執行效率,但同時也會引發可見性
、原子性
、有序性
問題。
CPU
,每個CPU都有各自的CPU快取,每個執行緒更新變數會先同步在CPU快取
中,而此時其他執行緒,無法獲取最新的CPU
快取值,這就是不可見性。count += 1
含有多個CPU
指令。當發生執行緒切換,會導致原子問題。開始學習並行,經常會看到volatile
、synchronized
等並行關鍵字,而瞭解並行程式設計的有序性、原子性、可見性等問題,就能更好的理解並行場景下的原理。