【操作系統】第十章:基於信號量和管程的同步實現

上章鏈接: 【操作系統】第九章:臨界區的概念和互斥的理解.

爲了克服競態條件(引入不確定結果的情況),引入Lock機制。鎖機制可以實現互斥訪問,但是光互斥還是不夠的,我們還需要一些同步的機制、在某個臨界區內 允許多個進程/線程去執行,這種時候只有鎖機制就不夠了。所以我們需要更高層的同步互斥語義以及藉助硬件的原子操作來實現更高層的同步互斥的功能。
在這裏插入圖片描述
有了鎖機制,就可以確保臨界區的操作是互斥的。

同步

同步機制的實現:臨界區內可以有多個進程或者線程來執行。某些進程/線程可能不做寫操作,只是讀操作,那麼就沒必要限制這些線程禁止執行。爲了應對這種現象,我們可以通過信號來實現這種禁止。

信號量(Semaphore)

信號量用一個整形表示,有兩個針對信號量的操作。分別是P操作和V操作。
在這裏插入圖片描述
P操作:P類似於獲取LOCK,如果在這被擋住則無法繼續向下執行(臨界區或者其他類型的操作)

對於p[i]
sem-1;
if (sem<0)
wait();
else
exec();

V操作:信號量值+1並判斷閾值,小於等於0則會喚醒掛在信號量上的等待進程。喚醒一般喚醒一個,也可以喚醒多個

sem+1;
if (sem<=0)
awaken();

在這裏插入圖片描述
信號量類似信號燈。LOCK只允許有一個火車通過,而信號量不同,可以有多個(這裏是兩個)進程同時通過。每一個火車再進入臨界區之前,執行一個P操作,離開時執行一個V操作。


信號量的屬性

在這裏插入圖片描述
1.這個整形是個有符號數。一開始一般會設置爲大於0的數,也就意味着一開始不會被阻塞。但是因爲P操作做減法,一旦信號量小於0,則意味着當前執行P操作的進程被掛起。只能等待其他進程執行V操作喚醒。也就說P能過阻塞而V絕不會阻塞。信號量喚醒,假設每次喚醒一個則經常使用FIFO處理。
之前忙等的鎖能否公平的通過FIFO來實現喚醒?
【在非搶佔系統中可以,搶佔則可能被調度,不一定FIFO喚醒】
2.兩種類型的信號量
在這裏插入圖片描述

鎖機制的取值也是0或者1。那麼我們可以設計一個二進制信號量來模擬LOCK,來完全實現LOCK的功能。同時,信號量的初值可以大於0,則我們稱這種的爲通用的計數信號量。通用計數信號量會把信號量初值設置爲大於0的數,就允許多個執行P操作的進程來進入之後的過程。信號量除了應用於互斥還能用於條件同步。


信號量的使用

在這裏插入圖片描述

這個操作與LOCK操作對應。獲取鎖(P)釋放鎖(V),爲了模擬LOCK將初值設置爲1.
在臨界區之前做P操作,臨界區之後做V操作。這是二進制最常用的用法,完全可以用來代替鎖操作。而且除了完成互斥操作,還需要完成同步操作,同步操作的初值要設置爲0。爲什麼設置成0?如下圖
在這裏插入圖片描述
我們需要線程A必須要的等到線程B執行到某一條語句之後纔可能繼續執行,這是一個同步關係。我們用信號量來完成。
condition->P():進程A在需要等待地方執行P操作,sem-1,被掛起
condition->V():線程B在執行V操作,sem+1,線程A被喚醒
這是二進制信號量來完成的同步操作。


更復雜的同步互斥情況可能無法通過簡單的二進制信號量解決,此時我們需要用到一些條件同步來完成。
下面上具體例子:
在這裏插入圖片描述
需要用到同步和互斥兩種操作的過程。整個操作過程中的問題:
1.Buffer緩衝器是有限的。當生產者在往裏面寫數據的時候,消費者不能再做相應的操作,但是可以多個生產者都往裏面寫數據(這裏與LOCK不同),也可以有多個消費者來取數據,這取決於信號量初值。
2.同步約束條件:當緩衝區爲空,消費者取不出數據,所以消費者應該睡眠,直到生產者寫入數據再次被喚醒。同理,緩存區滿時,消費者無法再往緩衝區內寫數據,直到消費者從BUFFER裏取數據使得緩存區不滿,才能繼續寫入數據。
這個例子裏用到了互斥操作和同步操作。這麼多約束條件用信號量來解決:
在這裏插入圖片描述
二進制信號量:確保添加和取出這個互斥操作。
一般(資源)信號量fullBuffers:當BUFFer內從無到有資源,則喚醒消費者。初值爲0,意味着一開始是緩衝區內是空的
一般信號量:當BUFFer內資源從滿到有空間時,喚醒生產者。emptyBuffers:BUFFer的容量,生產者可以往裏面放多少數據

在這裏插入圖片描述
解析:
首先,因爲對緩衝區的讀寫是互斥操作,所以要首先保障互斥性,即當生產者寫入數據或者消費者取出數據時,這個操作只能由一個操作在執行(這裏是一個進程,根據初值的不同可以是多個進程,總之操作是互斥的,讀和寫不可以同時操作)
所以要將P和V操作包住讀和寫BUFFer的語句。
對於生產者:首先要確定緩存區是否滿了,滿了則不向下執行。用emptybuffers的P操作來實現,因爲生產者的信號量是n,也就說一個進程可以進入n次或者可以由n個進程同時進行寫操作。當emptybuffers的值小於0時,被阻塞;添加完數據後,還會執行一個fullbuffers的V操作,是由於Fullbuffer的初值是0,也就意味着如果消費者一開始想取數據是取不到數據的,因爲緩衝區爲空,而這一步的操作是告訴消費者,此時緩存區非空。
同理對於消費者就是一個相反的過程,首先執行fullbuffers的P操作,檢測如果生產者先執行,則FB的值是1,此時消費者可以繼續往下走;但是如果是消費者先執行,由於FB的初值是0,所以消費者會被掛起。而最後一步的EB的V操作是假設生產者填入大量數據導致緩存區滿而睡眠,通過V操作來喚醒生產者,通知其緩存區已經不滿,可以執行寫操作。

那麼P操作和V操作的順序有影響嗎?
Re)互換V操作沒有問題,但是互換P操作會產生死鎖。因爲V操作相當於擴大臨界區,在臨界區內執行的操作是互斥的不會被打斷;而P操作的互換會導致在BUFF滿或者空的時候出現死鎖,甚至會導致同時進入臨界區的問題。

信號量的實現

在這裏插入圖片描述
特點:
在這裏插入圖片描述
信號量使用困難,爲了讓開發者較簡單地來使用同步互斥手段,引出了管程的概念。

管程

管程的抽象程度比信號量還要高,也就意味着給開發者提供了更抽象的機制來更容易地完成同步互斥的問題。
在這裏插入圖片描述
管程最早是用於編程語言而非操作系統,這些語言通過設計管程可以簡化高級語言來完成同步互斥操作。也就意味着管程的整個實現是針對語言的併發機制來實現的。
管程(monitor):包含了一系列共享變量以及針對這些變量操作函數的組合模塊。
管程包含了鎖和條件變量。鎖是爲了確保所有要訪問管程管理的函數只能有一個線程;因爲會大量訪問各種共享資源,訪問過程中可能某個條件得不到滿足,就需要掛起那些得不到的滿足的線程,掛到條件變量。根據條件個數來確定需要多少個條件變量。
這兩個機制的組合就實現了管程。
在這裏插入圖片描述
如圖,首先進入管程就需要一個隊列。因爲進入管程是互斥的,首先要取得鎖,進入管程空間後,線程就可以執行管程維護的一系列函數。函數執行過程中可能會針對某個共享資源的操作得不到滿足,則需要等待。又因爲訪問是互斥的,所以需要掛起到條件變量並釋放鎖。條件變量裏有兩個等待隊列,放着所有掛起的線程,條件滿足時,會喚醒相應線程。
在這裏插入圖片描述

條件變量有wait和signal操作。wait是掛起線程到條件變量,signal操作是喚醒條件變量使得掛起的線程有機會繼續執行。
在這裏插入圖片描述
和信號量類似。
Condition::Wait(lock)
1.wait操作執行,表明要去睡眠。
2.然後掛起
3.解鎖,因爲進入管程時已經加鎖
4.調度,選擇下一個線程去執行
5.加鎖,給新入進程加鎖保證互斥
Condition::Signal()
1.判斷等待隊列是否有線程或進程
2.將等待隊列中的線程取出
3.喚醒線程
4.等待隊列序號減少
#.隊列中如果沒有等待線程,則這個函數什麼都不做,也就說不一定會做減操作
同樣根據生產者和消費者的例子實現同步互斥:
在這裏插入圖片描述
1.buffer和count是共享變量。
2.爲了完成互斥,加鎖Lock。加鎖的位置是頭和尾,由管程定義決定,管程定義線程進入到管程時,只有一個線程可以進入。由於函數是管理共享變量的,所以一定要確保互斥性和唯一性。
3.同步的實現:緩存器滿,生產者睡眠。緩存器空,消費者睡眠。
對於生產者:
count==n時,標識緩存已滿。notFull. Wait(&lock)中notFull是一個條件變量,表明需要睡眠。這裏的lock是讓當前生產者釋放掉這個鎖,這使得其他線程纔有可能進入線程去執行。一旦將來被喚醒,意味着再去完成一次Lock::Acquire();,一旦獲得lock就可以跳出wait操作,下一步仍然是判斷count和n是否相等。這裏用while而不用if是因爲防止多生產者和多消費者的虛假喚醒。
notEmpty.Signal();每當放入緩存一個程序,即使緩存爲空也變爲非空,此時提醒消費者可以取數據,會喚醒消費者
對於消費者:
一開始,緩存器爲空,此時消費者無法取數據。notEmpty會判斷count是否爲0,如果爲0則消費者被掛起到notEmpty這個條件變量。
因爲count–,此時buffer即便是滿的也已經不滿了,Notfull.Signal()會起到提醒作用,如果notfull裏有等待的生產者進程,就會被喚醒,兩個正好配對。

在這裏插入圖片描述
當線程在管程內執行時,如果某線程要被喚醒時,是馬上執行阻塞態的線程呢、還是先完成發出喚醒操作的線程再去完成被喚醒線程呢?這是不一樣的。一旦發出signal操作意味着管程內有兩個線程可以執行,一個是本身正在執行的和被喚醒的線程。
那麼選擇哪一個先執行:有兩種方法
Hoare:
一旦發出signal操作,就應該讓被喚醒線程執行,然後自身去睡眠。直到線程執行到release之後纔會繼續執行。如右圖。這種方法實現起來比較困難。主要見於教材中
Hansen:
發出signal後,直到自己release之後纔會交給被喚醒者執行。 如左圖。實現起來比較簡單。主要用於真實OS和java中
在這裏插入圖片描述
while操作和if操作是由於喚醒機制不同造成的。
對於Hansen執行完signal操作之後,並沒有馬上讓被喚醒的線程執行,這種情況下可能會有多個等待着條件隊列上的線程被喚醒,也就說喚醒的可能不止一個。線程會去搶佔執行,也就說這個喚醒其他線程的線程當它能夠被選中去執行時,count可能不爲n了,所以需要一個while來確認。
對於Hoare的實現機制,signal操作之後會把控制權交給被喚醒的等待線程,每次只喚醒一個。那麼被喚醒線程佔用CPU執行後,count一定不爲n,因爲signal的執行條件是count<n。喚醒操作喚醒消費者,消費者會令count減少。也就說即便現在是放入數據後就達到n的閾值,切換到被喚醒者後會取出至少一個數據,此時緩存區內至少爲n-1,此時再切換回來執行+1操作仍然不會導致溢出內存。所以被喚醒者切換回來的步驟不需要再次判斷n的大小,因爲一定小於n。
在這裏插入圖片描述
底層機制的支持才能完善高層抽象。同步互斥因爲非確定性的交叉指令導致開發和調試非常困難。

同步互斥問題典例

讀者-寫者問題

概念:
在這裏插入圖片描述
規則:
1.寫操作時,只能有一個寫者對數據進行寫操作,寫操作執行時無法執行讀操作。
2.因爲讀操作不會改變數據,所以允許多個讀者同時讀數據。
3.當讀者在讀數據時,寫者必須等待讀者讀完後;同理寫者在寫數據,讀者和其他寫者都必須等待。
4.讀者優先。如果一開始有幾個讀者在讀數據,寫者來到並等待。此時寫者之後又來了一個讀者,則該讀者跳過等待的寫者,優先執行。

信號量實現讀者優先

在這裏插入圖片描述
同一時刻,首先要知道有多少讀者,用Rcount來計數,寫者只有一個。且CountMutex來保證Rcount的讀是互斥操作。寫本身因爲也需要互斥,所以還需要一個WriteMutex來完成對寫的互斥。
在這裏插入圖片描述
分析:
首先通過P和V操作來保證互斥,這裏PV用具體函數來表示。
然後因爲讀者可以有多個,Rcount如果等於0則意味着沒有讀者,也就說它本身是第一個讀者,但是又因爲沒讀者可能有寫者,所以首先執行寫操作的P操作保證沒有寫者存在;如果此時Rcount不爲0,意味着他不是第一個讀者,所以接下來寫者一定進不來,讀者可以繼續往下走。
讀完之後,做一個減操作,此時需要判斷是否還有讀者,如果沒有讀者,則意味着當前讀者是最後一個讀者,它即將離開意味着如果有寫者可以進入寫操作,所以要做一個喚醒,也就是寫操作的V操作。
Rcount是一個共享變量,因此當多個讀者對其進程操作時,要保證互斥性,需要由讀的PV操作包住Rcount的變化。


這裏存在一個問題,那就是如果用這種方法的話 ,如果讀者源源不斷出現,則寫者會被阻塞;即便改成寫者優先的寫法,如果寫者源源不斷則讀者也會被堵塞。那麼如何解決?

管程實現寫者優先

寫者優先的僞代碼:
在這裏插入圖片描述
規則
在這裏插入圖片描述

讀者:
1.讀者如果要執行讀操作,首先要檢測當前是否有寫者。當前有一個寫者正在執行,此時讀者等待;如果等待隊列裏有寫者,則讀者需要等待,等待隊列的寫者優先級高於讀者。
2.讀數據庫
3.檢查當前是否有寫者處於等待狀態,如果有則喚醒
寫者:
1.如果當前有正在執行的讀者/寫者,則等待。等待隊列中的讀者不用等
2.當臨界區內無讀者和寫者,則寫
3.檢測其他的讀者或者寫者並喚醒
基於管程的狀態標識:
AR:正在讀的讀者數
AW:正在寫的寫者數
WR:正在等待的讀者數
WW:正在等待的寫者數
Lock:管程內函數調用是互斥操作,所以需要鎖
okToRead:條件變量,表示當前可讀
okToWrite:條件變量,表示當前可寫


讀者:
在這裏插入圖片描述
開始讀:StartRead
1.管程中函數,確保互斥,則需要加鎖。也就說函數首尾被加上加鎖和解鎖。
2.因爲已經開始讀,所以正在讀的讀者數會增加。也就說AR會增加
3.也有可能需要等待, 需要等待寫者。 while做一個判斷,在某種情況下,如果有寫者,則等待隊列中的寫者增加且此時不能執行讀操作,必須等待寫操作執行完畢,所以掛起到okToRead的條件變量上並釋放鎖。假設這個讀者被喚醒,則會從wait語句中跳出,因爲此時有一個寫者已釋放,所以寫者會減少一個。
4.判定條件:沒有寫者,即AW或WW有一個大於0,可以用AW+WW>0表示
因爲允許多個讀者可以同時讀操作且進入管程是互斥的,所以AR++放在最後。
結束讀:DoneRead
1.讀完後,釋放讀者,管程的函數特徵的LOCK保證進入管程操作的唯一性。
2.當前有寫者的情況下我們需要喚醒寫者,也就說有等待的寫者。而且還要要求正在讀的讀者爲零,如果還有正在讀的讀者,則寫者繼續等待,該讀者釋放,寫者需要等最後一個出去的讀者來喚醒它。

StartRead和DoneRead完成了完整的讀者實現。在StartRead中的while語句中體現了寫者優先。


寫者:
在這裏插入圖片描述
StartWrite需要判斷當前沒有讀者也沒有寫者才能開始寫。
1.因爲是管程操作,所以一定會加鎖。一旦開始寫,則當前正在寫的寫者數量自然會增加。所以AW++
2.假設當前有正在讀的讀者或者正在寫的寫者,首先等待。WW++,並將自己掛起到條件變量中。
DoneWrite
1.LOCK
2.AW–因爲此時做完了一個寫操作,使得當前正在寫的寫者-1
3.喚醒等待隊列的寫者或讀者。寫者優先,所以優先考慮寫者。當喚醒讀者時,喚醒所有讀者而不是一個,不同於寫者只能有一個寫者,讀者必須寫者全部處理完畢纔會被喚醒。此時,處於等待隊列的所有讀者會被喚醒。

哲學家就餐問題

在這裏插入圖片描述
fork[5]表示叉子,未被拿起用1表示。P操作是拿起,放下是V操作。

在這裏插入圖片描述
哲學家代表進程或線程。個數是5,數組表示。
進程裏的循環就是:先拿左邊的叉子再拿右邊的叉子;兩個叉子拿到就吃飯,完事後先放下左邊再放下右邊。
死鎖問題:
五個進程每一步都是同時做的,都拿左邊的叉子,五個叉子都被拿完了,然後開始下一步,大家準備拿右邊的叉子,發現沒有叉子,但是還握着左邊的叉子,就會進入死鎖。
改進1
在這裏插入圖片描述
五個人同時拿,發現右邊沒有叉子,同時放下。然後等一會,再拿,再放,進入死循環。
改進2
在這裏插入圖片描述
改進一下,等待隨機時間。 這樣的話可行,但是取決於隨機數的變化。我們希望進程的執行公平執行,這樣會導致某些進程執行多某些進程執行少。雖然能克服死鎖情況,但是不能得到確定保證。
改進3:互斥訪問
把拿和放叉子的操作用PV包起來,實現互斥。
在這裏插入圖片描述
由於互斥訪問,導致不會死鎖和死循環。一定可以確保拿到兩把叉子並放下兩把叉子,這個過程別人不會打斷,因爲只有一個進程可以進入臨界區執行。
問題:每次只允許一個人進餐。實際上五把叉子應該至少允許有兩個哲學家同時進餐,但是這種解法最多隻有一個哲學家可以進餐。應該令相鄰的兩個進程不可以同時就餐,但是不相鄰的可以同時就餐。
改進4
在這裏插入圖片描述
管程鄰居就餐情況,左鄰居在就餐說明沒有左邊的叉子,右鄰居進餐說明沒有右邊的叉子,那麼這時候需要等待。
如果發現兩邊都沒有進餐,說明條件允許,拿起兩把叉子吃飯。吃完後放下左叉子,放下右叉子。
在這裏插入圖片描述
這裏面叉子不再是臨界資源,而是哲學家(進程)的狀態。可以分爲思考中/飢餓/正在吃/阻塞 四種狀態。
其中思考並不重要,去除。也就說我們需要考慮的是進程的三個狀態。每一個進程判斷鄰居的狀態做出響應。
在這裏插入圖片描述
判斷鄰居的狀態時是讀操作,設置的時候是寫。喚醒存在同步關係。
在這裏插入圖片描述
在這裏插入圖片描述
着重設計的應該是take和put函數。
對於拿叉子:

void take_forks(int i)//i取值0~N-1
{
P(mutex);//臨界區保護
//狀態的改變是需要互斥保護的,因爲左鄰右舍進程做狀態判斷時可能會去讀它,會根據它的當前狀態來判斷下一步的行動
 state[i]=HUNGRY;//第一步,狀態改變,因爲餓了所以纔打算拿叉子
 //不管拿不拿得到叉子,首先我餓了,狀態改變
 test_take_all_forks();//嘗試去拿兩把叉子
 //拿叉子需要判斷其他進程是否處於eating狀態,如果處於eating則阻塞,所以需要互斥保護
V(mutex);
  P(s[i]);	//同步信號,沒有叉子便阻塞
}
void test_take_all_forks(int i)
{
if(state[i]==HUNGRY&&state[LEFT]!=EATING&&state[RIGHT]!=EATING)
 {
  state[i]=EATING;//兩把叉子到手,用狀態表示
  V(s[i]);//通知第i人可以吃飯了
  //爲什麼通知自己可以吃飯了呢,因爲take_forks()函數後面會有一個P操作
  //這個P操作在不執行本函數時會阻塞,執行時則會+1-1=0使他不會被阻塞
}

對於放回叉子:

void put_forks(int i)
{
state[i]=THINKING;//吃完飯了,所以不會立刻飢餓
test_take_all_forks(LEFT);//看左鄰居狀態是否飢餓
test_take_all_forks(RIGHT);//看右鄰居狀態是否飢餓
}
void test_take_all_forks(int i)
{
if(state[i]==HUNGRY&&state[LEFT]!=EATING&&state[RIGHT]!=EATING)
 {
  state[i]=EATING;//兩把叉子到手,用狀態表示
  V(s[i]);//喚醒鄰居
}

這裏對於鄰居本身,其執行時由於test_take_all_forks執行不成功導致V操作沒有執行,然後會在take_forks中被阻塞。直到現在的i進程檢測它的狀態是飢餓且鄰居的鄰居也沒有吃飯時,會將其喚醒。此時V操作會正好令上一個P操作的-1消失。

eat()操作不需要同步互斥。thinking()在一開始需要做一個同步互斥, 一開始把所有人置成thinking態。因爲他也是進程的狀態,而狀態在這個例子裏是臨界資源,所以我們需要將其保護起來。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章