來自我的個人博客 Minecode.link
很多操作系統都提供了進程和線程的併發操作,他們可能在異步執行時訪問共享數據,而併發訪問共享數據可能帶來數據不一致的同步問題,在此總結一下操作系統的進程/線程同步問題,以線程的併發爲例。
問題簡介
上圖是多線程的狀態(以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會在時間片結束後保存當前線程上下文,並切換至下一線程。那麼當前線程很可能在獲取了數據還沒來得及處理,時間片就已結束,而當該線程的下一時間片到來時,數據可能已經變化了。一種可能的過程如下圖所示
這便是進程/線程併發訪問數據時會存在的同步問題,接下來我們討論如何解決該問題。
臨界區問題
爲了併發訪問數據的同步問題,我們介紹臨界區的概念。
臨界區: 一種代碼段,在其中可能發生多個線程共同改變變量、讀寫文件等操作,其要求當一個線程進入臨界區時,其他線程不能進入。從而避免出現同時讀寫的問題。(實際上,臨界區只需保證可以有多個讀者同時執行讀取操作,或唯一寫者執行寫入操作)
進入區: 判斷線程能否進入臨界區的代碼段。
退出區: 線程離開臨界區後可能對其執行的某些操作。
剩餘區: 線程完全退出臨界區和退出區後的剩下全部代碼。
對於上述的買票示例,買票的整個過程即爲臨界區代碼,但我們缺失了進入區,無法保證臨界區內線程的唯一,所以出現了同步問題。
臨界區調度原則
所有臨界區調度應當符合以下原則:
- 同一時間臨界區內僅可有一個線程執行,如果有若干線程請求進入空閒臨界區,一次進允許一個線程進入,如果臨界區已有線程,則要求其他視圖進入臨界區的線程等待。
- 進入臨界區的線程必須在有限時間內退出,以保證其他線程能進入該臨界區。
- 如果線程不能進入臨界區,應讓出CPU,避免出現忙等現象。
- 從線程請求進入臨界區到允許,有次數限制,避免讓線程無限等待。
總結爲三點: 互斥、前進、有限等待。
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時,進程阻塞自己,進入等待隊列;當臨界區內的線程執行完畢後,喚醒等待隊列中的進程。同時要保證等待隊列調度的算法合理性,避免某進程無限期等待。
死鎖問題
死鎖問題就是多個進程無限等待某個事件,而該事件是由這些進程來產生的,這樣就會造成”第二十二條軍規”中的問題,進程之間互相等待,無法前進。在此不討論死鎖問題,只介紹可能出現的死鎖情況。如下圖所示:
解決死鎖問題主要可以從死鎖預防、死鎖避免、死鎖檢測、死鎖恢復四個方面入手,後面會專門寫文章講解。
不同處理器的解決方案
單個處理器: 單處理器時無須擔心並行運算造成的同步問題,所以簡單在wait()和signal()中禁止臨界區中進程的中斷即可。
多處理器: 操作系統對於多處理器調度分爲SMP和非SMP的情況(SMP爲對稱多處理,處理器各自控制各自的調度,而非SMP爲某一個處理器作爲中控,管理其他處理器的調度)。
非對稱多處理: 對於非對稱多處理,由於有中央處理器來調度,可以簡單使用自旋鎖來進行忙等,系統來決策等待鎖的進程的調度問題。
對稱多處理: 對於對稱多處理系統,就要自行實現上述的信號量等待列表,以及等待鎖時的阻塞——喚醒機制。
經典同步問題
涉及到同步問題,有幾種經典的問題,主要的有讀者——寫者問題和哲學家進餐問題,前者關注互斥問題,後者關注死鎖問題。
讀者——寫者問題
僅進行讀取操作的爲讀者,而讀寫操作均需要的爲寫者。仍然以剛纔的買票問題爲例,我們不難發現,當同時讀取時,不會出現問題,當唯一寫入時,也不會出現問題,但是當同時進行讀寫時,則發生了數據錯誤問題。
所以讀者——寫者問題應當保證同一時間寫者的唯一性及讀者要等待寫者完成後再執行。
同時,在實際實現中,要着重處理讀者或寫者的飢餓問題,讀者/寫者優先的方案很可能造成對方的飢餓。
哲學家進餐問題
哲學家問題是一個經典的死鎖問題,n個哲學家圍坐在圓桌上,圓桌上放着n支筷子,也就是沒人左右都有1支筷子。只有同時擁有兩支筷子才能吃飯,吃完飯後會放下筷子。若同一時間每個哲學家都先拿起右手邊的筷子再拿起左手的,那麼就造成了死鎖問題,每個人都在等待左手的筷子。
解決辦法多種多樣,可以限制哲學家的數量爲n-1,也可以要求拿起筷子前判斷是否左右手的筷子均空閒。而本質上,解決方法就是死鎖的四種解決方法: 死鎖預防、死鎖避免、死鎖檢測、死鎖恢復。
總結
進程/線程同步問題是操作系統在數據共享方面的一大問題,其不僅需要硬件及系統級的實現,同時還需要程序員在開發時避免死鎖,同步問題與死鎖問題密不可分,後面將會討論死鎖問題。