一文帶你懟明白程式和執行緒通訊原理

cxuan發表於2020-02-18

程式間通訊

程式是需要頻繁的和其他程式進行交流的。例如,在一個 shell 管道中,第一個程式的輸出必須傳遞給第二個程式,這樣沿著管道進行下去。因此,程式之間如果需要通訊的話,必須要使用一種良好的資料結構以至於不能被中斷。下面我們會一起討論有關 程式間通訊(Inter Process Communication, IPC) 的問題。

關於程式間的通訊,這裡有三個問題

  • 上面提到了第一個問題,那就是一個程式如何傳遞訊息給其他程式。
  • 第二個問題是如何確保兩個或多個執行緒之間不會相互干擾。例如,兩個航空公司都試圖為不同的顧客搶購飛機上的最後一個座位。
  • 第三個問題是資料的先後順序的問題,如果程式 A 產生資料並且程式 B 列印資料。則程式 B 列印資料之前需要先等 A 產生資料後才能夠進行列印。

需要注意的是,這三個問題中的後面兩個問題同樣也適用於執行緒

第一個問題線上程間比較好解決,因為它們共享一個地址空間,它們具有相同的執行時環境,可以想象你在用高階語言編寫多執行緒程式碼的過程中,執行緒通訊問題是不是比較容易解決?

另外兩個問題也同樣適用於執行緒,同樣的問題可用同樣的方法來解決。我們後面會慢慢討論這三個問題,你現在腦子中大致有個印象即可。

競態條件

在一些作業系統中,協作的程式可能共享一些彼此都能讀寫的公共資源。公共資源可能在記憶體中也可能在一個共享檔案。為了講清楚程式間是如何通訊的,這裡我們舉一個例子:一個後臺列印程式。當一個程式需要列印某個檔案時,它會將檔名放在一個特殊的後臺目錄(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 ,則該位清除,而程式仍然保持清醒。

然而,當程式數量有許多的時候,這時你可以說通過增加喚醒等待位的數量來喚醒等待位,於是就有了 2、4、6、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

www.encyclopedia.com/computing/n…

j00ru.vexillium.org/syscalls/nt…

www.bottomupcs.com/process_hie…

en.wikipedia.org/wiki/Runtim…

en.wikipedia.org/wiki/Execut…

一文帶你懟明白程式和執行緒通訊原理

相關文章