一文帶你懟明白進程和線程通訊原理

進程間通訊

進程是須要頻繁的和其餘進程進行交流的。例如,在一個 shell 管道中,第一個進程的輸出必須傳遞給第二個進程,這樣沿着管道進行下去。所以,進程之間若是須要通訊的話,必需要使用一種良好的數據結構以致於不能被中斷。下面咱們會一塊兒討論有關 進程間通訊(Inter Process Communication, IPC) 的問題。html

關於進程間的通訊,這裏有三個問題java

  • 上面提到了第一個問題,那就是一個進程如何傳遞消息給其餘進程。
  • 第二個問題是如何確保兩個或多個線程之間不會相互干擾。例如,兩個航空公司都試圖爲不一樣的顧客搶購飛機上的最後一個座位。
  • 第三個問題是數據的前後順序的問題,若是進程 A 產生數據而且進程 B 打印數據。則進程 B 打印數據以前須要先等 A 產生數據後纔可以進行打印。

須要注意的是,這三個問題中的後面兩個問題一樣也適用於線程程序員

第一個問題在線程間比較好解決,由於它們共享一個地址空間,它們具備相同的運行時環境,能夠想象你在用高級語言編寫多線程代碼的過程當中,線程通訊問題是否是比較容易解決?算法

另外兩個問題也一樣適用於線程,一樣的問題可用一樣的方法來解決。咱們後面會慢慢討論這三個問題,你如今腦子中大體有個印象便可。shell

競態條件

在一些操做系統中,協做的進程可能共享一些彼此都能讀寫的公共資源。公共資源可能在內存中也可能在一個共享文件。爲了講清楚進程間是如何通訊的,這裏咱們舉一個例子:一個後臺打印程序。當一個進程須要打印某個文件時,它會將文件名放在一個特殊的後臺目錄(spooler directory)中。另外一個進程 打印後臺進程(printer daemon) 會按期的檢查是否須要文件被打印,若是有的話,就打印並將該文件名從目錄下刪除。編程

假設咱們的後臺目錄有很是多的 槽位(slot),編號依次爲 0,1,2,...,每一個槽位存放一個文件名。同時假設有兩個共享變量:out,指向下一個須要打印的文件;in,指向目錄中下個空閒的槽位。能夠把這兩個文件保存在一個全部進程都能訪問的文件中,該文件的長度爲兩個字。在某一時刻,0 至 3 號槽位空,4 號至 6 號槽位被佔用。在同一時刻,進程 A 和 進程 B 都決定將一個文件排隊打印,狀況以下數組

墨菲法則(Murphy) 中說過,任何可能出錯的地方終將出錯,這句話生效時,可能發生以下狀況。緩存

進程 A 讀到 in 的值爲 7,將 7 存在一個局部變量 next_free_slot 中。此時發生一次時鐘中斷,CPU 認爲進程 A 已經運行了足夠長的時間,決定切換到進程 B 。進程 B 也讀取 in 的值,發現是 7,而後進程 B 將 7 寫入到本身的局部變量 next_free_slot 中,在這一時刻兩個進程都認爲下一個可用槽位是 7 。安全

進程 B 如今繼續運行,它會將打印文件名寫入到 slot 7 中,而後把 in 的指針更改成 8 ,而後進程 B 離開去作其餘的事情服務器

如今進程 A 開始恢復運行,因爲進程 A 經過檢查 next_free_slot也發現 slot 7 的槽位是空的,因而將打印文件名存入 slot 7 中,而後把 in 的值更新爲 8 ,因爲 slot 7 這個槽位中已經有進程 B 寫入的值,因此進程 A 的打印文件名會把進程 B 的文件覆蓋,因爲打印機內部是沒法發現是哪一個進程更新的,它的功能比較侷限,因此這時候進程 B 永遠沒法打印輸出,相似這種狀況,即兩個或多個線程同時對一共享數據進行修改,從而影響程序運行的正確性時,這種就被稱爲競態條件(race condition)。調試競態條件是一種很是困難的工做,由於絕大多數狀況下程序運行良好,但在極少數的狀況下會發生一些沒法解釋的奇怪現象。不幸的是,多核增加帶來的這種問題使得競態條件愈來愈廣泛。

臨界區

不只共享資源會形成競態條件,事實上共享文件、共享內存也會形成競態條件、那麼該如何避免呢?或許一句話能夠歸納說明:禁止一個或多個進程在同一時刻對共享資源(包括共享內存、共享文件等)進行讀寫。換句話說,咱們須要一種 互斥(mutual exclusion) 條件,這也就是說,若是一個進程在某種方式下使用共享變量和文件的話,除該進程以外的其餘進程就禁止作這種事(訪問統一資源)。上面問題的糾結點在於,在進程 A 對共享變量的使用未結束以前進程 B 就使用它。在任何操做系統中,爲了實現互斥操做而選用適當的原語是一個主要的設計問題,接下來咱們會着重探討一下。

避免競爭問題的條件能夠用一種抽象的方式去描述。大部分時間,進程都會忙於內部計算和其餘不會致使競爭條件的計算。然而,有時候進程會訪問共享內存或文件,或者作一些可以致使競態條件的操做。咱們把對共享內存進行訪問的程序片斷稱做 臨界區域(critical region)臨界區(critical section)。若是咱們可以正確的操做,使兩個不一樣進程不可能同時處於臨界區,就能避免競爭條件,這也是從操做系統設計角度來進行的。

儘管上面這種設計避免了競爭條件,可是不能確保併發線程同時訪問共享數據的正確性和高效性。一個好的解決方案,應該包含下面四種條件

  1. 任什麼時候候兩個進程不能同時處於臨界區
  2. 不該對 CPU 的速度和數量作任何假設
  3. 位於臨界區外的進程不得阻塞其餘進程
  4. 不能使任何進程無限等待進入臨界區

從抽象的角度來看,咱們一般但願進程的行爲如上圖所示,在 t1 時刻,進程 A 進入臨界區,在 t2 的時刻,進程 B 嘗試進入臨界區,由於此時進程 A 正在處於臨界區中,因此進程 B 會阻塞直到 t3 時刻進程 A 離開臨界區,此時進程 B 可以容許進入臨界區。最後,在 t4 時刻,進程 B 離開臨界區,系統恢復到沒有進程的原始狀態。

忙等互斥

下面咱們會繼續探討實現互斥的各類設計,在這些方案中,當一個進程正忙於更新其關鍵區域的共享內存時,沒有其餘進程會進入其關鍵區域,也不會形成影響。

屏蔽中斷

在單處理器系統上,最簡單的解決方案是讓每一個進程在進入臨界區後當即屏蔽全部中斷,並在離開臨界區以前從新啓用它們。屏蔽中斷後,時鐘中斷也會被屏蔽。CPU 只有發生時鐘中斷或其餘中斷時纔會進行進程切換。這樣,在屏蔽中斷後 CPU 不會切換到其餘進程。因此,一旦某個進程屏蔽中斷以後,它就能夠檢查和修改共享內存,而不用擔憂其餘進程介入訪問共享數據。

這個方案可行嗎?進程進入臨界區域是由誰決定的呢?不是用戶進程嗎?當進程進入臨界區域後,用戶進程關閉中斷,若是通過一段較長時間後進程沒有離開,那麼中斷不就一直啓用不了,結果會如何?可能會形成整個系統的終止。並且若是是多處理器的話,屏蔽中斷僅僅對執行 disable 指令的 CPU 有效。其餘 CPU 仍將繼續運行,並能夠訪問共享內存。

另外一方面,對內核來講,當它在執行更新變量或列表的幾條指令期間將中斷屏蔽是很方便的。例如,若是多個進程處理就緒列表中的時候發生中斷,則可能會發生競態條件的出現。因此,屏蔽中斷對於操做系統自己來講是一項頗有用的技術,可是對於用戶線程來講,屏蔽中斷卻不是一項通用的互斥機制。

鎖變量

做爲第二種嘗試,能夠尋找一種軟件層面解決方案。考慮有單個共享的(鎖)變量,初始爲值爲 0 。當一個線程想要進入關鍵區域時,它首先會查看鎖的值是否爲 0 ,若是鎖的值是 0 ,進程會把它設置爲 1 並讓進程進入關鍵區域。若是鎖的狀態是 1,進程會等待直到鎖變量的值變爲 0 。所以,鎖變量的值是 0 則意味着沒有線程進入關鍵區域。若是是 1 則意味着有進程在關鍵區域內。咱們對上圖修改後,以下所示

這種設計方式是否正確呢?是否存在紕漏呢?假設一個進程讀出鎖變量的值並發現它爲 0 ,而剛好在它將其設置爲 1 以前,另外一個進程調度運行,讀出鎖的變量爲0 ,並將鎖的變量設置爲 1 。而後第一個線程運行,把鎖變量的值再次設置爲 1,此時,臨界區域就會有兩個進程在同時運行。

也許有的讀者能夠這麼認爲,在進入前檢查一次,在要離開的關鍵區域再檢查一次不就解決了嗎?實際上這種狀況也是於事無補,由於在第二次檢查期間其餘線程仍有可能修改鎖變量的值,換句話說,這種 set-before-check 不是一種 原子性 操做,因此一樣還會發生競爭條件。

嚴格輪詢法

第三種互斥的方式先拋出來一段代碼,這裏的程序是用 C 語言編寫,之因此採用 C 是由於操做系統廣泛是用 C 來編寫的(偶爾會用 C++),而基本不會使用 Java 、Modula3 或 Pascal 這樣的語言,Java 中的 native 關鍵字底層也是 C 或 C++ 編寫的源碼。對於編寫操做系統而言,須要使用 C 語言這種強大、高效、可預知和有特性的語言,而對於 Java ,它是不可預知的,由於它在關鍵時刻會用完存儲器,而在不合適的時候會調用垃圾回收機制回收內存。在 C 語言中,這種狀況不會發生,C 語言中不會主動調用垃圾回收回收內存。有關 C 、C++ 、Java 和其餘四種語言的比較能夠參考 連接

進程 0 的代碼

while(TRUE){
  while(turn != 0){
    /* 進入關鍵區域 */
    critical_region();
    turn = 1;
    /* 離開關鍵區域 */
    noncritical_region();
  }
}

進程 1 的代碼

while(TRUE){
  while(turn != 1){
    critical_region();
    turn = 0;
    noncritical_region();
  }
}

在上面代碼中,變量 turn,初始值爲 0 ,用於記錄輪到那個進程進入臨界區,並檢查或更新共享內存。開始時,進程 0 檢查 turn,發現其值爲 0 ,因而進入臨界區。進程 1 也發現其值爲 0 ,因此在一個等待循環中不停的測試 turn,看其值什麼時候變爲 1。連續檢查一個變量直到某個值出現爲止,這種方法稱爲 忙等待(busywaiting)。因爲這種方式浪費 CPU 時間,因此這種方式一般應該要避免。只有在有理由認爲等待時間是很是短的狀況下,纔可以使用忙等待。用於忙等待的鎖,稱爲 自旋鎖(spinlock)

進程 0 離開臨界區時,它將 turn 的值設置爲 1,以便容許進程 1 進入其臨界區。假設進程 1 很快便離開了臨界區,則此時兩個進程都處於臨界區以外,turn 的值又被設置爲 0 。如今進程 0 很快就執行完了整個循環,它退出臨界區,並將 turn 的值設置爲 1。此時,turn 的值爲 1,兩個進程都在其臨界區外執行。

忽然,進程 0 結束了非臨界區的操做並返回到循環的開始。可是,這時它不能進入臨界區,由於 turn 的當前值爲 1,此時進程 1 還忙於非臨界區的操做,進程 0 只能繼續 while 循環,直到進程 1 把 turn 的值改成 0 。這說明,在一個進程比另外一個進程執行速度慢了不少的狀況下,輪流進入臨界區並非一個好的方法。

這種狀況違反了前面的敘述 3 ,即 位於臨界區外的進程不得阻塞其餘進程,進程 0 被一個臨界區外的進程阻塞。因爲違反了第三條,因此也不能做爲一個好的方案。

Peterson 解法

荷蘭數學家 T.Dekker 經過將鎖變量與警告變量相結合,最先提出了一個不須要嚴格輪換的軟件互斥算法,關於 Dekker 的算法,參考 連接

後來, G.L.Peterson 發現了一種簡單不少的互斥算法,它的算法以下

#define FALSE 0
#define TRUE  1
#define N     2                             /* 進程數量 */

int turn;                                       /* 如今輪到誰 */
int interested[N];                              /* 全部值初始化爲 0 (FALSE) */

void enter_region(int process){                 /* 進程是 0 或 1 */
  
  int other;                                    /* 另外一個進程號 */
  
  other = 1 - process;                          /* 另外一個進程 */
  interested[process] = TRUE;                   /* 表示願意進入臨界區 */
  turn = process;
  while(turn == process 
        && interested[other] == true){}                          /* 空循環 */
  
}

void leave_region(int process){
  
  interested[process] == FALSE;              /* 表示離開臨界區 */
}

在使用共享變量時(即進入其臨界區)以前,各個進程使用各自的進程號 0 或 1 做爲參數來調用 enter_region,這個函數調用在須要時將使進程等待,直到可以安全的臨界區。在完成對共享變量的操做以後,進程將調用 leave_region 表示操做完成,而且容許其餘進程進入。

如今來看看這個辦法是如何工做的。一開始,沒有任何進程處於臨界區中,如今進程 0 調用 enter_region。它經過設置數組元素和將 turn 置爲 0 來表示它但願進入臨界區。因爲進程 1 並不想進入臨界區,因此 enter_region 很快便返回。若是進程如今調用 enter_region,進程 1 將在此處掛起直到 interested[0] 變爲 FALSE,這種狀況只有在進程 0 調用 leave_region 退出臨界區時纔會發生。

那麼上面討論的是順序進入的狀況,如今來考慮一種兩個進程同時調用 enter_region 的狀況。它們都將本身的進程存入 turn,但只有最後保存進去的進程號纔有效,前一個進程的進程號由於重寫而丟失。假如進程 1 是最後存入的,則 turn 爲 1 。當兩個進程都運行到 while 的時候,進程 0 將不會循環並進入臨界區,而進程 1 將會無限循環且不會進入臨界區,直到進程 0 退出位置。

TSL 指令

如今來看一種須要硬件幫助的方案。一些計算機,特別是那些設計爲多處理器的計算機,都會有下面這條指令

TSL RX,LOCK

稱爲 測試並加鎖(test and set lock),它將一個內存字 lock 讀到寄存器 RX 中,而後在該內存地址上存儲一個非零值。讀寫指令能保證是一體的,不可分割的,一同執行的。在這個指令結束以前其餘處理器均不容許訪問內存。執行 TSL 指令的 CPU 將會鎖住內存總線,用來禁止其餘 CPU 在這個指令結束以前訪問內存。

很重要的一點是鎖住內存總線和禁用中斷不同。禁用中斷並不能保證一個處理器在讀寫操做之間另外一個處理器對內存的讀寫。也就是說,在處理器 1 上屏蔽中斷對處理器 2 沒有影響。讓處理器 2 遠離內存直處處理器 1 完成讀寫的最好的方式就是鎖住總線。這須要一個特殊的硬件(基本上,一根總線就能夠確保總線由鎖住它的處理器使用,而其餘的處理器不能使用)

爲了使用 TSL 指令,要使用一個共享變量 lock 來協調對共享內存的訪問。當 lock 爲 0 時,任何進程均可以使用 TSL 指令將其設置爲 1,並讀寫共享內存。當操做結束時,進程使用 move 指令將 lock 的值從新設置爲 0 。

這條指令如何防止兩個進程同時進入臨界區呢?下面是解決方案

enter_region:
        TSL REGISTER,LOCK                      | 複製鎖到寄存器並將鎖設爲1
        CMP REGISTER,#0           | 鎖是 0 嗎?
        JNE enter_region                  | 若不是零,說明鎖已被設置,因此循環
        RET                           | 返回調用者,進入臨界區
    
    
leave_region:
            MOVE LOCK,#0              | 在鎖中存入 0 
        RET                           | 返回調用者

咱們能夠看到這個解決方案的思想和 Peterson 的思想很類似。假設存在以下共 4 指令的彙編語言程序。第一條指令將 lock 原來的值複製到寄存器中並將 lock 設置爲 1 ,隨後這個原來的值和 0 作對比。若是它不是零,說明以前已經被加過鎖,則程序返回到開始並再次測試。通過一段時間後(可長可短),該值變爲 0 (當前處於臨界區中的進程退出臨界區時),因而過程返回,此時已加鎖。要清除這個鎖也比較簡單,程序只須要將 0 存入 lock 便可,不須要特殊的同步指令。

如今有了一種很明確的作法,那就是進程在進入臨界區以前會先調用 enter_region,判斷是否進行循環,若是lock 的值是 1 ,進行無限循環,若是 lock 是 0,不進入循環並進入臨界區。在進程從臨界區返回時它調用 leave_region,這會把 lock 設置爲 0 。與基於臨界區問題的全部解法同樣,進程必須在正確的時間調用 enter_region 和 leave_region ,解法才能奏效。

還有一個能夠替換 TSL 的指令是 XCHG,它原子性的交換了兩個位置的內容,例如,一個寄存器與一個內存字,代碼以下

enter_region:
        MOVE REGISTER,#1                            | 把 1 放在內存器中
        XCHG REGISTER,LOCK                      | 交換寄存器和鎖變量的內容
        CMP REGISTER,#0                         | 鎖是 0 嗎?
        JNE enter_region                                    | 若不是 0 ,鎖已被設置,進行循環
        RET                                         | 返回調用者,進入臨界區
    
leave_region:                                       
        MOVE LOCK,#0                                | 在鎖中存入 0 
        RET                                         | 返回調用者

XCHG 的本質上與 TSL 的解決辦法同樣。全部的 Intel x86 CPU 在底層同步中使用 XCHG 指令。

睡眠與喚醒

上面解法中的 Peterson 、TSL 和 XCHG 解法都是正確的,可是它們都有忙等待的缺點。這些解法的本質上都是同樣的,先檢查是否可以進入臨界區,若不容許,則該進程將原地等待,直到容許爲止。

這種方式不但浪費了 CPU 時間,並且還可能引發意想不到的結果。考慮一臺計算機上有兩個進程,這兩個進程具備不一樣的優先級,H 是屬於優先級比較高的進程,L 是屬於優先級比較低的進程。進程調度的規則是不論什麼時候只要 H 進程處於就緒態 H 就開始運行。在某一時刻,L 處於臨界區中,此時 H 變爲就緒態,準備運行(例如,一條 I/O 操做結束)。如今 H 要開始忙等,但因爲當 H 就緒時 L 就不會被調度,L 歷來不會有機會離開關鍵區域,因此 H 會變成死循環,有時將這種狀況稱爲優先級反轉問題(priority inversion problem)

如今讓咱們看一下進程間的通訊原語,這些原語在不容許它們進入關鍵區域以前會阻塞而不是浪費 CPU 時間,最簡單的是 sleepwakeup。Sleep 是一個可以形成調用者阻塞的系統調用,也就是說,這個系統調用會暫停直到其餘進程喚醒它。wakeup 調用有一個參數,即要喚醒的進程。還有一種方式是 wakeup 和 sleep 都有一個參數,即 sleep 和 wakeup 須要匹配的內存地址。

生產者-消費者問題

做爲這些私有原語的例子,讓咱們考慮生產者-消費者(producer-consumer) 問題,也稱做 有界緩衝區(bounded-buffer) 問題。兩個進程共享一個公共的固定大小的緩衝區。其中一個是生產者(producer),將信息放入緩衝區, 另外一個是消費者(consumer),會從緩衝區中取出。也能夠把這個問題通常化爲 m 個生產者和 n 個消費者的問題,可是咱們這裏只討論一個生產者和一個消費者的狀況,這樣能夠簡化實現方案。

若是緩衝隊列已滿,那麼當生產者仍想要將數據寫入緩衝區的時候,會出現問題。它的解決辦法是讓生產者睡眠,也就是阻塞生產者。等到消費者從緩衝區中取出一個或多個數據項時再喚醒它。一樣的,當消費者試圖從緩衝區中取數據,可是發現緩衝區爲空時,消費者也會睡眠,阻塞。直到生產者向其中放入一個新的數據。

這個邏輯聽起來比較簡單,並且這種方式也須要一種稱做 監聽 的變量,這個變量用於監視緩衝區的數據,咱們暫定爲 count,若是緩衝區最多存放 N 個數據項,生產者會每次判斷 count 是否達到 N,不然生產者向緩衝區放入一個數據項並增量 count 的值。消費者的邏輯也很類似:首先測試 count 的值是否爲 0 ,若是爲 0 則消費者睡眠、阻塞,不然會從緩衝區取出數據並使 count 數量遞減。每一個進程也會檢查檢查是否其餘線程是否應該被喚醒,若是應該被喚醒,那麼就喚醒該線程。下面是生產者消費者的代碼

#define N 100                                       /* 緩衝區 slot 槽的數量 */
int count = 0                                               /* 緩衝區數據的數量 */
  
// 生產者
void producer(void){
  int item;
  
  while(TRUE){                                           /* 無限循環 */
    item = produce_item()                                               /* 生成下一項數據 */
    if(count == N){
      sleep();                                                  /* 若是緩存區是滿的,就會阻塞 */
    }
    
    insert_item(item);                                                  /* 把當前數據放在緩衝區中 */
    count = count + 1;                                                  /* 增長緩衝區 count 的數量 */
    if(count == 1){
      wakeup(consumer);                                         /* 緩衝區是否爲空? */
    }
  }
}

// 消費者
void consumer(void){
  
  int item;
  
  while(TRUE){                                          /* 無限循環 */
    if(count == 0){                                         /* 若是緩衝區是空的,就會進行阻塞 */
      sleep();
    }
    item = remove_item();                                               /* 從緩衝區中取出一個數據 */
    count = count - 1                                              /* 將緩衝區的 count 數量減一 */
    if(count == N - 1){                                                /* 緩衝區滿嘛? */
      wakeup(producer);     
    }
    consumer_item(item);                                                /* 打印數據項 */
  }
  
}

爲了在 C 語言中描述像是 sleepwakeup 的系統調用,咱們將以庫函數調用的形式來表示。它們不是 C 標準庫的一部分,但能夠在實際具備這些系統調用的任何系統上使用。代碼中未實現的 insert_itemremove_item 用來記錄將數據項放入緩衝區和從緩衝區取出數據等。

如今讓咱們回到生產者-消費者問題上來,上面代碼中會產生競爭條件,由於 count 這個變量是暴露在大衆視野下的。有可能出現下面這種狀況:緩衝區爲空,此時消費者恰好讀取 count 的值發現它爲 0 。此時調度程序決定暫停消費者並啓動運行生產者。生產者生產了一條數據並把它放在緩衝區中,而後增長 count 的值,並注意到它的值是 1 。因爲 count 爲 0,消費者必須處於睡眠狀態,所以生產者調用 wakeup 來喚醒消費者。可是,消費者此時在邏輯上並無睡眠,因此 wakeup 信號會丟失。當消費者下次啓動後,它會查看以前讀取的 count 值,發現它的值是 0 ,而後在此進行睡眠。不久以後生產者會填滿整個緩衝區,在這以後會阻塞,這樣一來兩個進程將永遠睡眠下去。

引發上面問題的本質是 喚醒還沒有進行睡眠狀態的進程會致使喚醒丟失。若是它沒有丟失,則一切都很正常。一種快速解決上面問題的方式是增長一個喚醒等待位(wakeup waiting bit)。當一個 wakeup 信號發送給仍在清醒的進程後,該位置爲 1 。以後,當進程嘗試睡眠的時候,若是喚醒等待位爲 1 ,則該位清除,而進程仍然保持清醒。

然而,當進程數量有許多的時候,這時你能夠說經過增長喚醒等待位的數量來喚醒等待位,因而就有了 二、四、六、8 個喚醒等待位,可是並無從根本上解決問題。

信號量

信號量是 E.W.Dijkstra 在 1965 年提出的一種方法,它使用一個整形變量來累計喚醒次數,以供以後使用。在他的觀點中,有一個新的變量類型稱做 信號量(semaphore)。一個信號量的取值能夠是 0 ,或任意正數。0 表示的是不須要任何喚醒,任意的正數表示的就是喚醒次數。

Dijkstra 提出了信號量有兩個操做,如今一般使用 downup(分別能夠用 sleep 和 wakeup 來表示)。down 這個指令的操做會檢查值是否大於 0 。若是大於 0 ,則將其值減 1 ;若該值爲 0 ,則進程將睡眠,並且此時 down 操做將會繼續執行。檢查數值、修改變量值以及可能發生的睡眠操做均爲一個單一的、不可分割的 原子操做(atomic action) 完成。這會保證一旦信號量操做開始,沒有其餘的進程可以訪問信號量,直到操做完成或者阻塞。這種原子性對於解決同步問題和避免競爭絕對必不可少。

原子性操做指的是在計算機科學的許多其餘領域中,一組相關操做所有執行而沒有中斷或根本不執行。

up 操做會使信號量的值 + 1。若是一個或者多個進程在信號量上睡眠,沒法完成一個先前的 down 操做,則由系統選擇其中一個並容許該程完成 down 操做。所以,對一個進程在其上睡眠的信號量執行一次 up 操做以後,該信號量的值仍然是 0 ,但在其上睡眠的進程卻少了一個。信號量的值增 1 和喚醒一個進程一樣也是不可分割的。不會有某個進程因執行 up 而阻塞,正如在前面的模型中不會有進程因執行 wakeup 而阻塞是同樣的道理。

用信號量解決生產者 - 消費者問題

用信號量解決丟失的 wakeup 問題,代碼以下

#define N 100                                           /* 定義緩衝區槽的數量 */
typedef int semaphore;                                      /* 信號量是一種特殊的 int */
semaphore mutex = 1;                                        /* 控制關鍵區域的訪問 */
semaphore empty = N;                                        /* 統計 buffer 空槽的數量 */
semaphore full = 0;                                     /* 統計 buffer 滿槽的數量 */

void producer(void){ 
  
  int item;  
  
  while(TRUE){                                          /* TRUE 的常量是 1 */
    item = producer_item();                                     /* 產生放在緩衝區的一些數據 */
    down(&empty);                                           /* 將空槽數量減 1  */
    down(&mutex);                                           /* 進入關鍵區域  */
    insert_item(item);                                      /* 把數據放入緩衝區中 */
    up(&mutex);                                         /* 離開臨界區 */
    up(&full);                                              /* 將 buffer 滿槽數量 + 1 */
  }
}

void consumer(void){
  
  int item;
  
  while(TRUE){                                          /* 無限循環 */
    down(&full);                                            /* 緩存區滿槽數量 - 1 */
    down(&mutex);                                           /* 進入緩衝區 */ 
    item = remove_item();                                   /* 從緩衝區取出數據 */
    up(&mutex);                                         /* 離開臨界區 */
    up(&empty);                                         /* 將空槽數目 + 1 */
    consume_item(item);                                 /* 處理數據 */
  }
  
}

爲了確保信號量能正確工做,最重要的是要採用一種不可分割的方式來實現它。一般是將 up 和 down 做爲系統調用來實現。並且操做系統只需在執行如下操做時暫時屏蔽所有中斷:檢查信號量、更新、必要時使進程睡眠。因爲這些操做僅須要很是少的指令,所以中斷不會形成影響。若是使用多個 CPU,那麼信號量應該被鎖進行保護。使用 TSL 或者 XCHG 指令用來確保同一時刻只有一個 CPU 對信號量進行操做。

使用 TSL 或者 XCHG 來防止幾個 CPU 同時訪問一個信號量,與生產者或消費者使用忙等待來等待其餘騰出或填充緩衝區是徹底不同的。前者的操做僅須要幾個毫秒,而生產者或消費者可能須要任意長的時間。

上面這個解決方案使用了三種信號量:一個稱爲 full,用來記錄充滿的緩衝槽數目;一個稱爲 empty,記錄空的緩衝槽數目;一個稱爲 mutex,用來確保生產者和消費者不會同時進入緩衝區。Full 被初始化爲 0 ,empty 初始化爲緩衝區中插槽數,mutex 初始化爲 1。信號量初始化爲 1 而且由兩個或多個進程使用,以確保它們中同時只有一個能夠進入關鍵區域的信號被稱爲 二進制信號量(binary semaphores)。若是每一個進程都在進入關鍵區域以前執行 down 操做,而在離開關鍵區域以後執行 up 操做,則能夠確保相互互斥。

如今咱們有了一個好的進程間原語的保證。而後咱們再來看一下中斷的順序保證

  1. 硬件壓入堆棧程序計數器等

  2. 硬件從中斷向量裝入新的程序計數器

  3. 彙編語言過程保存寄存器的值

  4. 彙編語言過程設置新的堆棧

  5. C 中斷服務器運行(典型的讀和緩存寫入)

  6. 調度器決定下面哪一個程序先運行

  7. C 過程返回至彙編代碼

  8. 彙編語言過程開始運行新的當前進程

在使用信號量的系統中,隱藏中斷的天然方法是讓每一個 I/O 設備都配備一個信號量,該信號量最初設置爲0。在 I/O 設備啓動後,中斷處理程序馬上對相關聯的信號執行一個 down 操做,因而進程當即被阻塞。當中斷進入時,中斷處理程序隨後對相關的信號量執行一個 up操做,可以使已經阻止的進程恢復運行。在上面的中斷處理步驟中,其中的第 5 步 C 中斷服務器運行 就是中斷處理程序在信號量上執行的一個 up 操做,因此在第 6 步中,操做系統可以執行設備驅動程序。固然,若是有幾個進程已經處於就緒狀態,調度程序可能會選擇接下來運行一個更重要的進程,咱們會在後面討論調度的算法。

上面的代碼其實是經過兩種不一樣的方式來使用信號量的,而這兩種信號量之間的區別也是很重要的。mutex 信號量用於互斥。它用於確保任意時刻只有一個進程可以對緩衝區和相關變量進行讀寫。互斥是用於避免進程混亂所必須的一種操做。

另一個信號量是關於同步(synchronization)的。fullempty 信號量用於確保事件的發生或者不發生。在這個事例中,它們確保了緩衝區滿時生產者中止運行;緩衝區爲空時消費者中止運行。這兩個信號量的使用與 mutex 不一樣。

互斥量

若是不須要信號量的計數能力時,能夠使用信號量的一個簡單版本,稱爲 mutex(互斥量)。互斥量的優點就在於在一些共享資源和一段代碼中保持互斥。因爲互斥的實現既簡單又有效,這使得互斥量在實現用戶空間線程包時很是有用。

互斥量是一個處於兩種狀態之一的共享變量:解鎖(unlocked)加鎖(locked)。這樣,只須要一個二進制位來表示它,不過通常狀況下,一般會用一個 整形(integer) 來表示。0 表示解鎖,其餘全部的值表示加鎖,比 1 大的值表示加鎖的次數。

mutex 使用兩個過程,當一個線程(或者進程)須要訪問關鍵區域時,會調用 mutex_lock 進行加鎖。若是互斥鎖當前處於解鎖狀態(表示關鍵區域可用),則調用成功,而且調用線程能夠自由進入關鍵區域。

另外一方面,若是 mutex 互斥量已經鎖定的話,調用線程會阻塞直到關鍵區域內的線程執行完畢而且調用了 mutex_unlock 。若是多個線程在 mutex 互斥量上阻塞,將隨機選擇一個線程並容許它得到鎖。

因爲 mutex 互斥量很是簡單,因此只要有 TSL 或者是 XCHG 指令,就能夠很容易地在用戶空間實現它們。用於用戶級線程包的 mutex_lockmutex_unlock 代碼以下,XCHG 的本質也同樣。

mutex_lock:
            TSL REGISTER,MUTEX                      | 將互斥信號量複製到寄存器,並將互斥信號量置爲1
            CMP REGISTER,#0                     | 互斥信號量是 0 嗎?
            JZE ok                                  | 若是互斥信號量爲0,它被解鎖,因此返回
            CALL thread_yield                           | 互斥信號正在使用;調度其餘線程
            JMP mutex_lock                          | 再試一次
ok:     RET                                             | 返回調用者,進入臨界區

mutex_unlcok:
            MOVE MUTEX,#0                           | 將 mutex 置爲 0 
            RET                                     | 返回調用者

mutex_lock 的代碼和上面 enter_region 的代碼很類似,咱們能夠對比着看一下

上面代碼最大的區別你看出來了嗎?

  • 根據上面咱們對 TSL 的分析,咱們知道,若是 TSL 判斷沒有進入臨界區的進程會進行無限循環獲取鎖,而在 TSL 的處理中,若是 mutex 正在使用,那麼就調度其餘線程進行處理。因此上面最大的區別其實就是在判斷 mutex/TSL 以後的處理。

  • 在(用戶)線程中,狀況有所不一樣,由於沒有時鐘來中止運行時間過長的線程。結果是經過忙等待的方式來試圖得到鎖的線程將永遠循環下去,決不會獲得鎖,由於這個運行的線程不會讓其餘線程運行從而釋放鎖,其餘線程根本沒有得到鎖的機會。在後者獲取鎖失敗時,它會調用 thread_yield 將 CPU 放棄給另一個線程。結果就不會進行忙等待。在該線程下次運行時,它再一次對鎖進行測試。

上面就是 enter_region 和 mutex_lock 的差異所在。因爲 thread_yield 僅僅是一個用戶空間的線程調度,因此它的運行很是快捷。這樣,mutex_lockmutex_unlock 都不須要任何內核調用。經過使用這些過程,用戶線程徹底能夠實如今用戶空間中的同步,這個過程僅僅須要少許的同步。

咱們上面描述的互斥量實際上是一套調用框架中的指令。從軟件角度來講,老是須要更多的特性和同步原語。例如,有時線程包提供一個調用 mutex_trylock,這個調用嘗試獲取鎖或者返回錯誤碼,可是不會進行加鎖操做。這就給了調用線程一個靈活性,以決定下一步作什麼,是使用替代方法仍是等候下去。

Futexes

隨着並行的增長,有效的同步(synchronization)鎖定(locking) 對於性能來講是很是重要的。若是進程等待時間很短,那麼自旋鎖(Spin lock) 是很是有效;可是若是等待時間比較長,那麼這會浪費 CPU 週期。若是進程不少,那麼阻塞此進程,並僅當鎖被釋放的時候讓內核解除阻塞是更有效的方式。不幸的是,這種方式也會致使另外的問題:它能夠在進程競爭頻繁的時候運行良好,可是在競爭不是很激烈的狀況下內核切換的消耗會很是大,並且更困難的是,預測鎖的競爭數量更不容易。

有一種有趣的解決方案是把二者的優勢結合起來,提出一種新的思想,稱爲 futex,或者是 快速用戶空間互斥(fast user space mutex),是否是聽起來頗有意思?

futex 是 Linux 中的特性實現了基本的鎖定(很像是互斥鎖)並且避免了陷入內核中,由於內核的切換的開銷很是大,這樣作能夠大大提升性能。futex 由兩部分組成:內核服務和用戶庫。內核服務提供了了一個 等待隊列(wait queue) 容許多個進程在鎖上排隊等待。除非內核明確的對他們解除阻塞,不然它們不會運行。

對於一個進程來講,把它放到等待隊列須要昂貴的系統調用,這種方式應該被避免。在沒有競爭的狀況下,futex 能夠直接在用戶空間中工做。這些進程共享一個 32 位整數(integer) 做爲公共鎖變量。假設鎖的初始化爲 1,咱們認爲這時鎖已經被釋放了。線程經過執行原子性的操做減小並測試(decrement and test) 來搶佔鎖。decrement and set 是 Linux 中的原子功能,由包裹在 C 函數中的內聯彙編組成,並在頭文件中進行定義。下一步,線程會檢查結果來查看鎖是否已經被釋放。若是鎖如今不是鎖定狀態,那麼恰好咱們的線程能夠成功搶佔該鎖。然而,若是鎖被其餘線程持有,搶佔鎖的線程不得不等待。在這種狀況下,futex 庫不會自旋,可是會使用一個系統調用來把線程放在內核中的等待隊列中。這樣一來,切換到內核的開銷已是合情合理的了,由於線程能夠在任什麼時候候阻塞。當線程完成了鎖的工做時,它會使用原子性的 增長並測試(increment and test) 釋放鎖,並檢查結果以查看內核等待隊列上是否仍阻止任何進程。若是有的話,它會通知內核能夠對等待隊列中的一個或多個進程解除阻塞。若是沒有鎖競爭,內核則不須要參與競爭。

Pthreads 中的互斥量

Pthreads 提供了一些功能用來同步線程。最基本的機制是使用互斥量變量,能夠鎖定和解鎖,用來保護每一個關鍵區域。但願進入關鍵區域的線程首先要嘗試獲取 mutex。若是 mutex 沒有加鎖,線程可以立刻進入而且互斥量可以自動鎖定,從而阻止其餘線程進入。若是 mutex 已經加鎖,調用線程會阻塞,直到 mutex 解鎖。若是多個線程在相同的互斥量上等待,當互斥量解鎖時,只有一個線程可以進入而且從新加鎖。這些鎖並非必須的,程序員須要正確使用它們。

下面是與互斥量有關的函數調用

向咱們想象中的同樣,mutex 可以被建立和銷燬,扮演這兩個角色的分別是 Phread_mutex_initPthread_mutex_destroy。mutex 也能夠經過 Pthread_mutex_lock 來進行加鎖,若是互斥量已經加鎖,則會阻塞調用者。還有一個調用Pthread_mutex_trylock 用來嘗試對線程加鎖,當 mutex 已經被加鎖時,會返回一個錯誤代碼而不是阻塞調用者。這個調用容許線程有效的進行忙等。最後,Pthread_mutex_unlock 會對 mutex 解鎖而且釋放一個正在等待的線程。

除了互斥量之外,Pthreads 還提供了第二種同步機制: 條件變量(condition variables) 。mutex 能夠很好的容許或阻止對關鍵區域的訪問。條件變量容許線程因爲未知足某些條件而阻塞。絕大多數狀況下這兩種方法是一塊兒使用的。下面咱們進一步來研究線程、互斥量、條件變量之間的關聯。

下面再來從新認識一下生產者和消費者問題:一個線程將東西放在一個緩衝區內,由另外一個線程將它們取出。若是生產者發現緩衝區沒有空槽能夠使用了,生產者線程會阻塞起來直到有一個線程能夠使用。生產者使用 mutex 來進行原子性檢查從而不受其餘線程干擾。可是當發現緩衝區已經滿了之後,生產者須要一種方法來阻塞本身並在之後被喚醒。這即是條件變量作的工做。

下面是一些與條件變量有關的最重要的 pthread 調用

上表中給出了一些調用用來建立和銷燬條件變量。條件變量上的主要屬性是 Pthread_cond_waitPthread_cond_signal。前者阻塞調用線程,直到其餘線程發出信號爲止(使用後者調用)。阻塞的線程一般須要等待喚醒的信號以此來釋放資源或者執行某些其餘活動。只有這樣阻塞的線程才能繼續工做。條件變量容許等待與阻塞原子性的進程。Pthread_cond_broadcast 用來喚醒多個阻塞的、須要等待信號喚醒的線程。

須要注意的是,條件變量(不像是信號量)不會存在於內存中。若是將一個信號量傳遞給一個沒有線程等待的條件變量,那麼這個信號就會丟失,這個須要注意

下面是一個使用互斥量和條件變量的例子

#include <stdio.h>
#include <pthread.h>

#define MAX 1000000000                              /* 須要生產的數量 */
pthread_mutex_t the_mutex;
pthread_cond_t condc,condp;                         /* 使用信號量 */
int buffer = 0;

void *producer(void *ptr){                              /* 生產數據 */
  
  int i;
  
  for(int i = 0;i <= MAX;i++){
    pthread_mutex_lock(&the_mutex);                             /* 緩衝區獨佔訪問,也就是使用 mutex 獲取鎖 */
    while(buffer != 0){
      pthread_cond_wait(&condp,&the_mutex);
    }
    buffer = i;                                         /* 把他們放在緩衝區中 */
    pthread_cond_signal(&condc);                            /* 喚醒消費者 */
    pthread_mutex_unlock(&the_mutex);                           /* 釋放緩衝區 */
  }
  pthread_exit(0);
  
}

void *consumer(void *ptr){                              /* 消費數據 */
  
  int i;
  
  for(int i = 0;i <= MAX;i++){
    pthread_mutex_lock(&the_mutex);                             /* 緩衝區獨佔訪問,也就是使用 mutex 獲取鎖 */
    while(buffer == 0){
      pthread_cond_wait(&condc,&the_mutex);
    }
    buffer = 0;                                         /* 把他們從緩衝區中取出 */
    pthread_cond_signal(&condp);                            /* 喚醒生產者 */
    pthread_mutex_unlock(&the_mutex);                           /* 釋放緩衝區 */
  }
  pthread_exit(0);
  
}

管程

爲了可以編寫更加準確無誤的程序,Brinch Hansen 和 Hoare 提出了一個更高級的同步原語叫作 管程(monitor)。他們兩我的的提案略有不一樣,經過下面的描述你就能夠知道。管程是程序、變量和數據結構等組成的一個集合,它們組成一個特殊的模塊或者包。進程能夠在任何須要的時候調用管程中的程序,可是它們不能從管程外部訪問數據結構和程序。下面展現了一種抽象的,相似 Pascal 語言展現的簡潔的管程。不能用 C 語言進行描述,由於管程是語言概念而 C 語言並不支持管程。

monitor example
    integer i;
    condition c;
    
    procedure producer();
    .
    .
    .
    end;
    
    
    procedure consumer();
    .
    end;
end monitor;

管程有一個很重要的特性,即在任什麼時候候管程中只能有一個活躍的進程,這一特性使管程可以很方便的實現互斥操做。管程是編程語言的特性,因此編譯器知道它們的特殊性,所以能夠採用與其餘過程調用不一樣的方法來處理對管程的調用。一般狀況下,當進程調用管程中的程序時,該程序的前幾條指令會檢查管程中是否有其餘活躍的進程。若是有的話,調用進程將被掛起,直到另外一個進程離開管程纔將其喚醒。若是沒有活躍進程在使用管程,那麼該調用進程才能夠進入。

進入管程中的互斥由編譯器負責,可是一種通用作法是使用 互斥量(mutex)二進制信號量(binary semaphore)。因爲編譯器而不是程序員在操做,所以出錯的概率會大大下降。在任什麼時候候,編寫管程的程序員都無需關心編譯器是如何處理的。他只須要知道將全部的臨界區轉換成爲管程過程便可。毫不會有兩個進程同時執行臨界區中的代碼。

即便管程提供了一種簡單的方式來實現互斥,但在咱們看來,這還不夠。由於咱們還須要一種在進程沒法執行被阻塞。在生產者-消費者問題中,很容易將針對緩衝區滿和緩衝區空的測試放在管程程序中,可是生產者在發現緩衝區滿的時候該如何阻塞呢?

解決的辦法是引入條件變量(condition variables) 以及相關的兩個操做 waitsignal。當一個管程程序發現它不能運行時(例如,生產者發現緩衝區已滿),它會在某個條件變量(如 full)上執行 wait 操做。這個操做形成調用進程阻塞,而且還將另外一個之前等在管程以外的進程調入管程。在前面的 pthread 中咱們已經探討過條件變量的實現細節了。另外一個進程,好比消費者能夠經過執行 signal 來喚醒阻塞的調用進程。

Brinch Hansen 和 Hoare 在對進程喚醒上有所不一樣,Hoare 建議讓新喚醒的進程繼續運行;而掛起另外的進程。而 Brinch Hansen 建議讓執行 signal 的進程必須退出管程,這裏咱們採用 Brinch Hansen 的建議,由於它在概念上更簡單,而且更容易實現。

若是在一個條件變量上有若干進程都在等待,則在對該條件執行 signal 操做後,系統調度程序只能選擇其中一個進程恢復運行。

順便提一下,這裏還有上面兩位教授沒有提出的第三種方式,它的理論是讓執行 signal 的進程繼續運行,等待這個進程退出管程時,其餘進程才能進入管程。

條件變量不是計數器。條件變量也不能像信號量那樣積累信號以便之後使用。因此,若是向一個條件變量發送信號,可是該條件變量上沒有等待進程,那麼信號將會丟失。也就是說,wait 操做必須在 signal 以前執行

下面是一個使用 Pascal 語言經過管程實現的生產者-消費者問題的解法

monitor ProducerConsumer
        condition full,empty;
        integer count;
        
        procedure insert(item:integer);
        begin
                if count = N then wait(full);
                insert_item(item);
                count := count + 1;
                if count = 1 then signal(empty);
        end;
        
        function remove:integer;
        begin
                if count = 0 then wait(empty);
                remove = remove_item;
                count := count - 1;
                if count = N - 1 then signal(full);
        end;
        
        count := 0;
end monitor;

procedure producer;
begin
            while true do
      begin 
                item = produce_item;
                ProducerConsumer.insert(item);
      end
end;

procedure consumer;
begin 
            while true do
            begin
                        item = ProducerConsumer.remove;
                        consume_item(item);
            end
end;

讀者可能以爲 wait 和 signal 操做看起來像是前面提到的 sleep 和 wakeup ,並且後者存在嚴重的競爭條件。它們確實很像,可是有個關鍵的區別:sleep 和 wakeup 之因此會失敗是由於當一個進程想睡眠時,另外一個進程試圖去喚醒它。使用管程則不會發生這種狀況。管程程序的自動互斥保證了這一點,若是管程過程當中的生產者發現緩衝區已滿,它將可以完成 wait 操做而不用擔憂調度程序可能會在 wait 完成以前切換到消費者。甚至,在 wait 執行完成而且把生產者標誌爲不可運行以前,是不會容許消費者進入管程的。

儘管類 Pascal 是一種想象的語言,但仍是有一些真正的編程語言支持,好比 Java (終於輪到大 Java 出場了),Java 是可以支持管程的,它是一種 面向對象的語言,支持用戶級線程,還容許將方法劃分爲類。只要將關鍵字 synchronized 關鍵字加到方法中便可。Java 可以保證一旦某個線程執行該方法,就不容許其餘線程執行該對象中的任何 synchronized 方法。沒有關鍵字 synchronized ,就不能保證沒有交叉執行。

下面是 Java 使用管程解決的生產者-消費者問題

public class ProducerConsumer {
  static final int N = 100;                                     // 定義緩衝區大小的長度
  static Producer p = new Producer();                                           // 初始化一個新的生產者線程
  static Consumer c = new Consumer();                                   // 初始化一個新的消費者線程
  static Our_monitor mon = new Our_monitor();                                         // 初始化一個管程
  
  static class Producer extends Thread{
    public void run(){                                          // run 包含了線程代碼
      int item;
      while(true){                                              // 生產者循環
        item = produce_item();
        mon.insert(item);
      }
    }
    private int produce_item(){...}                                             // 生產代碼
  }
  
  static class consumer extends Thread {
    public void run( ) {                                            // run 包含了線程代碼
        int item;
      while(true){
        item = mon.remove();
                consume_item(item);
      }
    }
    private int produce_item(){...}                                             // 消費代碼
  }
  
  static class Our_monitor {                                            // 這是管程
    private int buffer[] = new int[N];
    private int count = 0,lo = 0,hi = 0;                                                    // 計數器和索引
    
    private synchronized void insert(int val){
      if(count == N){
        go_to_sleep();                                          // 若是緩衝區是滿的,則進入休眠
      }
            buffer[hi] = val;                                   // 向緩衝區插入內容
      hi = (hi + 1) % N;                                            // 找到下一個槽的爲止
      count = count + 1;                                            // 緩衝區中的數目自增 1 
      if(count == 1){
        notify();                                                   // 若是消費者睡眠,則喚醒
      }
    }
    
    private synchronized void remove(int val){
      int val;
      if(count == 0){
        go_to_sleep();                                          // 緩衝區是空的,進入休眠
      }
      val = buffer[lo];                                         // 從緩衝區取出數據
      lo = (lo + 1) % N;                                            // 設置待取出數據項的槽
      count = count - 1;                                            // 緩衝區中的數據項數目減 1 
      if(count = N - 1){
        notify();                                                   // 若是生產者睡眠,喚醒它
      }
      return val;
    }
    
    private void go_to_sleep() {
      try{
        wait( );
      }catch(Interr uptedExceptionexc) {};
    }
  }
      
}

上面的代碼中主要設計四個類,外部類(outer class) ProducerConsumer 建立並啓動兩個線程,p 和 c。第二個類和第三個類 ProducerConsumer 分別包含生產者和消費者代碼。最後,Our_monitor 是管程,它有兩個同步線程,用於在共享緩衝區中插入和取出數據。

在前面的全部例子中,生產者和消費者線程在功能上與它們是相同的。生產者有一個無限循環,該無限循環產生數據並將數據放入公共緩衝區中;消費者也有一個等價的無限循環,該無限循環用於從緩衝區取出數據並完成一系列工做。

程序中比較回味無窮的就是 Our_monitor 了,它包含緩衝區、管理變量以及兩個同步方法。當生產者在 insert 內活動時,它保證消費者不能在 remove 方法中運行,從而保證更新變量以及緩衝區的安全性,而且不用擔憂競爭條件。變量 count 記錄在緩衝區中數據的數量。變量 lo 是緩衝區槽的序號,指出將要取出的下一個數據項。相似地,hi 是緩衝區中下一個要放入的數據項序號。容許 lo = hi,含義是在緩衝區中有 0 個或 N 個數據。

Java 中的同步方法與其餘經典管程有本質差異:Java 沒有內嵌的條件變量。然而,Java 提供了 wait 和 notify 分別與 sleep 和 wakeup 等價。

經過臨界區自動的互斥,管程比信號量更容易保證並行編程的正確性。可是管程也有缺點,咱們前面說到過管程是一個編程語言的概念,編譯器必需要識別管程並用某種方式對其互斥做出保證。C、Pascal 以及大多數其餘編程語言都沒有管程,因此不能依靠編譯器來遵照互斥規則。

與管程和信號量有關的另外一個問題是,這些機制都是設計用來解決訪問共享內存的一個或多個 CPU 上的互斥問題的。經過將信號量放在共享內存中並用 TSLXCHG 指令來保護它們,能夠避免競爭。可是若是是在分佈式系統中,可能同時具備多個 CPU 的狀況,而且每一個 CPU 都有本身的私有內存呢,它們經過網絡相連,那麼這些原語將會失效。由於信號量過低級了,而管程在少數幾種編程語言以外沒法使用,因此還須要其餘方法。

消息傳遞

上面提到的其餘方法就是 消息傳遞(messaage passing)。這種進程間通訊的方法使用兩個原語 sendreceive ,它們像信號量而不像管程,是系統調用而不是語言級別。示例以下

send(destination, &message);

receive(source, &message);

send 方法用於向一個給定的目標發送一條消息,receive 從一個給定的源接受一條消息。若是沒有消息,接受者可能被阻塞,直到接受一條消息或者帶着錯誤碼返回。

消息傳遞系統的設計要點

消息傳遞系統如今面臨着許多信號量和管程所未涉及的問題和設計難點,尤爲對那些在網絡中不一樣機器上的通訊情況。例如,消息有可能被網絡丟失。爲了防止消息丟失,發送方和接收方能夠達成一致:一旦接受到消息後,接收方立刻回送一條特殊的 確認(acknowledgement) 消息。若是發送方在一段時間間隔內未收到確認,則重發消息。

如今考慮消息自己被正確接收,而返回給發送着的確認消息丟失的狀況。發送者將重發消息,這樣接受者將收到兩次相同的消息。

對於接收者來講,如何區分新的消息和一條重發的老消息是很是重要的。一般採用在每條原始消息中嵌入一個連續的序號來解決此問題。若是接受者收到一條消息,它具備與前面某一條消息同樣的序號,就知道這條消息是重複的,能夠忽略。

消息系統還必須處理如何命名進程的問題,以便在發送或接收調用中清晰的指明進程。身份驗證(authentication) 也是一個問題,好比客戶端怎麼知道它是在與一個真正的文件服務器通訊,從發送方到接收方的信息有可能被中間人所篡改。

用消息傳遞解決生產者-消費者問題

如今咱們考慮如何使用消息傳遞來解決生產者-消費者問題,而不是共享緩存。下面是一種解決方式

#define N 100                               /* buffer 中槽的數量 */

void producer(void){
  
  int item;
  message m;                                    /* buffer 中槽的數量 */
  
  while(TRUE){
    item = produce_item();                      /* 生成放入緩衝區的數據 */
    receive(consumer,&m);                       /* 等待消費者發送空緩衝區 */
    build_message(&m,item);                     /* 創建一個待發送的消息 */
    send(consumer,&m);                              /* 發送給消費者 */
  }
  
}

void consumer(void){
  
  int item,i;
  message m;
  
  for(int i = 0;i < N;i++){                             /* 循環N次 */
    send(producer,&m);                          /* 發送N個緩衝區 */
  }
  while(TRUE){
    receive(producer,&m);                       /* 接受包含數據的消息 */
    item = extract_item(&m);                    /* 將數據從消息中提取出來 */
    send(producer,&m);                          /* 將空緩衝區發送回生產者 */
    consume_item(item);                     /* 處理數據 */
  }
  
}

假設全部的消息都有相同的大小,而且在還沒有接受到發出的消息時,由操做系統自動進行緩衝。在該解決方案中共使用 N 條消息,這就相似於一塊共享內存緩衝區的 N 個槽。消費者首先將 N 條空消息發送給生產者。當生產者向消費者傳遞一個數據項時,它取走一條空消息並返回一條填充了內容的消息。經過這種方式,系統中總的消息數量保持不變,因此消息均可以存放在事先肯定數量的內存中。

若是生產者的速度要比消費者快,則全部的消息最終都將被填滿,等待消費者,生產者將被阻塞,等待返回一條空消息。若是消費者速度快,那麼狀況將正相反:全部的消息均爲空,等待生產者來填充,消費者將被阻塞,以等待一條填充過的消息。

消息傳遞的方式有許多變體,下面先介紹如何對消息進行 編址

  • 一種方法是爲每一個進程分配一個惟一的地址,讓消息按進程的地址編址。
  • 另外一種方式是引入一個新的數據結構,稱爲 信箱(mailbox),信箱是一個用來對必定的數據進行緩衝的數據結構,信箱中消息的設置方法也有多種,典型的方法是在信箱建立時肯定消息的數量。在使用信箱時,在 send 和 receive 調用的地址參數就是信箱的地址,而不是進程的地址。當一個進程試圖向一個滿的信箱發送消息時,它將被掛起,直到信箱中有消息被取走,從而爲新的消息騰出地址空間。

屏障

最後一個同步機制是準備用於進程組而不是進程間的生產者-消費者狀況的。在某些應用中劃分了若干階段,而且規定,除非全部的進程都就緒準備着手下一個階段,不然任何進程都不能進入下一個階段,能夠經過在每一個階段的結尾安裝一個 屏障(barrier) 來實現這種行爲。當一個進程到達屏障時,它會被屏障所攔截,直到全部的屏障都到達爲止。屏障可用於一組進程同步,以下圖所示

在上圖中咱們能夠看到,有四個進程接近屏障,這意味着每一個進程都在進行運算,可是尚未到達每一個階段的結尾。過了一段時間後,A、B、D 三個進程都到達了屏障,各自的進程被掛起,但此時還不能進入下一個階段呢,由於進程 B 尚未執行完畢。結果,當最後一個 C 到達屏障後,這個進程組纔可以進入下一個階段。

避免鎖:讀-複製-更新

最快的鎖是根本沒有鎖。問題在於沒有鎖的狀況下,咱們是否容許對共享數據結構的併發讀寫進行訪問。答案固然是不能夠。假設進程 A 正在對一個數字數組進行排序,而進程 B 正在計算其平均值,而此時你進行 A 的移動,會致使 B 會屢次讀到重複值,而某些值根本沒有遇到過。

然而,在某些狀況下,咱們能夠容許寫操做來更新數據結構,即使還有其餘的進程正在使用。竅門在於確保每一個讀操做要麼讀取舊的版本,要麼讀取新的版本,例以下面的樹

上面的樹中,讀操做從根部到葉子遍歷整個樹。加入一個新節點 X 後,爲了實現這一操做,咱們要讓這個節點在樹中可見以前使它"剛好正確":咱們對節點 X 中的全部值進行初始化,包括它的子節點指針。而後經過原子寫操做,使 X 稱爲 A 的子節點。全部的讀操做都不會讀到先後不一致的版本

在上面的圖中,咱們接着移除 B 和 D。首先,將 A 的左子節點指針指向 C 。全部本來在 A 中的讀操做將會後續讀到節點 C ,而永遠不會讀到 B 和 D。也就是說,它們將只會讀取到新版數據。一樣,全部當前在 B 和 D 中的讀操做將繼續按照原始的數據結構指針而且讀取舊版數據。全部操做均能正確運行,咱們不須要鎖住任何東西。而不須要鎖住數據就可以移除 B 和 D 的主要緣由就是 讀-複製-更新(Ready-Copy-Update,RCU),將更新過程當中的移除和再分配過程分離開。

文章參考:

《現代操做系統》

《Modern Operating System》forth edition

https://www.encyclopedia.com/computing/news-wires-white-papers-and-books/interactive-systems

https://j00ru.vexillium.org/syscalls/nt/32/

https://www.bottomupcs.com/process_hierarchy.xhtml

https://en.wikipedia.org/wiki/Runtime_system

https://en.wikipedia.org/wiki/Execution_model

相關文章
相關標籤/搜索