你想要了解的線程知識都在這裏(一)

寫在前面

因爲本篇文章是線程開篇的第一篇文章,所以會着重介紹一些線程的基礎,與一些概念性的內容。部份內容引用參考資料,可能存在過於官方的狀況。不過系統性的學習也應當如此,但願你們能夠耐着性子看完。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 種狀態

線程一般至少擁有三種狀態,分別是:

  • 運行(Running):線程正在執行
  • 就緒(Ready):線程能夠執行,但 cpu 被佔用
  • 等待(Waiting):線程等待某一事件(一般是 I/O 或 同步)

線程在 3 種狀態間來回切換,以下圖所示:


圖片來自《程序員的自我修養——連接、裝載與庫》

輪轉法 和 優先級調度

線程調度從多任務操做系統開始就不斷被提出不一樣的方案和算法。主流方式儘管各不相同,但都有兩種經典算法的痕跡,一種是論轉法,另外一種是優先級調度。

輪轉法顧名思義,各個線程輪流執行一小段時間。特色是線程之間交錯執行。
優先級調度法則是按照線程優先級來進行調度,線程擁有各自的 線程優先級(Thread Priority),高優先級線程更早執行。

在優先級調度下,有一種 餓死(Starvation) 的現象,即 cpu 總被高優先級線程搶佔,致使某個低優先級線程永遠沒法執行。這裏有個很好的例子就是 OSSpinLock(自旋鎖)

固然,爲了不餓死現象的存在,一個線程如果等待了足夠長的時間,那麼它的優先級也會被提高上來。

I/O 密集型 與 cpu 密集型

咱們通常把線程頻繁等待的線程稱之爲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. 讀取 i 到某寄存器 X
  2. X++
  3. 將 X 的內容存儲回 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 源碼,會發現它定義了大量的原子性操做,例如:

  • __sync_add_and_fetch((p), 1) 先自加1,再返回
  • __sync_sub_and_fetch((p), 1) 先自減1,再返回
  • __sync_add_and_fetch((p), (v)) 先自加v,再返回
  • __sync_sub_and_fetch((p), (v)) 先自減v,再返回
  • __sync_fetch_and_or((p), (v)) 先返回,再進行或運算
  • __sync_fetch_and_and((p), (v))先返回,再進行與運算

同步與鎖

但隨着邏輯愈來愈複雜,原子性操做顯然不能知足咱們的需求,這個時候咱們就要引入一個新的概念,

鎖是一種很常見的同步方式,每個線程在訪問某個資源時先試圖 獲取(Aquire) 鎖,並在結束後釋放(Release) 該鎖。在鎖被佔用時試圖獲取鎖時,線程會等待,直到鎖從新可用。

鎖的種類有不少,這裏列舉一些,不做過多說明:

  • 二元信號量(Binary Semaphore)
  • 信號量(Semaphore)
  • 互斥量(Mutex)
  • 臨界區(Critical Section)
  • 讀寫鎖(Read-Write Lock)(Shared / Exclusive)
  • 條件變量(Condition Variable)

是否是以爲不少都很是眼熟?咱們平時用到的 pthread_mutex、NSLock、NSCondition、dispatch_semaphore、@synchronized 等都在上面。

這裏還要說起一個概念即可重入(Reentrant),一個函數能夠被重入,表示這個函數沒有執行完成,因爲外部因素或內部調用,又一次進入該函數執行。一個函數被稱爲可重入的,表示該函數被重入後沒有任何不良後果,是併發安全的強力保證。一個可重入的函數能夠在多線程環境下放心使用。

過分優化

咱們從新看到上面的代碼段,按照前文的說明,咱們對於 x++ 這樣的非原子操做進行了加鎖處理,而且也保證了輸出 x 在兩次自增以後,爲何說 x 的值有可能爲 1 呢?

這裏就要提到一個編譯器優化,爲了提升 x 的訪問速度,會把 x 放入某個寄存器中,而咱們知道不一樣線程的寄存器是獨立的,所以產生了問題,程序順序以下所示:

  1. [threa1] 讀取寄存器到 R[1]=0
  2. [thread1] R[1]++(暫時不將 R[1]寫回)
  3. [thread2] 讀取 x 的值到某個寄存器 R[2]=0
  4. [thread2] R[2]++

5.[thread2] 將 R[2]寫回 6.[thread1] (好久之後) 將R[1] 寫回 x

由此,咱們引出引出一個關鍵字:volatile

volatile 在各個語言環境中的意思並不相同,在 c 和 c++ 中,volatile 確保本條指令不會因編譯器的優化而省略,且要求每次直接讀值。

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實際上包含了兩個步驟:

  1. 分配內存
  2. 調用構造函數

因此 pInstance = new T; 實際上包含了三個步驟:

  1. 分配內存
  2. 在內存的位置上調用構造函數
  3. 將內存地址賦值給 pInstance

在這三個步驟中,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 中,查看對應的彙編代碼,以下圖所示:


Objective-C & Apple clang version 11.0.3 (clang-1103.0.32.59)

咱們能夠看到,每次須要使用到以前的變量時,都會從內存從新讀取入寄存器,而每次操做完寄存器,都會從新寫入內存,並無將變量的值存放在寄存器不回寫的狀況。(若將編譯器優化開至 Fast / Fastest, Smallest 等,會發現更有意思的事情,請你們本身嘗試)
因此咱們無論是看書仍是看文章,對於看到的知識都應該抱有一個懷疑態度,多去嘗試,多去探究才能真正的進步。

文章中若有錯誤,歡迎指出。

相關文章
相關標籤/搜索