關於併發控制的簡單整理

在開始之前,先引入一個情景:

環境是一個餐廳的廚房,人物當然是一羣廚師。

每當飯點,廚房的廚師需要做菜。廚師中又分有主廚,副廚,一些打下手的,還有學徒。顯然廚師之間需要配合協助,同時對於多個打下手的人之間又會出現一些競爭(比如:刀具的使用,食材的選擇,餐盤的使用等等),還有一些與做菜無關的人員,對於學徒,也許只是讓他們在旁邊拖拖地,洗洗盤,並沒有嚴格的融入到做菜這個行爲中去。

如果我們將這個情景抽象爲一個操作系統中的情景,那麼對應應該是這樣的,各種刀具,餐具,食材等等廚房的用具都是硬件資源,廚師(除了主廚)的行爲可以代表爲一個進程(或線程),廚師之間的配合規則是一個同步的機制,廚師之間對於某些資源的爭用,則是該片主要內容互斥問題。

引入廚房和廚師的介紹,希望可以讓大家對併發控制所涉及的處理對象有一個直觀的印象,對於併發控制的介紹,先從基礎開始(對於基礎已知,可跳過此處)。

和併發相關的關鍵術語
   術語                                                                                 說明
原子操作 一個或多個指令的序列,對外不可分;即沒有其他進程可以看到其中間狀態或中斷
臨界區 是一段代碼,在這段代碼中進程將訪問共享資源,當另外一個進程在這段代碼中運行時,這個進程
不能在這段代碼中執行
死鎖 兩個或兩個以上的進程因其中的每個進程都在等待其他進程昨晚某些事情而不能繼續執行的情形
互斥 當一個進程在臨界區訪問共享資源時,其他進程不能進入該臨界區訪問任何共享資源的情形
競爭條件 多個線程或者進程在讀寫一個共享數據是,結果依賴於他們執行的相對時間的情形
飢餓 指一個可運行的進程儘管能繼續執行,但被調度器無限期的忽視,而不能被調度執行的情況










進程的交互
交互程度 關係 進程之間的影響 潛在控制問題
進程之間不感知 競爭 *一個進程的結果與其他進程的活動無關
*進程的執行時間可能會受影響
*互斥
*死鎖(可複用的資源)
*飢餓
進程之間間接感知
(如共享對象)
共享合作 *一個進程的結構可能依賴於從其他進程獲得的消息
*進程的執行可能會受到影響
*互斥
*死鎖(可複用的資源)
*飢餓
*數據一致性
進程直接感知
(存在通信原語)
通信合作 *一個進程的結果可能依賴於從其他進程獲得的信息
*進程的即使可能會受到影響
*死鎖(可消費的資源)
*飢餓









(注:上表,均參考操作系統精髓與設計原理第6版中文譯本,陳向羣、陳渝,機械工業出版社)


由於上述進程交互之間的關係,同時多道程序設計系統中交替和重疊的執行模式下,進程的相對執行速度不可預測,所以會帶來一下困難。

1,全局資源的共享並不安全

2、很難做好資源的分配

3、程序設計錯誤很難調試。

併發控制就需要處理好上述的困難,也就是確保每個活躍的進程與執行速度無關時,爲每個活躍進程分配好資源,同時保護好數據和物理資源不被無意干涉。

那麼併發控制通過什麼樣的方式處理調解?

1、基於硬件,比如有中斷禁用,專用機器指令等

2、軟件方式,有Dekker‘s Algorithm,Peterson's Algorithm,Lamport's Algorithm等等

3、基於高級抽象的數據結構,有信號量,管程等等

按照上述的解決方式,分別介紹一兩種方法。

1、基於硬件

1)可以利用硬件的方式,來實施禁用中斷,保證程序在執行過程中不被打斷。

具體可以通過CPU發送電信號,使用寄存器中特定的中斷標識位來標識當前程序不可被中斷,其他程序等待當前進程結束並釋放共享資源後,交替進入共享資源。

這種方法可以解決單處理機上互斥的問題,同時帶來的一個缺陷是執行效率會很低;對於多處理機而言,這個方法完全不能達到互斥。

2)由於存在上述的問題,有人嘗試使用機器指令,實現在一個指令週期內的原子操作。

在操作系統概念(第七版)中,介紹的是TestAndSet和Swap原子指令,而在操作系統精髓與設計原理中介紹的是比較和交換,Exchange指令,兩書所述基本思想相同。在操作系統概念中給出了一個相對可行的基於機器指令的算法,此處引用。

do {
    waiting[i] = true;//此處的waiting數組代表進程是否等待,等待爲true,反之爲false
    key = true;//表示臨界區是否可用
    while(waiting[i] && key)
         key = TestAndSet(&lock);//其中的全局變量lock表示臨界區是否被當前進程鎖定
    waiting[i] = false;//當前進程設爲忙碌
    //critical section
    j = (i + 1) % n;
    while((j != i) && !wait[j])
         j = (j + 1) % n;
    if(j == i)//如果遍歷所有進程,發現沒有進程申請試用臨界區,則將臨界區標記爲未鎖定;如果發現其中有進程等待臨界區,則將等待進程設爲忙碌
        lock = false;
    else
        waiting[j] = false;
    //remainder section
}while(true);
以下是TestAndSet原子操作

bool TestAndSet(bool *target)
{
    bool rv = *target;
    *target = true;
    return rv;
}
以上算法,通過lock和waiting數組保證了互斥操作和進程可執行,通過對進程的遍歷保證了進程的有限等待。

基於硬件的方式,雖然簡單易證明其正確性,在單處理機上易於實現多個進程,改進後的機器指令適用於共享內存的多處理機上的多個進程和多個臨界區,但是忙等待的出現以及可能出現飢餓和死鎖現象,不得不尋求更好的解決方法。

2、軟件方法

此處看看使用軟件的方法能否解決硬件方式的缺陷。

關於Dekker's Algorithm由於之前學習過,則直接提供一個鏈接。

此處討論Peterson's Algorithm和Lamport's bakery Algorithm 
瞭解過Dekker's Algorithm之後,發現代碼實在難懂,這裏給出Peterson's Algorithm的代碼。

bool flag[2];//表示兩個進程是否進入臨界區
int turn;//表示臨界區哪個進程佔用
void p0() {
    while(true) {
        flag[0] = true;//表示進程0申請進入臨界區
        turn = 1;//允許進程1進入臨界區
        while(flag[1] && turn == 1)//進程1申請進入的同時,臨界區允許進入,則進程0等待
            ;/*do nothing*/
        /*critical section*/
        flag[0] = false;//進程0退出臨界區
        /*remainder section*/
}
void p1() {//思想相同,對象不同
    while(true) {
        flag[1] = true;
        turn = 0;
        while(flag[0] && turn == 0)
            ;/*do nothing*/
        /*critical section*/
        flag[1] = false;
        /*remainder section*/
}
這種方法與Dekker' s Algorithm類似,通過進程申請進入臨界區,之後由臨界區判斷,哪個進程佔用臨界區,從而達到互斥的要求,進程正常執行並且能有限等待。

當進程0申請進入臨界區,由於中斷的不確定性,turn的值將不確定,但是可以確定turn值是0或1中的某一個。

當turn的值是1時,同樣的原因,flag[1]不確定,

當其值爲true時,進程0因爲while循環而等待,直到進程1釋放臨界區資源;當其值爲false時,則進程0訪問臨界區,之後必定會使的turn爲0(不論是中斷還是順序執行),然後進程1等待,直到進程0釋放臨界區。

當turn的值是0時,同上述分析,是可行的。

同樣的Peterson's Algorithm也有忙等待的缺陷,從代碼看,只能處理兩個進程,並不能使用多個(當然也可推廣爲多個,此處)。

接下來介紹能處理多個進程的Lampost's bakery Algorithm。

int ticket[n];//表示進程所在隊列中的編號,如果爲零,則表示未進入隊列
bool entering[n];//表示進程準備進入隊列
void lock(int pid) {
    entering[pid] = true;//進程pid準備進入隊列
    int Max = 0;//隊列中的最大編號
    for(int i = 0; i < n; ++i) {//通過for循環,尋找最大編號
        int current = ticket[pid];
        Max = current > Max? current: Max;//更新最大值
    }
    ticket[pid] = Max + 1;//添加到隊列最後
    entering[pid] = false;//表示進程pid已經進入隊列
    for(int i = 0; i < n; ++i) {//查看除了當前的進程,是否還有其他進程使用臨界區
        if(i != pid) {
            while(entering[i] == 1)//保證進程擁有編號
                ;
            while(ticket[i] != 0 && ( ticket[pid] > ticket[i] || ( ticket[pid] == ticket[i] && pid > i) ) )
                ;//保證編號較小的進程優先,或者在相同編號下次序較小的優先
        }
    }
    /* critical section */
}
void unlock(int pid) {
    ticket[pid] = 0;//退出臨界區,即退出隊列
}
該算法的思想來自於麪包店買麪包的情況,先來先服務。當然如果已經買了麪包,發現漏買某種麪包的時候,需要重新排隊購買。

在模擬一下該情景,對於每一個要買麪包的人,需要先領取服務的編號,也就是在隊列中的編號,如果發現領取了相同的編號,則在隊列中先來的先服務。

如果是先來先服務,那麼是不是可以捨棄entering數組呢?此處不可捨棄。考慮這種情況,如果捨棄entering數組,那麼有兩個進程i1和進程i2,i1 小於i2,當進程i2先進入lock函數時,獲得編號,由於中斷的存在進程i1未分配編號但是其Max的值爲0,之後又是進程i2執行由於進程i1未獲得編號,從而進入臨界區;之後進程i1由於同進程i2擁有相同的編號,但是優先級小於進程i2,也順利進入臨界區。也就是通過entering數組保證了該算法的原子性,其中while(entering[i] == 1)就是爲了進程i獲得一個編號,避免上述情況的出現。

同樣的該算法軟件實現的算法相似,都需要忙等待(維基百科中如果對象是線程則,可使用yield交出線程)
從上述軟件的實現方式看,都不得不使用忙等待,這種佔用寶貴CPU資源的做法,實在無法容忍。於是採用高級抽象的數據結構的方式孕育而生。
3、基於高級抽象數據結構
首先我們先來看看信號量。

信號量三個基本操作

A 一個信號量初始化未非負整數

B semWait操作是信號量減1,如果爲負,則執行semWait的進程阻塞;否則進程執行。

C semSignal操作是信號量加1,如果信號量爲非正,則被semWait操作阻塞的進程解除阻塞;否則什麼也不做。

對於A操作,

如果是二元信號量(只有1和0兩種取值),如果取值爲1表示臨界區允許訪問;反之,臨界區拒絕訪問。

如果是計數信號量,那麼信號量的初值代表共享資源的數目;當信號量的值爲零時,表示共享資源分配無剩餘;當信號量爲負時,其絕對值表示正在等待的進程數目。

對於B,C操作,

關於二元信號量代碼如下

typedef struct {
    enum {zero, one} value;//二元信號量的取值
    queue<process> process;//阻塞隊列
}binary_semaphore;//二元信號量類型
void semWaitB(binary_semaphore s) {//判斷是否阻塞當前進程。如果臨界區允許訪問,則執行當前進程,並將臨界區設爲不可訪問;否則添加到阻塞隊列中掛起
    if(s.value == one) s.value = zero;
    else {
        /*add this process to qprocess*/
        block();
    }
}
void semSignalB(semaphore s) {//判斷是否喚醒阻塞隊列中的進程。如果阻塞隊列中爲空,則將臨界區設爲可訪問;否則將阻塞隊列中的進程移除並喚醒
    if(!s.qprocess.empty()) s.value = zero;
    else {
        /*remove a process P from qprocess*/
        wakeup(P);
    }
}

對於計數信號量代碼如下

typedef struct {
    int count;//資源數量
    queue<process> process;//阻塞隊列
}semaphore;//計數信號量類型
void semWait(semaphore s) {//對計數器減1操作後,判斷其是否爲負數。爲負則已無共享資源,將進程掛起到阻塞隊列中;爲非負,則是資源分配出一份給進程
    s.count--;
    if(s.count < 0) {
        /*add this process to qprocess*/
        block();
    }
}
void semSignal(semaphore s) {//對計數器加1操作後,判斷其值是否爲非正。爲非正則阻塞隊列中有進程阻塞,喚醒並執行;爲正,則是資源有剩餘,不做任何操作
    s.count++;
    if(s.count <= 0) {
        /*remove a process P form qprocess*/
        wakeup(P);
    }
}
嘗試分析其是否可行是發現,其原子性是如何保證的?

其原子性是基於禁用中斷或使用軟件方式獲得的。沒錯這樣的方式會引入這兩種方式的缺陷,忙等待等等。對於這個問題操作系統概念(第7中文版)和操作系統精髓與設計原理(第6中文版)都給出了相同的解釋。

使用軟件方法是會引起忙等待這種消耗CPU處理能力的,但是由於對整個進程的臨界區的處理內容的多少是不確定的,處理的時間也是不確定的,如果很長時間的佔用臨界區,勢必會極大的浪費CPU資源,如果這種浪費是在我們可以接受的範圍以內,自然也就可以接受。由於對信號量的處理函數規模較小,忙等待的現象也就顯得不是那麼明顯。如果是單處理機,則使用禁用中斷的方式;如果是多處理機,則使用軟件方式。單處理中直接禁用中斷,即可確保其原子操作,而在多處理機上未必可行。基於多處理機的優點,使用軟件方式更合適,因爲在忙等待的時候佔用某個處理機的時候,可以使用其他的處理機進行其它希望進入臨界區的進程。

基於原子性操作的信號量處理函數,如果使用硬件方式處理,則參考上述的使用機器指令的代碼,至於禁用中斷,在函數內第一行和函數內末尾分別使用禁用中斷和允許中斷即可,如果使用軟件的方式,則參看軟件方式處理雙進程的的Dekker's Algorithm和Peterson's Algorithm。

接下來,看一下如何使用信號量來處理進程之間訪問臨界區的方法

semaphore mutex;
void p() {
    semWait(mutex);
    /*critical section*/
    semSignal(mutex);
    /*remainder section*/
}
由於semWait()和semSignal()上面已經論證了具有原子性,同時根據也可以保證這兩個原子操作是可靠的,也就是能達到互斥並做到有限等待的。所以使用信號量可以達到併發控制的要求。

當然信號量也是有缺陷的,由於信號量作爲一個進程之外的數據單獨處理,在處理複雜進程的時候,很難發現其是否遺漏,是否順序是否正確,信號量操作是否寫錯等等。信號量過於靈活,就像goto語句一樣,會在編程時就出現未知的錯誤。

由於上述問題的出現,於是又出現了新的數據結構——管程。

管程是更高層次的抽象,該類型提供了一組由程序員定義的、在管程內互斥的操作(類似於在面向對象的程序設計中稱之爲方法),一組變量類型的申明,對其內部變量操作的子程序和函數的實現。它能夠確保其內部的變量只能被其內部的操作、函數等訪問,同樣的其內部的操作、函數等也只能訪問內部變量,同時確保只有一個進程在管程中執行。

引用操作系統概念(第7中文版)中的管程的定義

monitor monitorName {
    //shared variable declarations
    process P1(...) {//各個進程
        ...
    }
    process P2(...) {
        ...
    }

    ...

    process Pn(...) {
        ...
    }
    initialization code(...) {//初始化代碼
        ...
    }
}
這只是一個數據結構,類似於信號量是否有其他的操作?

管程通過一個條件變量,來控制互斥與同步的機制,但是由於管程之間並沒有保證互斥,於是又借用了上面所說的信號量來實現管程中條件變量的控制。用信號量處理,還是那句話,有缺陷但是系統是可以接受的。
此處不給出相關使用管程實現互斥的方法,如果需要可以參考操作系統概念(第7中文版)183頁至185頁上關於管程實現的例子與方法或參考該鏈接

管程通過monitorName.cWait(condition c)和monitorName.cSignal(condition c)兩個原子操作改變條件變量。

其中的操作

cWait(condition c):調用進程的執行在條件c上掛起,管程可被另一進程佔用。

cSignal(condition c):再掛起進程中選擇一個恢復執行,如果沒有這樣的進程則什麼也不做。

也就是說進程如果爲通過與條件變量產生關聯,則該進程的請求不能在管程中執行,此處於信號量有區別。

此外在執行cSignal(condition c)時,有兩種方式

A 喚醒等待,進程Pi等待新來的進程Pj離開管程或者等待另一個條件

B 喚醒繼續,Pj喚醒並等待Pi離開管程或等待另一個條件

兩種方式均可行,A方式多用於教材,B方式多用於工業,前者更安全,後者效率則更高。

上面只是簡單介紹了併發控制的一些方法,還有許多沒有涉及。最後引用Tsinghua University關於操作系統課程PPT講義上的圖,結束全文。


從上圖我們可以瞭解到操作系統與底層硬件之間的關係極爲密切,操作系統必須通過底層來完成併發控制。以上之所以從硬件一直到抽象數據類型,不僅僅是因爲歷史原因,而是前輩的從當時的水平不斷的改進當時的歷程。

如有不當之處,請各位批評指正,謝謝。


發佈了48 篇原創文章 · 獲贊 10 · 訪問量 3萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章