因爲本篇文章是線程開篇的第一篇文章,所以會着重介紹一些線程的基礎,與一些概念性的內容。部份內容引用參考資料,可能存在過於官方的狀況。不過系統性的學習也應當如此,但願你們能夠耐着性子看完。c++
在閱讀本文章時,但願你們暫且拋棄掉語言的固有認知,不一樣語言對於某些問題的處理可能會產生較大的不一樣,所以本篇文章的代碼也是基於僞代碼,意在拋棄語言的干擾後,爲你們講解各知識點。程序員
你會發現有些狀況在你的語言中並不會出現,這也許是由於你的編譯器優化策略有所改進等緣由。盡信書不如無書,但願你們多多實踐,實踐是檢驗真理的惟一標準。算法
線程(Thread) 也被稱做 輕量級進程(Lightweight Process, LWP),是程序執行流的最小單元。標準線程由 線程ID、當前指令指針(PC)、寄存器集合、堆棧組成。一個經典的線程與進程的關係圖以下所示:api
在計算機發展早期,CPU 資源十分昂貴,若是一個 CPU 只運行一個程序,那麼碰到例如程序讀寫磁盤(多是磁帶)時,CPU就空閒下來了,這簡直是暴殄天物。緩存
因而人們很快編寫了一個監控程序,當 程序A 暫時無需使用 CPU 時,監控程序就將正在等待 CPU 資源的程序啓動。使 CPU 充分被利用。安全
這種方法被稱做 多道程序(Multiprogramming),雖然原始,但大大提升了 CPU 利用率。markdown
固然這種方法存在很大的弊端,即調度策略太粗糙,也許有些程序急需 cpu 來完成一些任務(例如用戶交互),有可能很長時間後才被分配到 cpu。多線程
稍加改進後,程序運行模式變爲了協做模式,即每一個程序運行一段時間後,主動讓出 cpu 給其餘程序,這種程序協做模式叫作 分時系統(Time-Sharing System) 尤爲是對於交互式的任務,這一點很是重要。併發
Windosw 早期版本(Windows 95 和 Windows NT以前),Mac OS X 以前的版本都是採用這種分時系統來調度程序。app
固然,這裏面也存在很大的問題,例如一個鄭旭正在進行一個很是耗時的計算,一直霸佔着 cpu 不放,那麼操做系統也沒辦法,其餘程序只能等待,就好像系統死機了同樣。好比一個 while(1) 死循環會致使整個系統中止。在今天看來其實這是很是荒唐的。
多任務系統咱們幾天已經很是熟悉了。操做系統接管了全部硬件資源,而且自己運行在一個受硬件保護的級別。
全部的應用程序以 進程(Process) 的方式運行在比操做系統權限低的級別,有本身的獨立地址空間。cpu 由操做系通通一進行分配,根據進程優先級,都會有機會獲得 cpu,若是運行時間超過了必定的時間,操做系統會暫停該進程,將 cpu 分配給其餘等待運行的進程。
這種 cpu 分配方式,咱們稱爲 搶佔式(Preemptive),操做系統能夠強制剝奪 cpu 資源而且分配給他認爲目前最須要的進程。
咱們所說的併發,每每指的就是多線程或多進程之間的協做調度,這種方式能夠很好的解決耗時操做將操做系統卡住的問題。
事實上,cpu 的核心數量表明着真正的併發線程的數量,而單核 cpu 僅僅是在多線程間快速切換,使得看起來像是同時進行同樣。
線程一般至少擁有三種狀態,分別是:
線程在 3 種狀態間來回切換,以下圖所示:
線程調度從多任務操做系統開始就不斷被提出不一樣的方案和算法。主流方式儘管各不相同,但都有兩種經典算法的痕跡,一種是論轉法,另外一種是優先級調度。
輪轉法顧名思義,各個線程輪流執行一小段時間。特色是線程之間交錯執行。
優先級調度法則是按照線程優先級來進行調度,線程擁有各自的 線程優先級(Thread Priority),高優先級線程更早執行。
在優先級調度下,有一種 餓死(Starvation) 的現象,即 cpu 總被高優先級線程搶佔,致使某個低優先級線程永遠沒法執行。這裏有個很好的例子就是 OSSpinLock(自旋鎖)
固然,爲了不餓死現象的存在,一個線程如果等待了足夠長的時間,那麼它的優先級也會被提高上來。
咱們通常把線程頻繁等待的線程稱之爲IO 密集型線程(IO Bound Thread),而把不多等待的線程稱之爲 CPU 密集型線程。IO 密集型線程 老是比 CPU 密集型 線程容易獲得優先級的提高。
前面介紹了一些線程的概念與發展史,儘管只是冰山一角,但筆者已經感受到乏味了,所以直接帶來一個有趣的案例,以此案例引出後續。
見以下僞代碼段
int x = 0;
thread1: {
lock();
x++;
unlock();
};
thread2: {
lock();
x++;
unlock();
};
after thread1 & thread 2: {
print(x);
}
複製代碼
咱們分析代碼,認爲對了 x 進行了加鎖操做後,再進行自增操做,保證了自增操做的完整性。 可是在老的編譯器中,或者老的系統版本中,這種操做常常會輸出 x = 1。
這是爲何呢? 接下來咱們就來談談線程安全。
咱們先來看一個著名例子,兩個線程分別執行表中代碼
線程1 | 線程2 |
---|---|
i = 1; ++i; |
--i; |
在不少體系結構中,++i 的實現方法分爲以下三步:
因爲 線程1 和 線程2 併發執行,所以他們的執行序列可能以下表所示
序號 | 指令 | 執行後的變量值 | 線程 |
---|---|---|---|
1 | i=1 | i=1, X[1]=未知 | 線程1 |
2 | X[1]=i | i=1, X[1]=1 | 線程1 |
3 | X[2]=I | i=1, X[2]=1 | 線程2 |
4 | X[1]++ | i=1, X[1]=2 | 線程1 |
5 | X[2]-- | i=1, X[2]=0 | 線程2 |
6 | X[2]-- | i=2, X[1]=2 | 線程1 |
7 | X[2]-- | i=0, X[2]=0 | 線程2 |
能夠看出來,這裏錯的很是離譜,i 的結果多是 0、一、2,緣由就是自增操做被編譯爲彙編代碼後不止一條,頗有可能執行到一半被調度系統打斷,去執行了其餘代碼。
咱們把單指令的操做稱爲原子操做(Atomic),不管如何,單指令操做都不會被打斷。 事實上,若是你去看 libdispatch 源碼,會發現它定義了大量的原子性操做,例如:
但隨着邏輯愈來愈複雜,原子性操做顯然不能知足咱們的需求,這個時候咱們就要引入一個新的概念,鎖
鎖是一種很常見的同步方式,每個線程在訪問某個資源時先試圖 獲取(Aquire) 鎖,並在結束後釋放(Release) 該鎖。在鎖被佔用時試圖獲取鎖時,線程會等待,直到鎖從新可用。
鎖的種類有不少,這裏列舉一些,不做過多說明:
是否是以爲不少都很是眼熟?咱們平時用到的 pthread_mutex、NSLock、NSCondition、dispatch_semaphore、@synchronized 等都在上面。
這裏還要說起一個概念即可重入(Reentrant),一個函數能夠被重入,表示這個函數沒有執行完成,因爲外部因素或內部調用,又一次進入該函數執行。一個函數被稱爲可重入的,表示該函數被重入後沒有任何不良後果,是併發安全的強力保證。一個可重入的函數能夠在多線程環境下放心使用。
咱們從新看到上面的代碼段,按照前文的說明,咱們對於 x++ 這樣的非原子操做進行了加鎖處理,而且也保證了輸出 x 在兩次自增以後,爲何說 x 的值有可能爲 1 呢?
這裏就要提到一個編譯器優化,爲了提升 x 的訪問速度,會把 x 放入某個寄存器中,而咱們知道不一樣線程的寄存器是獨立的,所以產生了問題,程序順序以下所示:
5.[thread2] 將 R[2]寫回 6.[thread1] (好久之後) 將R[1] 寫回 x
由此,咱們引出引出一個關鍵字:volatile。
volatile 在各個語言環境中的意思並不相同,在 c 和 c++ 中,volatile 確保本條指令不會因編譯器的優化而省略,且要求每次直接讀值。
volatile 修飾的成員變量在每次被線程訪問時,都強迫從共享內存中重讀該成員變量的值。並且,當成員變量發生變化時,強迫線程將變化值回寫到共享內存。這樣在任什麼時候刻,兩個不一樣的線程老是看到某個成員變量的同一個值。
volatile 關鍵字能夠用來阻止過分優化,基本上能夠作到兩件事情:
顯然,在上面一個問題中,第一點是能夠被優化的,可是第二點是沒法知足的,由於咱們即便能夠阻止編譯器的過分優化,也沒法阻止 cpu 動態調換執行順序。
咱們來看一段貌似沒有問題的代碼,下面這段代碼是一段看似沒問題的單例代碼,注意這裏的 double-checked lock,旨在下降鎖粒度,具體妙用請讀者本身體會。
volatile T *pInstance = 0;
T * GetInstance() {
if (pInstance == NULL) {
lock();
if (pInstance == NULL) {
pInstance = new T;
}
unlock();
}
return pInstance;
}
複製代碼
乍看之下,這樣的代碼是沒問題的,lock() 和 unlock() 防止了多線程競爭致使的錯誤。但實際上會出現另外一個問題,問題的來源還是 cpu 的亂序執行。
C++中的new實際上包含了兩個步驟:
因此 pInstance = new T; 實際上包含了三個步驟:
在這三個步驟中,2 和 3 的位置其實是能夠任意調換的。也就是說可能會發生一種狀況:pInstance已經不爲空了,但構造函數尚未調用完成。所以當其餘線程訪問時,拿到了一個尚未被初始化的 pInstance,此時程序會如何表現就取決於這個類如何設計了。
由此咱們引出一個新的概念:內存屏障。
內存屏障 也叫作 內存柵障,增長 barrier 指令會阻止cpu將該指令以前的指令交換到 barrier 以後,反之亦然。你們能夠思考如下,咱們的 barrier 指令應該加到哪一行?
咱們以 Objective-C 中最經常使用的構建單例的代碼來看:
static T *_tInstance_ = nil;
+ (instancetype) shareInstance
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
_tInstance_ = [[T alloc] init];
});
return _tInstance_;
}
複製代碼
若是你熟悉 gcd 源碼的話,就會知道,在 dispatch_once 的實現中,增長了內存屏障,節選代碼以下:
dispatch_once_f(dispatch_once_t *val, void *ctxt, void (*func)(void *))
{
volatile long *vval = val;
if (dispatch_atomic_cmpxchg)(val, 0L, 1L) {
// 執行方法
func(ctxt);
// 增長內存屏障
dispatch_atomic_barrier();
*val = ~0L;
} else {
// 等待鎖
do {
_dispatch_hardware_pause();
} while (*vval != ~0L);
dispatch_atomic_barrier();
}
}
複製代碼
在這段代碼中,咱們能夠保證對象的構造與賦值過程必定在 dispatch_atomic_barrier() 前完成。固然,這裏不會出上述狀況的緣由是 鎖,這一點讀者能夠自行體會。
高級語言提供的一些 api 中常常會幫咱們處理這些事情,致使咱們忽視其中的細節,而這些細節每每須要咱們格外關心。
本篇文章大部分表現已通過時,但知識永遠不會過期,咱們學習到的底層知識、原理,都是幫咱們更好的認識計算機的世界,讓咱們能夠解決奇形怪狀的各類問題。
在學習知識時應該抱着嚴謹的態度,將理論與應用相結合。上文中提到的很是多的優化在實際開發中都已經沒有了。例如咱們上文中提到的 多線程中 x++ 的問題,實際上通過測試,即便不增長 volatile關鍵字 也不會出問題。(Objective-C & Apple clang version 11.0.3 (clang-1103.0.32.59))
咱們能夠寫個小 demo 來測試一下,環境同上:
{
int i = 1;
i++;
int j = i;
j++;
int k = j;
i++;
k = i;
}
複製代碼
咱們將編譯出來的 .app 文件丟到 hopper 中,查看對應的彙編代碼,以下圖所示:
咱們能夠看到,每次須要使用到以前的變量時,都會從內存從新讀取入寄存器,而每次操做完寄存器,都會從新寫入內存,並無將變量的值存放在寄存器不回寫的狀況。(若將編譯器優化開至 Fast / Fastest, Smallest 等,會發現更有意思的事情,請你們本身嘗試)
因此咱們無論是看書仍是看文章,對於看到的知識都應該抱有一個懷疑態度,多去嘗試,多去探究才能真正的進步。
文章中若有錯誤,歡迎指出。