作業系統併發的一些知識點梳理

霍丙南發表於2021-02-15

併發無論是在作業系統層面還是在程式語言層面,都是一個極為重要的概念。執行緒(thread)是對併發的一種抽象,經典觀念認為一個程式只有一個執行點(一個程式計數器,用來指向要執行的指令)。但是多執行緒(multi-thread)程式會有多個執行點(多個程式計數器)。換個角度來看,執行緒的概念類似於程式,有別於程式的地方就是多執行緒環境下,每個執行緒他們要共享地址空間,不同執行緒之間能夠訪問到共同的資料。

執行緒與程式十分相似,但又不同,程式是分時作業系統最早提出的一種任務排程模型。程式的出現使得作業系統擁有更好的互動性和更高的效率。在作業系統中,每個程式都有自己獨立的地址空間,各個程式之間相互隔離,互補干擾。

而執行緒可以看做是更細粒度的一種程式。但是執行緒必須依賴於程式存在,沒有獨立於程式的執行緒。程式是作業系統分配資源的最小單位,執行緒是作業系統排程的最小單位。

現代分時作業系統中大部分作業系統都支援執行緒,執行緒成為了CPU,作業系統排程的最小單元。然而執行緒不僅僅侷限於作業系統,執行緒這個抽象的概念也可以被程式設計語言去實現。所以按照執行緒的實現者的不同可以將執行緒分為兩類:

  1. 使用者執行緒:有程式設計語言實現(軟體實現),不依賴於作業系統。
  1. 核心執行緒:作業系統實現,作業系統負責排程。

有關程式

程式:一個具有獨立功能的程式在資料集合上的一次動態執行過程(程式的學術定義)

程式這個概念與程式,或者我們的程式碼有很大的聯絡。我們寫出的程式碼最終要變成計算機可以識別的二進位制語言儲存於記憶體中,程式可以看做是程式碼的一次動態執行。有人說,程式=資料結構+演算法。這種說法完全正確,但也可以退化來看:程式=資料+指令。所以程式就可以看做是資料和指令在計算機內的一次執行。

所以程式與程式的關係大致如下:

  1. 程式是產生程式的基礎。
  2. 程式每次執行構成了不同的程式。
  3. 程式是程式功能的體現。
  4. 一個程式可以對應多個程式,通過呼叫關係,一個程式又可以包括多個程式。

同時程式與程式的區別大致如下:

  1. 程式是一個動態的概念。程式是一個靜態的概念。程式是有序指令的集合,程式是程式的一次執行。
  2. 程式具有一定的時效性,它的執行週期可預期。

所以程式可以看作是程式的例項,程式可以看作是程式的模板。

程式的組成

  • code 程式碼
  • data 資料
  • PCB(Process Control Block) 程式控制塊

程式的特點

  • 動態性:程式可以被動態建立,也可以動態結束。
  • 併發性:程式可以被獨立排程,並佔用處理機執行。
  • 獨立性:不同程式之間是相互隔離,互不影響的。
  • 制約性:因訪問共享資源(資料)或程式間同步而受到制約。

PCB的構成

  • 程式標識資訊
    • 本程式標識
    • 父程式標識
    • 使用者標識
  • 處理器狀態資訊
    • 使用者可見暫存器
    • 控制和狀態暫存器:PC,PSW
    • 棧指標
  • 資源資訊

程式的生命週期

有關執行緒

執行緒是程式中的一條流程。從資源組合角度來看:程式把一組相關資源組合起來,構成了一個資源平臺(環境),包括地址空間(程式碼段,資料段),開啟的檔案等各類資源。從執行角度來看,程式碼在這個資源平臺上執行的一個流程稱為執行緒。

執行緒模型的優缺點

  • 優點
    • 一個程式可以存在多個執行緒(同時存在)。
    • 各個執行緒之間可以併發地執行。
    • 各個執行緒之間可以共享地址空間和檔案資源。
  • 缺點
    • 一個執行緒的崩潰,有可能導致其所屬程式的所有執行緒崩潰。

程式是資源分配的單位,執行緒是CPU排程的單位。程式擁有一個完整的資源平臺,而執行緒只獨享必不可少的資源,入暫存器和棧。同時執行緒具有與程式類似的五種狀態,但是執行緒比較輕量能夠減少併發時間和開銷,執行緒的輕量級主要體現在如下方面:

  1. 執行緒的建立時間很短。
  2. 執行緒的終止時間很短。
  3. 同一程式內執行緒的切換時間很迅速。
  4. 同一程式內不同執行緒之間共享記憶體和檔案等系統資源。

使用者執行緒和核心執行緒

  1. 使用者執行緒:使用者執行緒是作業系統無法感知的執行緒,它不是由作業系統建立、排程、管理。不依賴於作業系統核心,它由一組使用者級別的庫函式完成,通過使用者執行緒可以在不支援多執行緒模型的作業系統之上完成多執行緒程式設計。同時,使用者執行緒的切換無須經過作業系統核心,所以它的切換會很快,同時使用者還可以自己DIY執行緒的排程演算法。但是使用者態執行緒也有缺點,如果使用者執行緒發起一個阻塞的系統的呼叫,那麼它會阻塞整個程式內的所有使用者執行緒。同時作業系統將時間片分給了程式,而沒有直接分給執行緒,所以平均每個執行緒的執行時間會比較短,因此使用者態執行緒執行起來會比較慢。
  2. 核心執行緒:作業系統核心中實現一種機制(執行緒機制),由作業系統負責建立、排程、管理執行緒,使用者僅需發出執行緒建立相關的系統呼叫即可。但是核心執行緒的建立會經歷使用者態到核心態的轉變,所以開銷比使用者執行緒大,但是核心執行緒由作業系統管理,因此當其中一個執行緒發生阻塞時,並不會影響到同程式內其他執行緒的工作,同時核心執行緒分得的CPU時間較多,執行效率較高。

C語言環境程式建立程式碼


#include <stdio.h>

#include <pthread.h>

/* 執行緒任務函式 */

void *mythread(void *args) {

    printf("%s\n", (char*) args);

    return NULL;

}

int main() {

    pthread_attr_t p1Attr; /* 執行緒的屬性 */

    pthread_t p1; /* 執行緒 */

    int rc;

    pthread_attr_init(&p1Attr); /* 初始化執行緒屬性 */

    pthread_attr_setscope(&p1Attr, PTHREAD_SCOPE_SYSTEM); /*與作業系統繫結*/

    pthread_attr_setschedpolicy(&p1Attr, SCHED_RR); /* 輪詢的方式進行排程 */

    puts("Hello world\n");

    rc = pthread_create(&p1, &p1Attr, mythread, "A"); 

    puts("Start a new thread\n");

    rc = pthread_join(p1, NULL); 

    return 0;

}

pthread是POSIX Threads的簡稱。

POSIX,可移植作業系統介面(英文:Portable Operating System Interface)POSIX是IEEEE為要在各種UNIX作業系統上執行軟體,而定義的一系列作業系統API介面,正式名稱為IEEEE Std 1003,國際標準化組織名稱

ISO/IEC 9945。 目前Linux基本上逐步實現了POSIX的相容,但並未獲得正式的POSIX認證。微軟的Windows NT聲稱實現了部分POSIX標準。當前POSIX主要分為四部分:Base Definition、System Interfaces、Shell and Utillities、Rationale。

在Linux環境中,你可以使用<pthread.h>結合libpthread.so來建立執行緒,在Windows下可以使用MinGW結合pthread來建立執行緒,當然也可以使用<windows.h>中的windows API來建立執行緒,只不過<pthread.h>顯得更加標準和易使用,但需要平臺和工具的支援。

如你所見,執行緒的建立有點類似於函式的呼叫,然而,並不是首先執行函式然後返回給呼叫者,而是為呼叫的例程建立一個新的執行執行緒,它可以獨立於呼叫者執行,至於函式什麼時候被呼叫完全取決於作業系統(相應庫函式的排程策略)。開玩笑的說:如果一個程式設計師遇到了一個問題,他想要用多執行緒去解決,那麼他將面臨兩個問題。

那麼使用多執行緒併發會帶來哪些問題呢?

併發帶來的問題

併發固然可以提高程式的執行效率。但是同樣也帶來了許多沉重的代價,例如:

  1. 共享資料問題。
  2. 併發同步問題。
  3. BUG不易復現問題。

共享資料問題


#include <stdio.h>

#include <pthread.h>

static int counter = 0; /* 全域性變數 */

/* 對變數counter進行遞增操作 */

void *decrement(void *args) {

    printf("In thread %s\n", (char*)args);

    int i;

    for (i=0; i<100000; ++i)

        counter--;

    return NULL;

}

/* 對變數進行遞減操作 */

void *increment(void *args) {

    printf("In thread %s\n", (char*)args);

    int i;

    for (i=0; i<100000; ++i) 

        counter++;

    return NULL;

}

int main() {

    pthread_t p1,p2;

    int rc;

    rc = pthread_create(&p1, NULL, decrement, "DECREMENT");

    if (rc != 0) {

        printf("thread [DECREMENT] create error!\n");

        exit(-1);

    }

    rc = pthread_create(&p2, NULL, increment, "INCREMENT");

    if (rc != 0) {

         printf("thread [INCREMENT] create error!\n");

        exit(-1);

    }

    pthread_join(p1, NULL);

    pthread_join(p2, NULL);

    printf("counter = %d\n", counter);

    return 0;

}

上述C語言程式碼邏輯很簡單,一個執行緒對變數counter進行迴圈遞增操作,另一個執行緒對變數進行迴圈遞減操作,因為迴圈的次數是一樣的,所以我們預期的結果是,最終counter的值不會改變。但是實際執行結果並不是這樣。

上述程式碼執行結果的輸出具有不確定性。

從執行結果來看,這並不符合我們的預期,而且大大超出了我們的預期,因為多次執行,結果卻還不盡相同。

執行緒上下文和原子性

之所以會產生這樣的結果,根本原因在於執行緒在執行時處於不可控狀態。也就是說,你無法確定某一時刻某個執行緒是否在執行。當我們建立好執行緒之後,執行緒的執行與排程將交由作業系統,我們無法管理我們的執行緒。

執行緒的排程,一般採用時間片輪轉演算法進行排程,即給一個執行緒分配一定的執行實行例如2ms,2ms之後作業系統會將這個執行緒當前執行的狀態儲存到TCB(Thread Control Block,主要用於排程中恢復執行緒的執行現場), 這個TCB也稱為執行緒上下文。

正如我們所說,一個執行緒什麼時候被執行,什麼時候被掛起完全取決於作業系統,那麼當執行緒用完CPU時間片時,執行緒函式中程式碼停止的位置也具有一定的隨機性。但是這種隨機性是導致出現共享資料問題的原因嗎?

答案是:不全是。導致共享資料問題的原因不僅僅在於執行緒的排程,還取決於指令的原子性。我們寫的高階語言程式碼最終要編譯為二進位制資料儲存於記憶體中,那麼我們在高階語言中可以通過一行(一句)程式碼完成的事情,真正交給CPU去做的時候,可能需要好幾個步驟。

例如上述程式碼中的counter++counter--。這兩句程式碼看起來好像是一步就可以完成,但是CPU真正去執行的時候並不是。我們可以通過gcc -S [source file]的方式,去檢視編譯後的彙編程式碼。


movl	counter(%rip), %eax

subl	$1, %eax

movl	%eax, counter(%rip)

通過彙編程式碼我們可以看到,counter--需要三個步驟才可以完成。同時也要注意,我們說程式碼停止執行的位置具有隨機性,這個位置是對於最終的機器指令來說的。而不是針對於原始碼來說。

我們看到CPU在執行的時候,首先它要講counter從記憶體中轉移至暫存器中。然後對暫存器中的值加上立即數1,然後再將加1之後的暫存器中的值轉移至記憶體中。

我們可以將上述三個步驟分別用LOADCALCSTORE來代替。問題出現的關鍵點便在於,我們對資料進行CALC之後是否能及時的STORE至記憶體中,也就是,現在記憶體中的值,是否是一個最新的值(合理的值)。如果現在CALC之後,未來得及進行STORE操作就移交了CPU 的使用權,那麼其他執行緒讀取到的值,就不是一個合理的值。

那麼什麼是原子性,原子性就是我們期望事件不可再分。例如一條指令,我們期望他不會被分解為其他若干條指令。而是一次性,作為一個基本單元的去執行,並且在執行過程中不可能被中斷。

上述程式碼的問題就在於,我們把counter++counter--誤以原子指令的形式去執行。

值得注意的是,有時候一條彙編指令並不一定代表一條原子指令。即彙編指令也不能保障原子性。原子性的保障還需依靠硬體系統的微指令來保障

競態條件與臨界區

在多執行緒併發的環境下,多個執行緒在競爭著對同一資源物件進行操作,那麼這兩個執行緒將處於競態條件(Race Condition),競態條件下執行的程式碼結果依賴於併發執行或者事件的順序,這種結果往往具有不確定性和不可重現性。

臨界區(Critical section) 是指程式中一段需要訪問共享資源並且當另一個程式處於相應程式碼區域時便不會執行的程式碼區域。簡單說,臨界區就是訪問共享變數的程式碼段,這個程式碼段一定不能被多個執行緒同時執行。

臨界區的特點

  • 互斥性:同一時間,臨界區最多隻有一個執行緒進行訪問。
  • Progress:如果一個執行緒想要進入臨界區,那麼它最終會成功。
  • 有限等待:如果$執行緒_i​$出入臨界區入口,那麼$執行緒_i​$的請求被接受之前,其他執行緒進入臨界區時間是有限制的。
  • 無忙等待:如果一個執行緒在等待進入臨界區,那麼在此之前它可選擇無忙等待。(Optional)

臨界區是一種邏輯概念。那麼針對於臨界區的性質,有三種實現策略

  • 基於硬體中斷的實現。
  • 基於軟體
  • 更深層次的抽象

基於中斷的臨界區實現

在分時作業系統中,沒有時鐘中斷,就沒有上下文切換,就沒有併發。作業系統的排程器的實現就是依賴於時鐘中斷。那麼我們在實現臨界區的時候,可以在一個執行緒進入臨界區程式碼後主動禁用掉CPU對中斷的響應,線上程離開臨界區程式碼後,再開啟CPU對中斷的響應。這種實現可以實現良好的互斥性和其他臨界區的特性。

但是這種實現並不是最好的實現,因為禁用CPU中斷帶來的開銷非常大。一旦CPU中斷響應被禁止,那麼不僅僅是其他執行緒無法被排程,甚至一些基本的裝置請求,網路請求等都會受到影響。而且一旦我們臨界區程式碼的開銷也同樣巨大,那麼這種實現的效果就會很差。換言之,這種實現的粒度太大了。

同時這種實現只能作用於單核CPU,對於多核CPU,就不能保障臨界區的特性了。

基於軟體的實現

基於軟體的實現,就是利用一下資料結構+演算法,來實現臨界區的功能。

例如Bakery演算法:


do{

    flag[i] = TRUE

    turn = j

    while (flag[i] && turn == j);

    	進入臨界區

    flag[i] = FALSE

    	離開臨界區

}while(TRUE)

相對比基於中斷的實現方式,基於軟體的實現能夠達到一種細粒度的控制。但是基於軟體實現的方式會很複雜。

更深層次的抽象

鎖和訊號量。它們是作業系統提供的更高階的程式設計抽象用來解決臨界區問題。鎖和訊號量不僅能夠解決共享資料問題,同時他也可以解決執行緒間同步的問題,同時可以將我們程式碼的穩定性提高,降低出現BUG的風險。這兩個概念十分重要,它們是解決併發問題的關鍵,在下面的章節中會詳細的介紹。

這種更高層次的抽象,並不是上述兩種實現方法的Next Generation。而是借鑑了上述兩種實現方式之後的一個更為通用和抽象的解決方案。

鎖(Lock)

之前章節我們講過併發帶來的一個基本問題——共享資料。出現這個問題的原因與指令執行的原子性有關(具體有關原子性的概念可以參照之前講過的共享資料問題的哪一章節)。顯然,單純從指令的原子性上去避免共享資料問題有很大的難度,因為這個需要依賴於我們的硬體系統,需要硬體系統支援。

既然如此,那麼應該選用哪種方法既不依賴於硬體,還可以讓我們的程式碼原子性的去執行呢。我們可以從軟體層面藉助於一種資料結構去實現。這個資料結構便是鎖。

鎖是對於臨界區的一種實現,鎖本質上是一個資料結構。在程式設計中使用它,你可以像使用變數一樣去使用。鎖為程式設計師們提供了細粒度的併發控制。之前的章節我們講過,執行緒是由程式設計師建立,由作業系統排程的。換言之,我們建立了執行緒之後交給了作業系統我們就丟失了對執行緒的控制權。鎖這樣的一個資料結構能夠線上程排程方面幫助程式設計師們“曲線救國”。

如何去實現一個鎖

既然鎖是對於臨界區的一種實現,那麼鎖就應該具備臨界區的基本要求。可以這麼講,任何鎖都具備互斥性,這是臨界區的基本要求。那麼什麼是互斥性?互斥性就是在涉及到對共享的變數進行操作的程式碼時,我們必須保證只有一個執行緒在操作,而且這個執行緒必須執行完畢臨界區內的所有程式碼才可以讓出臨界區交給下一個執行緒處理。

鎖的實現不僅僅只是軟體層面的實現,當然僅靠軟體(編寫程式碼)去實現鎖也可以,但是這樣實現的鎖不是一個最佳的鎖。如果想要實現一個表現良好的鎖一定程度上還需要依賴於硬體系統。所以,一個表現良好的鎖是軟硬結合去實現的。

如何去評價鎖

我們說到表現良好的鎖,何為表現良好,怎麼去評價。換言之,一個表現的鎖體現在哪些方面上。

  1. 互斥性:最基本的條件,一個鎖是否可以阻止多個執行緒進入臨界區。
  2. 公平性:當鎖可用時,是否每個執行緒有公平的機會去搶到鎖,是否保障每個執行緒都有機會進入臨界區。
  3. 效能:鎖應用於高併發的場景,然而併發的初衷是為了提高效率,如果使用鎖帶來了很大的開銷,那就類似於捨本逐末,買櫝還珠了。

實現一個鎖

正如上圖所示,當一個執行緒獲得鎖之後,他可以執行臨界區中的程式碼。而沒有獲得鎖的執行緒只能排隊,直到獲取到鎖才可以執行臨界區的程式碼。這樣的設計保障了良好的互斥性。那麼應該如何去實現呢。

我們可以用一個變數(flag)來標誌鎖是否被某個執行緒佔用。

  1. 當第一個執行緒進入臨界區後,它要把這個標誌位設為1。
  2. 當一個執行緒想要進入臨界區時,它首先要檢查這個標誌位是否1。如果是1那麼證明鎖被某個執行緒佔用,所以它要等待鎖。
  3. 當執行緒執行完臨界區的程式碼時,它要將標誌位設為0,釋放鎖的的所有權,以便其他執行緒使用。

typedef struct lock_t {int flag;} lockt_t;

void init(lock_t *mutex) {
   mutex->flag = 0; /* 初始狀態為0 代表鎖未被任何執行緒持有*/
}

void lock(lock_t *mutex) {
    /* 自旋等待 */
    while (mutex->flag != 0); // spin-wait
    mutex->flag = 1;
}

void unlock(lock_t *mutex) {
    mutex->flag = 0;
}

/* thread code */
static lock_t mutex;
static int counter = 10;

{
    init(&mutex);
}

void decrement() {
    /* 嘗試進入臨界區 */
    mutex->lock();
    /* 進入臨界區 */
    counter++;
    /* 臨界區程式碼執行完畢,釋放鎖 */
    mutex->unlock();
    /* 退出臨界區 */
}

這樣實現的鎖有問題嗎?,我們可以測試一下。


static lock_t mutex;
static int counter = 0;
const static int LOOP_CNT = 10000;
void decrement() {
    counter--;
}
void increment() {
    counter++;
}
void *threadI(void *args) {
    printf("thread %s\n", (char*)args);
    int i;
    for (i = 0; i < LOOP_CNT; ++i) {
        lock(&mutex);
        increment();
        unlock(&mutex);
    }    
    return NULL;
}

void *threadD(void *args) {
    printf("thread %s\n", (char*)args);
    int i;
    for (i = 0; i < LOOP_CNT; ++i) {
        lock(&mutex);
        decrement();
        unlock(&mutex);
    }
    return NULL;
}

int main() {
    pthread_t th1,th2;
    init(&mutex);
    pthread_create(&th1, NULL, threadI, "threadI");、
    pthread_create(&th2, NULL, threadD, "threadD");
    pthread_join(th1, NULL);
    pthread_join(th2, NULL);
    printf("counter = %d\n", counter);
    return 0;
}

結果出現了點小意外。

雖然這種狀況出現的概率很小,但是出現即意味著我們在程式碼設計上有問題?那麼問題出在了哪裡呢。

問題便是,我們的鎖也是一個共享的變數,在併發場景下同樣會出現共享變數問題。也就是說我們對鎖進行操作的程式碼在CPU看來同樣不具備原子性。在我們實現鎖的程式碼中,在對flag標識為進行賦值時,如果作業系統排程中斷,那麼很有可能出現兩個執行緒同時將flag設定為1,同時擁有鎖的現象。顯然這連基本的互斥性都無法滿足,那麼這將是一個bad lock。那麼應該怎麼做,這就不得不依賴我們的硬體原語了。

test-and-set

test-and-set是一種硬體原語。這種硬體原語能夠保障指令的原子性。在SPARC上,這個指令叫做ldstub(load/store unsigned byte) 載入儲存無符號位元組。在x86平臺上,是xchg(atomic exchange, 原子交換指令)

因為這是一個硬體方面的原語,我們只能以C程式碼的形式來定義一下這個硬體原語做了什麼


int test_and_set(int *oldptr, int new) {
    int old = *oldptr;
    *oldptr = new;
    return old;
}

我們用test-and-set這個硬體語言去重新實現一下我們的鎖


void lock(lock_t *mutex) {
 /* 這段程式碼可以保證flag設定的原子性 */
    while (test_and_set(&mutex->flag, 1) == 1); // spin-lock
}

void unlock(lock_t *mutex) {
    mutex->flag = 0;
}

自旋鎖

我們通過上述C語言程式碼實現了一個簡單的鎖,其實這種鎖還有一個名字——自旋鎖。這種鎖的實現簡單,實現思路也非常的清晰明瞭,但是這個鎖是一個表現良好的鎖嗎? 在互斥性上,自旋鎖能夠做到良好的互斥性。但是從開銷方面來看,這個鎖並不是一個表現良好的鎖。為什麼這麼說呢?因為自旋鎖並沒有真正的讓其他執行緒去等待,用一個更為確切的詞語說,自旋鎖的策略是讓其他執行緒阻塞。 事實上,上述臨界區程式碼的執行過程中,沒有獲得鎖的執行緒同樣會獲得作業系統分給它的CPU時間片。只不過它阻塞在lock的while迴圈上,這種操作有點浪費CPU的資源,因為它在這段時間內什麼都沒做,只是在不斷的迴圈,直至把CPU時間片耗光,然後等待作業系統將CPU的使用權分配給其他執行緒,我們也稱這種等待為有忙等待。 ### 自旋鎖的後話 我們通過一個簡單的資料結構+硬體原語的支援實現了一個簡單的鎖。這個鎖具有良好的互斥性,但是我們詬病了這個鎖的資源浪費。那麼自旋真的就是不好的現象嗎?事實上,自旋並非一種壞事,任何事物要評價它的好與壞,一定程度上也要看事物作用的環境。有些場景下,自旋的確是一個不錯的選擇,例如Linux系統中有一種叫做兩階段鎖的自旋鎖(two-phase-lock),兩階段鎖就意識到自旋可能很有用,尤其是在一些臨界區程式碼很少,(如果臨界區程式碼很少,一個執行緒一個CPU時間片內就能執行完程式碼,並且釋放鎖,那麼在CPU時間片分給下一個執行緒時,下一個執行緒會立即進入臨界區並執行,這樣看來,自旋鎖的效率會很高,也沒有造成資源的浪費,或者在多核CPU的情況下,自旋鎖在某些場景下也有不錯的表現)執行緒很快會釋放鎖的場景。因此兩階段鎖的第一階段先會自旋一段時間,希望它能夠獲取到鎖。 自旋鎖的實現中,除了硬體原語test-and-set,還有一些其他的硬體原語也可以幫助自旋鎖進行原子性的加鎖操作。例如:compare-and-swap(CAS, 比較並交換),LL/SC(連結載入和條件式儲存指令)。 除此之外,還有一種公平的自旋鎖實現——ticket鎖。ticket鎖依賴於一個叫做fetch-and-add(獲取並增加)的硬體原語去實現,如果有興趣可以查閱相關資料瞭解ticket鎖的實現。

非自旋鎖的簡單實現

既然自旋鎖的確有一些不可避免的開銷,那麼我們如何去實現一個“完美”的鎖呢?既然沒有獲得鎖之前,執行緒會一直自旋等待,那麼有沒有辦法消除這種自旋等待呢?最簡單的辦法就是如果我不能獲取到鎖,我就跳出迴圈,這樣不就不會自旋了嗎?然而,我們跳出了迴圈,還必須要保障跳出迴圈之後不能進入臨界區,這就有點棘手了。好在作業系統為我們提供了一個API——yield。 yield的這個API的歷史很是久遠,最初它的設計初衷是為了便於作業系統進行多工的排程,所以期望程式設計師們在程式設計時可以在一些程式碼段中新增yield,一旦執行了yield函式,作業系統就會獲得CPU的使用權,進而作業系統會暫時中止掉當前的程式,轉而排程其他程式,這樣可以時多個程式併發的執行,提高作業系統的互動性和程式響應時間。但是這種設計無疑是加重了程式設計師們的負擔。所以這種方法最終沒有應用於作業系統的任務排程器上。 儘管yield沒有被用於任務排程器,但是對於當前我們面臨的問題似乎是一個很好的方案,我們可以在獲取不到鎖的時候呼叫yieldAPI,進而轉交控制權給作業系統,讓作業系統繼續排程其他執行緒,這樣看來只需對程式碼進行少量修改,就能對原來的自旋鎖進行一個不錯的優化。

void lock(lock_t *mutex) {      
    while (test_and_set(&mutex->flag, 1) == 1)           
    yield();  
}

然而事實上,這種實現也不是一個很好的方案,為什麼這麼說呢?儘管這樣避免了無意義的迴圈操作,但是這會讓作業系統陷入一種頻繁切換執行緒上下文的操作,這種操作的開銷也十分巨大。

假如我們當前有100個執行緒,只有一個執行緒獲取到了鎖,那麼作業系統在最壞的情況下要進行99次的執行緒上下文切換操作才可以重新將CPU使用權交給當前擁有鎖的執行緒。

這樣的實現雖然避免了自旋,但又讓執行緒進入了一種頻繁的進入-跳出操作,又讓作業系統執行了巨大的開銷。

使用佇列

既然自旋和yield都不是一個很好的選擇,那麼我們可以選擇使用佇列的方式的進行。


typedef struct lock_t {
    int flag;
    int guard;
    queue_t *q;
} lock_t; /* 使用佇列的鎖 */

void init(lock_t *mutex) {
    mutex->flag = 0;
    mutex->guard = 0;
    init_queue(mutex->q);
}

void lock(lock_t *mutex) {
      /* 自旋鎖 */
    /* 鎖的作用是保障只有一個執行緒完成佇列的新增&執行緒休眠工作和獲得鎖操作 */
    while (test_and_set(&mutex->guard, 1) == 1);
    if (mutex->flag == 0) {
        /* 當前還有執行緒獲得鎖 */
        mutex->flag = 1;
        mutex->guard = 0;
    } else {
        /* 已有執行緒獲得鎖,將此執行緒的ID號加入到等待佇列中,並休眠 */
        queue_add(mutex->q, gettid());
        m->guard = 0;
        park(); /* 執行緒休眠 */
    }
    /* 從這裡可以看出,自旋鎖在針對比較小的臨界區時,是很有效的 */
}

void unlock(lock_t *mutex) {
    /* 如果有執行緒正在嘗試加鎖,那麼要阻塞 */
    while (test_and_set(&mutex->guard, 1) == 1);
    if (queue_empty(mutex->q))
        mutex->flag = 0; /* 當前佇列中沒有執行緒想要獲得鎖,所以可以釋放 */
    else
        /* 當前佇列中有執行緒想要獲得鎖,所以喚醒一個執行緒即可 */
        /* 這裡無需做鎖的釋放操作,原因是park()API的使用特性,下面會做詳細講解 */
        unpark(queue_remove(mutex->q)); /* 喚醒一個執行緒 */
    mutex->guard = 0;
}

parkunpark同樣是作業系統API,Solaris系統提供了這兩個系統呼叫。這兩個系統呼叫的作用:

  1. park,讓執行緒睡眠。執行緒的狀態將處於阻塞狀態,一旦執行緒睡眠,他將不會獲得作業系統排程,直到被喚醒。

    同時當執行緒被喚醒時,被喚醒的執行緒會繼續從park()函式所在位置開始執行。這也就是我們在上述程式碼中喚醒執行緒之後而不用釋放鎖的原因。

  2. unpark,喚醒指定的睡眠的執行緒。

上述程式碼設計中其實有一個漏洞,那就是park操作不是原子的。也就是說,當一個執行緒被當前獲得鎖的執行緒喚醒時,他要從park函式開始執行,假如此時發生了中斷,作業系統要切換執行緒,那麼就會導致當前正在執行喚醒操作的執行緒永遠的睡眠下去。但是我們也不能將park操作放入由mutex-guard確定的自旋鎖中,這樣會導致死鎖問題。不過,Solaris作業系統意識到了這一點,它提供了一個新的setparkAPI幫助我們解決了這一原子性問題。


void lock(lock_t *mutex) {
    /* 嘗試進入臨界區 */
    while (test_and_set(&mutex->guard, 1) == 1);
    if (mutex->flag == 0) {
        mutex->flag = 1;
    mutex->guard = 0; /* 退出臨界區 */
    } else {
        queue_add(mutex->q, gettid());
    m->guard = 0; /* 退出臨界區 */
        setpark(); 
    }
}

訊號量

訊號量是一個整型數值的物件,它也是一種資料結構。這個資料有結構有兩個基本的原子操作:

  1. P操作,對訊號量整形數值進行減一操作,如果結果小於0,那麼將會阻塞。
  2. V操作,對訊號量整形數值進項加一操作,如果數值小於等於0,喚醒一個等待的執行緒。

訊號量這個概念是由一個出色的電腦科學家Dijkstra提出的,沒錯,他就是提出那個最短路勁演算法的大神。

訊號量看起來和我們說的鎖,有幾分相似,事實上,訊號量能夠完成鎖的功能,而且訊號量還能完成鎖不能完成的功能。我們可以根據功能將訊號量分為以下幾類:

  • 互斥訊號量,訊號量的整型數值初始為1,這時訊號量就是一個鎖。
  • 同步訊號量,訊號量的整型數值初始為0,同步訊號量類似於程式語言中的條件變數,它可以實現執行緒間的同步操作。
  • 計數訊號量。

二值訊號量(鎖)

二值訊號量可以當做一個互斥鎖來使用,我們給訊號量賦一個初始值1,此時這個訊號量就是一個互斥鎖。


import java.util.concurrent.Semaphore;

public class Sem {

    static class Number {

        int number = 0;

        Semaphore mutex = new Semaphore(1); /* 鎖 */

        public void decrement() throws InterruptedException {

            mutex.acquire();

            number--;

            mutex.release();

        }

        public void increment() throws InterruptedException {

            mutex.acquire();

            number++;

            mutex.release();

        }

    }

    static final int LOOP_CNT = 10000;

    public static void main(String[] args) throws InterruptedException {

        final Number number = new Number();

        Thread decrement = new Thread(() -> {

            try {

                for (int i=0; i<LOOP_CNT; ++i) {

                    number.decrement();

                }

            } catch (InterruptedException e) {

                e.printStackTrace();

            }

        }, "decrement");

        Thread increment = new Thread(() -> {

            try {

                for (int i=0; i<LOOP_CNT; ++i) {

                    number.increment();

                }

            } catch (InterruptedException e) {

                e.printStackTrace();

            }

        }, "increment");

        increment.start();

        decrement.start();

        increment.join();

        decrement.join();

        System.out.println("number is " + number.number);

    }

}

這樣的二值訊號量模擬鎖的邏輯也很好理解。當一個執行緒執行訊號量的P操作時(在Java中訊號量的P操作為acquire,訊號量的V操作為release)。它會先將訊號量中的整型數字作減一操作,因為訊號量初值為一,所以第一個對訊號量做P操作的執行緒不會被阻塞,進而進入臨界區執行臨界區程式碼。當第二個執行緒對訊號量做P操作時,它會發現,此時整型數字已經小於一了,所以它會阻塞住無法執行臨界區程式碼,直到先於它的執行緒對訊號量作了V操作,這個阻塞的執行緒才有可能被喚醒進而進入臨界區執行。

條件變數(Condition Variable)

當我們的訊號量初始值為零時,此時訊號量將作為一個條件變數來使用。例如下面的例子:


public class SemCondition {

    /* 構造一個Buffer,這個Buffer的最大容量為MAX_SIZE,如果超出了這個容量,就會自動清零 */

    static class Buffer {

        static final int MAX_SIZE = 500; /* 最大容量 */

        ArrayList<Integer> buffer = new ArrayList<>(); /* Buffer */

        /* 互斥鎖,因為我們的ArrayList是一個執行緒不安全的類,所以對他進行操作時,要加鎖 */

        Semaphore mutex = new Semaphore(1);

        /* 條件變數,當前的Buffer是否達到最大容量 */

        Semaphore isFull = new Semaphore(0);

        /* 條件變數,當前的Buffer是否已被清空 */

        Semaphore isEmpty = new Semaphore(0);

        void incBuffer() throws InterruptedException{

            mutex.acquire();

            buffer.add(0);

            if (buffer.size() > MAX_SIZE) {

                isFull.release();

                mutex.release(); /* 一定要在條件變數acquire之前釋放掉互斥鎖,不然就會死鎖 */

                isEmpty.acquire();

            } else  {

                System.out.println(Thread.currentThread().getName() + " => 新增了一個元素");

                mutex.release();

            }

        }

        void cleanBuffer() throws InterruptedException{

            mutex.acquire();

            while (buffer.size() <= MAX_SIZE) {

                mutex.release();

                isFull.acquire();

            }

            System.out.println("緩衝區滿了,正在清理....");

            Thread.sleep(2000);

            buffer.clear();

            isEmpty.release();

            mutex.release();

        }

    }

    public static void main(String[] args) {

        final Buffer buffer = new Buffer();

        for (int i=0; i<3; ++i) {

            new Thread(()->{

                while (true) {

                    try {

                        buffer.incBuffer();

                    } catch (InterruptedException e) {

                        e.printStackTrace();

                        break;

                    }

                }

            }, "inc_" + i).start();

        }

        new Thread(()->{

            while (true) {

                try {

                    buffer.cleanBuffer();

                } catch (InterruptedException e) {

                    e.printStackTrace();

                    break;

                }

            }

        }, "clean").start();

    }

}

相關文章