作業系統的程式/執行緒同步問題

weixin_34337265發表於2018-01-06

來自我的個人部落格 Minecode.link

很多作業系統都提供了程式和執行緒的併發操作,他們可能在非同步執行時訪問共享資料,而併發訪問共享資料可能帶來資料不一致的同步問題,在此總結一下作業系統的程式/執行緒同步問題,以執行緒的併發為例。

問題簡介

6362190-b2f809c9ed6afb02.png
多執行緒實現原理(以iOS為例)

上圖是多執行緒的狀態(以iOS系統為例)。作業系統是通過CPU的時間片輪轉來實現多執行緒的,每個執行緒有著對應的時間片,當其時間片到來時CPU會切換到該執行緒上下文並執行,待時間片結束後切換至下一執行緒,儲存原執行緒的上下文並載入下一執行緒的上下文,依次迴圈。

但是,CPU何時切換並不是由使用者決定,當時間片到達後會立即進行執行緒的切換,那麼當多個執行緒併發進行讀寫操作時,就可能出現執行緒同步問題。

我們先重現一下這個問題,以下函式模擬了買票的操作,持續買票直到賣完,整型sum表示當前的票數,函式如下:

- (void)buyFunction {
    // 有餘票則繼續購買
    while (sum > 0) {
        // 購買前列印當前票數
        NSLog(@"執行緒%@準備買票 剩餘:%d張票", [NSThread currentThread].name, sum);
        // 模擬購買操作,票數-1
        sum--;
    }
    // 票賣完則列印當前票數
    NSLog(@"執行緒%@: 票已賣完 剩餘:%d張票", [NSThread currentThread].name, sum);
}

現在我們開兩個執行緒同時執行此操作,初始為10張票(sum = 10),檢視控制檯輸出:

執行緒2準備買票 剩餘:10張票
執行緒1準備買票 剩餘:10張票
執行緒1準備買票 剩餘:9張票
執行緒2準備買票 剩餘:8張票
執行緒1準備買票 剩餘:7張票
執行緒2準備買票 剩餘:6張票
執行緒1準備買票 剩餘:5張票
執行緒2準備買票 剩餘:4張票
執行緒1準備買票 剩餘:3張票
執行緒2準備買票 剩餘:2張票
執行緒1準備買票 剩餘:1張票
執行緒2: 票已賣完 剩餘:0張票
執行緒1: 票已賣完 剩餘:-1張票

觀察結果,我們發現了一些問題:
(1) 兩次買票過程餘票數相同
(2) 同一張票買了兩次
(3) 沒有餘票之後仍然被買了一次

這樣的結果已經體現出了執行緒不安全的危害,為什麼會出現這種情況呢?
前面講到,CPU會在時間片結束後儲存當前執行緒上下文,並切換至下一執行緒。那麼當前執行緒很可能在獲取了資料還沒來得及處理,時間片就已結束,而當該執行緒的下一時間片到來時,資料可能已經變化了。一種可能的過程如下圖所示

6362190-25ce94227a76fd1c.png
執行緒同步出現問題

這便是程式/執行緒併發訪問資料時會存在的同步問題,接下來我們討論如何解決該問題。

臨界區問題

為了併發訪問資料的同步問題,我們介紹臨界區的概念。

臨界區: 一種程式碼段,在其中可能發生多個執行緒共同改變變數、讀寫檔案等操作,其要求當一個執行緒進入臨界區時,其他執行緒不能進入。從而避免出現同時讀寫的問題。(實際上,臨界區只需保證可以有多個讀者同時執行讀取操作,或唯一寫者執行寫入操作)
進入區: 判斷執行緒能否進入臨界區的程式碼段。
退出區: 執行緒離開臨界區後可能對其執行的某些操作。
剩餘區: 執行緒完全退出臨界區和退出區後的剩下全部程式碼。

對於上述的買票示例,買票的整個過程即為臨界區程式碼,但我們缺失了進入區,無法保證臨界區內執行緒的唯一,所以出現了同步問題。

臨界區排程原則

所有臨界區排程應當符合以下原則:

  1. 同一時間臨界區內僅可有一個執行緒執行,如果有若干執行緒請求進入空閒臨界區,一次進允許一個執行緒進入,如果臨界區已有執行緒,則要求其他檢視進入臨界區的執行緒等待。
  2. 進入臨界區的執行緒必須在有限時間內退出,以保證其他執行緒能進入該臨界區。
  3. 如果執行緒不能進入臨界區,應讓出CPU,避免出現忙等現象。
  4. 從執行緒請求進入臨界區到允許,有次數限制,避免讓執行緒無限等待。

總結為三點: 互斥、前進、有限等待

Tips: 在非搶佔核心系統中程式會一直執行直到中斷或退出,故不涉及程式同步問題。

臨界區問題的解決方法

解決臨界區問題,需要通過加鎖的方式,類似於當一個執行緒進入臨界區後即上鎖,阻止其他執行緒進入,待執行完成後開啟鎖允許其他執行緒進入。

軟體實現方法

解決臨界區問題,主要在於保證資源的互斥訪問,以及避免出現飢餓現象。

Peterson演算法提供了一個合理的思路: 設定旗標陣列flag標記請求進入臨界區的執行緒,設定turn表示可以進入臨界區的執行緒,在進入區進行雙重判斷,兩個執行緒同時對turn賦值只會有一個保留下來,從而確保資源訪問的互斥。
而在退出區,對flag旗標進行了false處理,從而保證了"前進"原則,避免了剩餘區中的執行緒持續搶佔造成其他執行緒飢餓。

硬體實現方法

Peterson演算法是基於軟體的實現,而從硬體層面也可以解決此問題,硬體方面的處理主要在於執行緒修改共享資源時是原子地,即不可被中斷。比如機器提供了能夠原子執行的指令,那麼我們可以通過簡單的修改布林變數來實現互斥,因為加鎖的過程是原子的。而對於可能造成飢餓的問題,只需在退出區對等待列表進行一定處理,保證"前進"原則即可。

簡單來說,無論硬體還是軟體的實現,本質都是通過加鎖。只是通過硬體的特性,可以提高效率,同時也簡化了臨界區的實現難度。

訊號量

臨界區問題為我們解決執行緒同步提供了一種思路,而在實際使用中,要處理同一個例項有多個資源的情況,我們可以採用一種較為簡單的方式——訊號量,大多作業系統都提供了訊號量的同步工具。

原理

簡單來說,訊號量是某個例項可用資源的計數,初始為該例項可用資源的數量,而每當執行緒需要使用,則呼叫wait()方法減少訊號量,釋放資源時呼叫signal()方法增加訊號量,故訊號量為0表示所有資源都在被使用,執行緒使用資源的請求不被允許。

訊號量主要分為計數訊號量和二進位制訊號量,前者主要針對一個例項有多個資源的情況,值域不受限制,而後者訊號量僅為0或1,也就是說執行緒之間訪問該資源是互斥的,也可稱作互斥鎖

同臨界區問題的前提: 必須保證執行訊號量操作wait(),signal()是原子地。

以下是訊號量的虛擬碼實現:

while (true) {
    waiting(mutex); // 減少訊號量(進入區)
    // 臨界區
    signal(mutex); // 增加訊號量(退出區)
    // 剩餘區
}

具體實現

由於基於臨界區問題,那麼訊號量在具體實現中也要處理其缺點: 飢餓問題。同時其需要避免忙等問題和死鎖問題。

在此之前我們先看一下其基本功能實現。

實現訊號量需要維護一個訊號量值和一個等待程式連結串列。
當訊號量為0時,將請求進入臨界區的程式放入等待列表中,並阻塞自己(避免出現頻繁迴圈請求的忙等問題)。待其他臨界區退出時,從等待列表中取出並喚醒。以下為實現的虛擬碼:

// 訊號量定義
typedef struct {
    int value;
    struct process *list;
} semaphore;

// 減少訊號量
void wait(semaphore *mutex) {
    mutex->value--;               // 減少訊號量值
    if (mutex->value < 0) {
        addToList(list, mutex);   // 將該程式新增至等待程式連結串列
        block();                  // 阻塞自己等待喚醒
    }
}

// 增加訊號量
void signal(semaphore *mutex) {
    mutex->value++;                                  // 增加訊號量   
    if (mutex->value <= 0) {
        process *p = removeFromList(list ,mutex);    // 同等待連結串列中取出一個程式
        wakeup(P);                                   // 喚醒等待中的程式
    }
}

飢餓問題

在訊號量大於0時從佇列中取出哪個程式是需要討論的問題,選擇合適的排程方式很重要。FIFO(先進先出)可以解決,但如果LIFO(後進先出)排程則可能會造成部分程式無限期阻塞,也就是飢餓問題。

忙等問題

當程式請求進入臨界區而沒有被允許時,如果此程式開始在進入區連續迴圈請求,則會消耗大量效能,浪費了部分CPU時間片。這種加鎖方式稱為自旋鎖,即程式在等待鎖時仍然在執行,此方法會造成忙等的效能浪費,但同時也比阻塞-喚醒機制效率更高,避免了阻塞到喚醒的上下文切換。

如果要克服忙等問題,可以在進入區增加當訊號量小於0時,程式阻塞自己,進入等待佇列;當臨界區內的執行緒執行完畢後,喚醒等待佇列中的程式。同時要保證等待佇列排程的演算法合理性,避免某程式無限期等待。

死鎖問題

死鎖問題就是多個程式無限等待某個事件,而該事件是由這些程式來產生的,這樣就會造成"第二十二條軍規"中的問題,程式之間互相等待,無法前進。在此不討論死鎖問題,只介紹可能出現的死鎖情況。如下圖所示:

6362190-2cd9bb58fea9837b.png
訊號量死鎖的情況

解決死鎖問題主要可以從死鎖預防、死鎖避免、死鎖檢測、死鎖恢復四個方面入手,後面會專門寫文章講解。

不同處理器的解決方案

單個處理器: 單處理器時無須擔心並行運算造成的同步問題,所以簡單在wait()和signal()中禁止臨界區中程式的中斷即可。

多處理器: 作業系統對於多處理器排程分為SMP和非SMP的情況(SMP為對稱多處理,處理器各自控制各自的排程,而非SMP為某一個處理器作為中控,管理其他處理器的排程)。

非對稱多處理: 對於非對稱多處理,由於有中央處理器來排程,可以簡單使用自旋鎖來進行忙等,系統來決策等待鎖的程式的排程問題。
對稱多處理: 對於對稱多處理系統,就要自行實現上述的訊號量等待列表,以及等待鎖時的阻塞——喚醒機制。

經典同步問題

涉及到同步問題,有幾種經典的問題,主要的有讀者——寫者問題和哲學家進餐問題,前者關注互斥問題,後者關注死鎖問題。

讀者——寫者問題

僅進行讀取操作的為讀者,而讀寫操作均需要的為寫者。仍然以剛才的買票問題為例,我們不難發現,當同時讀取時,不會出現問題,當唯一寫入時,也不會出現問題,但是當同時進行讀寫時,則發生了資料錯誤問題。

所以讀者——寫者問題應當保證同一時間寫者的唯一性及讀者要等待寫者完成後再執行

同時,在實際實現中,要著重處理讀者或寫者的飢餓問題,讀者/寫者優先的方案很可能造成對方的飢餓。

哲學家進餐問題

哲學家問題是一個經典的死鎖問題,n個哲學家圍坐在圓桌上,圓桌上放著n支筷子,也就是沒人左右都有1支筷子。只有同時擁有兩支筷子才能吃飯,吃完飯後會放下筷子。若同一時間每個哲學家都先拿起右手邊的筷子再拿起左手的,那麼就造成了死鎖問題,每個人都在等待左手的筷子。

6362190-5b6e636c9be144dc.png
哲學家進餐問題

解決辦法多種多樣,可以限制哲學家的數量為n-1,也可以要求拿起筷子前判斷是否左右手的筷子均空閒。而本質上,解決方法就是死鎖的四種解決方法: 死鎖預防、死鎖避免、死鎖檢測、死鎖恢復

總結

程式/執行緒同步問題是作業系統在資料共享方面的一大問題,其不僅需要硬體及系統級的實現,同時還需要程式設計師在開發時避免死鎖,同步問題與死鎖問題密不可分,後面將會討論死鎖問題。

相關文章