1 進程同步背景
對於之前所提到的生產者消費者問題,採用共享內存解決生產者消費者問題時,N個緩衝區最多隻能用N-1個,那麼爲什麼有一個是用不了的呢?這是因爲在判斷緩衝區空或滿時,用取餘計算實現的,之所以犧牲一個位置是爲了讓緩衝區空和緩衝區滿兩種狀態有兩種不同的表達式,若是換一種方法,設置一個計數變量 count
,count
的值表示當前緩衝區已經使用的容量,count=0
表示緩衝區空,count=BUFFER_SIZE
表示緩衝區滿(BUFFER_SIZE
爲緩衝區大小),這樣就解決了犧牲一個緩衝區容量的問題,如下圖:
下面是實現生產者添加商品的僞代碼,若不能理解該過程可以查閱數據結構中循環隊列的內容:
// 生產者調用的方法
public void enter(Object item) {
// 緩衝區已經滿無法繼續添加
while (count == BUFFER_SIZE) ;
// 添加一個商品到緩衝區
buffer[in] = item;
in = (in + 1) % BUFFER_SIZE;
count++; // 計數加一
}
下面是實現消費者消費商品的僞代碼:
// 消費者調動的方法
public Object remove() {
Object item;
// 當前緩衝區沒有商品,無法消費
while (count == 0) ;
// 從緩衝區移除一個商品
item = buffer[out];
out = (out + 1) % BUFFER_SIZE;
count--; // 商品總數減一
return item;
}
上述思想看起來是沒有問題的,但是在實現過程中會出現問題,問題就出在 count
的自增和自減操作,由於count++
和count--
屬於高級指令,所以在機器執行過程中是分爲三個步驟的,中間還有一步轉交給寄存器的操作,如下圖:
所以當一條指令分解成三個指令時,多個程序併發時就會出現問題,現假設count=5
,若生產一個商品,再消費一個商品,最後count
的值還是等於5
,可在併發執行時就不一定會這樣,見下面的例子(注:以下例子只是情況的一種,在併發執行時會有非常多的不確定性),最終count
的值等於4
:
由以上的例子可知,對共享數據的併發訪問可能導致數據的不一致性,要保持數據的一致性,就需要一種保證併發進程的正確執行順序的機制,解決有界緩衝區問題的共享內存方法在類數據count
上存在競爭條件。
競爭條件:
- 若干個併發的進程(線程)都可以訪問和操縱同一個共享數據,從而執行結果就取決於併發進程對這個數據的訪問次序;
- 爲了保證數據的一致性,需要有同步機制來保證多個進程對共享數據的互斥訪問。
進程的類型分爲兩種,一種是獨立進程,它獨立執行,不受其他進程的影響;另一種就是協作進程,如剛剛所講的生產者和消費者問題,這兩個進程就屬於協作進程。進程間資源訪問衝突也分爲兩種,一種是共享變量的修改衝突,如上面的count
值,另一種則是操作順序衝突,比如某個作業A,需要作業B提供數據進行計算,若B提供數據,那麼A會受到影響。
進程間的制約關係分爲如下兩種:
- 間接制約:有些資源需要互斥使用,因此各進程進行競爭,獨佔分配到的部分或全部共享資源,進程的這種關係爲進程的互斥;
- 直接制約:進行協作,具體說,一個進程運行到某一點時要求另一夥伴進程爲它提供消息,在未獲得消息之前,該進程處於等待狀態,獲得消息後被喚醒進入就緒態.進程的這種關係爲進程的同步。
由於多個進程相互競爭資源,若各個進程互不相讓,此時就會發生死鎖想象。
2 臨界區
臨界資源即共享資源,對於臨界資源,多個進程必須互斥的對它進行訪問,在進程中某些代碼會訪問到臨界資源,這段代碼就叫做臨界區 (critical section),即進程中訪問臨界資源的一段代碼,實現進程對臨界資源的互斥訪問就是讓各進程互斥的進入自己的臨界區,也就是說當某個進程在臨界區中執行時,其他進程都不能訪問自己臨界區,這樣就保證了某個時間內只有一個進程在臨界區內使用臨界資源,這樣就實現了臨界資源的互斥訪問。
臨界區的執行在時間上是互斥的,進程必須請求允許進入臨界區,也就是說當某個進程想進入臨界區時,比如進行某種操作來判斷當前臨界區是否有進程在執行,在具體實現時也是利用代碼來判斷的,整個進程的訪問過程分爲以下三個區:
- 進入區 (entry section):在進入臨界區之前,檢查可否進入臨界區的一段代碼。如果可以進入臨界區,通常設置相應“正在訪問臨界區”標誌;
- 退出區 (exit section):用於將"正在訪問臨界區"標誌清除;
- 剩餘區 (remainder section):代碼中的其餘部分。
臨界區互斥問題的解決方案要滿足如下三個要求:
- 互斥:假定進程 Pi 在其臨界區內執行,其他任何進程將被排斥在自己的臨界區之外;
- 有空讓進:臨界區雖沒有進程執行,但有些進程需要進入臨界區,不能無限期地延長下一個要進入臨界區進程的等待時間;
- 有限等待:在 一個進程提出進入臨界區的請求和該請求得到答覆的時間內,其他進程進入臨界區的次數必須是有限的。
2.1 進程的互斥
如何實現進程間的互斥?這裏舉一個現實中游樂園的滑滑梯例子,滑滑梯一次只能進一個小朋友,當有很多小朋友想要玩的時候,那麼一個解決辦法是讓他們輪流來玩,另一個解決辦法是提出想玩滑滑梯申請。在解決進程間的互斥問題時,也是藉助了這兩個思想,這裏介紹兩種算法。
算法1:設立一個兩進程公用的整型變量 turn
,用來描述允許進入臨界區的進程標識有兩個進程Pi,Pj
, 如果 turn==i
,那麼進程 Pi
允許在其臨界區執行,即採用輪流的方式,用turn
表示當前運行哪個進程進入臨界區。
Pi
進入臨界區的僞代碼如下:
while (turn != i); // 判斷是否輪到 Pi
critical section; // 執行臨界區
turn = j; // 執行完臨界區就輪到下一個 Pj
remainder section; // 執行剩餘區
Pj
進入臨界區的僞代碼如下:
while (turn != j); // 判斷是否輪到 Pj
critical section; // 執行臨界區
turn = i; // 執行完臨界區就輪到下一個 Pi
remainder section; // 執行剩餘區
對於之前提到的臨界區互斥問題的三個要求,該算法顯然是滿足第一個互斥要求的,實際上該算法是強制輪流進入臨界區,沒有考慮進程的實際需要,若 Pi
執行完臨界區,turn
也轉交給了Pj
,但此時Pj
不需要使用臨界區,這時臨界區處於空閒狀態,但turn
這時不屬於Pi
,所以Pi
依然無法執行臨界區,容易造成資源利用不充分,所以不滿足第二個要求有空讓進,也不滿足第三個要求有限等待。
算法2:由於算法1 只記住了哪個進程能進入臨界區,沒有保存進程的狀態,設立一個標誌數組 flag[]
,用來描述進程是否準備進入臨界區,初值均爲 FALSE
,先申請後檢查 。可防止兩個進程同時進入臨界區。
Pi
進入臨界區的僞代碼如下:
flag[i] = TURE; // Pi 申請執行臨界區
while (flag[j]); // 判斷 Pj 是不是在執行臨界區或它也想執行臨界區
critical section;
flag[i] = FALSE; // Pi 執行完臨界區,撤銷之前的申請
remainder section;
Pj
進入臨界區的僞代碼如下:
flag[j] = TURE; // Pj 申請執行臨界區
while (flag[i]); // 判斷 Pi 是不是在執行臨界區或它也想執行臨界區
critical section;
flag[j] = FALSE; // Pj 執行完臨界區,撤銷之前的申請
remainder section;
該算法顯然滿足互斥要求,因爲每次執行臨界區前都會判斷對方是否在執行臨界區或是否也想進入臨界區;設想某個時刻 Pi
和 Pj
都申請執行臨界區,這樣會導致雙方誰也不能執行臨界區,所以不滿足有空讓進的要求,算法2對比算法1的優點是不用交替進入,可連續使用,不用等待對方,缺點就是兩進程可能都進入不了臨界區。
算法3:在算法2的基礎上進一步改進,同樣是要先申請執行臨界區,但要把turn
改爲對方,然後再進行檢查若當前對方在執行臨界區或對方想要執行臨界區且turn
也是對方,那麼就要等待對方執行完。
Pi
進入臨界區的僞代碼如下:
flag[i] = TURE; // Pi 申請執行臨界區
turn = j; // 讓 Pj 下次執行
while (flag[j] && turn = j); // 判斷 Pj 是不是在執行臨界區或它也想執行臨界區且當且turn爲它
critical section;
flag[i] = FALSE; // Pi 執行完臨界區,撤銷之前的申請
remainder section;
Pj
進入臨界區的僞代碼如下:
flag[j] = TURE; // Pj 申請執行臨界區
turn = i; // 讓 Pi 下次執行
while (flag[i] && turn = i); // 判斷 Pi 是不是在執行臨界區或它也想執行臨界區且當且turn爲它
critical section;
flag[j] = FALSE; // Pj 執行完臨界區,撤銷之前的申請
remainder section;
算法3解決了算法1和算法2的缺點,同時算法3具有先到先入,後到等待的特點。
3 信號量
可以用臨界區解決互斥問題,它們是平等進程間的一種協商機制,之前所提到的輪流和申請都是基於兩個進程的臨界區模型所提出來的,當進程數目過多的時候,顯然要引入新的機制來解決互斥問題,操作系統可從進程管理者的角度來處理互斥的問題,信號量 (Semaphore) 就是操作系統提供的管理公有資源的有效手段。
信號量是在1965年,由荷蘭學者Dijkstra提出(所以P、V分別是荷蘭語的test (proberen)、increment (verhogen)),是一種卓有成效的進程同步機制。用於保證多個進程在執行次序上的協調關係的相應機制稱爲進程同步機制。
信號量是一個整型變量,代表信號量代表可用資源實體的數量。除了初始化之外,僅能通過兩個不可分割的原子操作訪問,即P(S)
和V(S)
,簡稱爲P,V操作。
原子操作:指的是操作系統內最小的操作單位,它的執行時不可中斷的。
由於S
代表當前可用資源的數量,當 S <= 0
時,會一直等待資源,所以存在忙等現象,又稱自旋鎖,此時CPU的利用率是不高的,僞代碼如下:
P(S); // 申請資源
while (S <= 0); // 當前沒有可用資源就要一直等待
S--; // 若有資源,就要總資源數減一
V(S); // 使用完資源要釋放資源
S++; // 釋放資源
爲了解決忙等現象,引入了一種不需要忙等的方案,它將 S--
操作提前了,先減再判斷 S
的值,若判斷的 S < 0
,就讓進程進入阻塞狀態(通常是設置一個阻塞進程隊列),在釋放資源時,若 S >= 0
,則要喚醒阻塞的進程,僞代碼如下:
P(S); // 申請資源
S--; // 總資源數減一
if (S < 0) {
block; // 若當前無可用資源,則將該進程阻塞
}
V(S); // 使用完資源要釋放資源
S++; // 釋放資源
if (S >= 0) {
wakeup; // 若當前有可用資源,則將之前阻塞的進程喚醒
}
S是與臨界區內所使用的公用資源有關的信號量:
- P(S):表示申請一個資源;
- V(S):表示釋放一個資源。
一般來說初始化指定一個非負整數值,表示空閒資源總數,在信號量經典定義下,信號量S的值不可能爲負,後面的定義下可能爲負,因爲後面的定義是先做 S--
:
S≥0
表示可供併發進程使用的資源數;S<0
其絕對值就是正在等待進入臨界區的進程數。
在用信號量解決問題的時候,首先要分清楚這個問題是個同步問題,還是一個互斥問題,若是一個互斥問題,那麼就要找到互斥的臨界資源是什麼,並把臨界資源抽象成信號量,然後給信號量賦初值並給出正確的P,V操作。
4 哲學家問題
問題描述:(由Dijkstra首先提出並解決)5個哲學家圍繞一張圓桌而坐,桌子上放着5支筷子(注意是5支而不是5雙),每兩個哲學家之間放一支;哲學家的動作包括思考和進餐,進餐時需要同時拿起他左邊和右邊的兩支筷子,思考時則同時將兩支筷子放回原處。如何保證哲學家們的動作有序進行?如:不出現相鄰者同時進餐,問題模型如下圖:
先考慮該問題是一個同步問題還是一個互斥問題,顯然它是一個互斥問題,那麼它的臨界資源就是筷子,把臨界資源抽象成信號量爲 Semaphore chopStick[] = new Semaphore[5];
,即一個容量爲5的數組。
哲學家思考和進餐的過程如下面的僞代碼:
Repeat
思考;
取chopStick[i]; // 一根筷子
取chopStick[(i+1) mod 5]; // 取旁邊一根筷子
進餐;
放chopStick[i]; // 放回筷子
放chopStick[(i+1) mod 5]; // 放回筷子
Until false;
用信號量表示的僞代碼如下:
while (true) {
// 取左邊的筷子
chopStick[i].P();
// 取右邊的筷子
chopStick[(i + 1) % 5].P();
// 進餐
// 放回左邊的筷子
chopStick[i].V();
// 放回右邊的筷子
chopStick[(i + 1) % 5].V();
// 思考
}
用信號量實現保證了互斥,但是這種實現下可能會出現死鎖,當五個哲學家每人拿起了他左邊的筷子,則桌子上筷子全部被拿完了,而沒有一個哲學家湊齊了兩支筷子,解決這個死鎖的方法有如下幾種:
- 最多允許四個哲學家同時就坐,此時至少有一個哲學家能夠同時拿起兩支筷子;
- 同時拿起兩根筷子,若某個哲學家要進餐時,要求他同時拿起一雙,若不能同時拿起兩支,就不能進餐;
- 非對稱解決,處於奇數位置的哲學家限制他只能拿左邊的筷子, 處於偶數位置的哲學家限制他只能拿右邊的筷子,這樣無論如何都會有一個哲學家能同時拿起兩支筷子。
5 生產者消費者問題
問題描述:若干進程通過有限的共享緩衝區交換數據。其中,"生產者"進程不斷寫入,而"消費者"進程不斷讀出;共享緩衝區共有N個;任何時刻只能有一個進程可對共享緩衝區進行操作,問題模型如下圖:
由於進程之間是共享了臨界資源,所以他們之間肯定是互斥關係,所以要設置臨界區保證進程間互斥訪問,由於生產者生產商品給消費者使用,他們之間也存在着同步關係。
緩衝區的大小是固定爲N
,當緩衝區滿的時候,生產者是不能再生產商品的,當緩衝區爲空的時候消費者是不能消費商品的,我們可以抽象以下變量:
- full:表示緩衝區滿的數量,也就是可用商品的個數,它的初值爲
0
; - empty:表示緩衝區空的數量,也就是還可以生產多少商品的個數,它的初值爲
N
; - mutex:用於訪問緩衝區的互斥,初值是
1
。
每生產一個商品就要進行 full++
操作,每消費一個商品就要進行 empty++
操作,full
和 empty
滿足關係式 full + empty = N
。對於生產者來說它一開始要生產商品放到緩衝區裏面,而緩衝區是互斥的,生產的時候還要看緩衝區裏面是否還有空位,有空位才能夠生產,所以對應的有兩對P,V操作,一對是關於互斥信號量 mutex
的操作,一對是關於資源信號量 empty
的操作。在實現的時候要注意每個進程中各個P操作的次序是非常重要的。應先檢查資源數目,再檢查是否互斥,否則可能出現死鎖。
對於生產者操作的僞代碼如下;
P(empty); // 申請空位 empty--
P(mutex); // 申請佔用緩衝區
// 生產一個商品放入緩衝區
V(mutex); // 釋放佔用的緩衝區
V(full); // 添加商品 full++
對於消費者操作的僞代碼如下:
P(full); // 申請消費一個商品 full--
P(mutex); // 申請佔用緩衝區
// 消費緩衝區中的一個商品
V(mutex); // 釋放佔用的緩衝區
V(empty); // 增加一個空位 empty++
用信號量表示生產者的僞代碼如下:
public void enter(Object item) {
empty.P(); // 申請緩衝區中的一個空位
mutex.P(); // 申請佔用緩衝區
// 添加一個商品到緩衝區
buffer[in] = item;
in = (in + 1) % BUFFER_SIZE;
mutex.V(); // 釋放佔用緩衝區
full.V(); // 增加一個商品
}
用信號量表示消費者的僞代碼如下:
public Object remove() {
full.P(); // 申請消費一個商品
mutex.P(); // 申請佔用緩衝區
// 從緩衝區消費一個商品
Object item = buffer[out];
out = (out + 1) % BUFFER_SIZE;
mutex.V(); // 釋放佔用的緩衝區
empty.V(); // 增加一個空位
return item;
}
6 讀寫問題
問題描述:對共享資源的讀寫操作,任一時刻“寫者”最多隻允許一個,而“讀者”則允許多個。
讀寫問題存在以下三種關係:
- 讀者和寫者:互斥關係,寫者在寫的時候讀者不可以讀,讀者在讀的時候寫者不可以寫;
- 寫者和寫者:互斥關係,同一時刻只能有一個寫者進行寫操作;
- 讀者和讀者:沒有限制,多個讀者可以同時讀。
那麼可以從兩個方面來考慮這個問題,即有讀者來會怎麼樣和有寫者來會怎麼樣,當有讀者來的時候:
- 如果當前系統中沒有讀者也沒用寫者,那麼新來的讀者可以直接讀,一旦這個讀者開始讀的時候,後面來的讀者都可以讀,但是後來的寫者是不可以寫的;
- 當一個讀者到來的時候,發現有一個寫者正在等待,因爲之前已經到來了讀者並且現在在讀,那麼這個時候來的讀者便可以直接讀;
- 當一個讀者到來的時候,發現有一個寫者正在寫,那麼該讀者就不能讀,並且後面來的讀者都要等待,除非這個寫者寫完。
當寫者到來時:
- 若當前沒有讀者,寫者可以直接寫;
- 若當前有讀者,寫者便要等待讀者讀完;
- 若當前正有寫者在寫,那麼該寫者要等待。
總結來說寫者是更任何人互斥的,讀讀是允許的,並且可以發現只有第一個和最後一個讀者是會影響寫者的,那麼如何知道哪個讀者是第一個來的,哪個讀者是最後一個走的呢?我們的解決方法是設置一個變量來統計讀者的個數,初值可以設爲 0
,來一個讀者就加一,走一個讀者就減一,這裏引入一個共享變量必然會成爲臨界資源,對於這個臨界資源時肯定要對它進行保護的,採用的採用信號量機制如下:
- 信號量
Wmutex
表示"允許寫",初值是1
; - 公共變量
Rcount
表示“正在讀”的進程數,初值是0
; - 信號量
Rmutex
爲了保護臨界資源Rcount
,它表示對Rcount
的互斥操作,初值是1
。
寫者的操作僞代碼如下:
P(Wmutex); // 申請寫信號量
write; // 寫
V(Wmutex); // 釋放寫信號量
讀者的操作相對複雜,其僞代碼如下:
P(Rmutex); // 申請對 Rcount 的使用
if (Rcount == 0) {
// 當前讀者是第一個讀者
// 若允許他讀,則要不允許後來的讀者寫
// 要將寫操作的信號量做 P 操作
P(Wmutex);
}
++Rcount; // 讀者數加一,上下對 Rmutex 的P,V操作實際上是爲了保護 Rcount
V(Rmutex);
…
read; // 讀
…
P(Rmutex);
--Rcount; // 讀完之後讀者數減一,上下對 Rmutex 的P,V操作實際上是爲了保護 Rcount
if (Rcount == 0) {
// 當前讀者是最後一個離開的讀者
// 此時應該釋放寫操作,對寫操作做 V 操作
V(Wmutex);
}
V(Rmutex);
7 P,V操作總結
信號量S
爲一個整型的變量,它描述的是當前可用資源的數目,當 S > 0
時表示有S個資源可用,當 S = 0
時表示無資源可用,當 S < 0
則 表示 S
等待隊列中的進程個數,P(S)
表示申請一個資源,V(S)
表示釋放一個資源,信號量的初值應該大於等於0。
P,V操作必須成對出現,有一個P操作就一定有一個V操作,且有以下規律:
- 當爲互斥操作時:它們處於同一進程;
- 當爲同步操作時:它們不在同一進程中出現。
對於前後相連的兩個P(S1)
和P(S2)
,順序是至關重要的,同步P操作應該放在互斥P操作前。