操做系統併發的一些知識點梳理

併發不管是在操做系統層面仍是在編程語言層面,都是一個極爲重要的概念。線程(thread)是對併發的一種抽象,經典觀念認爲一個程序只有一個執行點(一個程序計數器,用來指向要執行的指令)。可是多線程(multi-thread)程序會有多個執行點(多個程序計數器)。換個角度來看,線程的概念相似於進程,有別於進程的地方就是多線程環境下,每一個線程他們要共享地址空間,不一樣線程之間可以訪問到共同的數據。java

線程與進程十分類似,但又不一樣,進程是分時操做系統最先提出的一種任務調度模型。進程的出現使得操做系統擁有更好的交互性和更高的效率。在操做系統中,每一個進程都有本身獨立的地址空間,各個進程之間相互隔離,互補干擾。程序員

而線程能夠看作是更細粒度的一種進程。可是線程必須依賴於進程存在,沒有獨立於進程的線程。進程是操做系統分配資源的最小單位,線程是操做系統調度的最小單位。算法

現代分時操做系統中大部分操做系統都支持線程,線程成爲了CPU,操做系統調度的最小單元。然而線程不只僅侷限於操做系統,線程這個抽象的概念也能夠被程序設計語言去實現。因此按照線程的實現者的不一樣能夠將線程分爲兩類:編程

  1. 用戶線程:有程序設計語言實現(軟件實現),不依賴於操做系統。
  1. 內核線程:操做系統實現,操做系統負責調度。

有關進程

進程:一個具備獨立功能的程序在數據集合上的一次動態執行過程(進程的學術定義)windows

進程這個概念與程序,或者咱們的代碼有很大的聯繫。咱們寫出的代碼最終要變成計算機能夠識別的二進制語言存儲於內存中,進程能夠看作是代碼的一次動態執行。有人說,程序=數據結構+算法。這種說法徹底正確,但也能夠退化來看:程序=數據+指令。因此進程就能夠看作是數據和指令在計算機內的一次運行。安全

因此進程與程序的關係大體以下:網絡

  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();

    }

}
相關文章
相關標籤/搜索