哲學家就餐問題
1 描述
哲學家就餐問題是在計算機科學中的一個經典問題,用來演示在並行計算中多線程同步(Synchronization)時產生的問題。在1971年,著名的計算機科學家艾茲格.迪科斯徹提出了一個同步問題,即假設有五臺計算機都試圖訪問五份共享的磁帶驅動器。稍後,這個問題被託尼?霍爾重新表述爲哲學家就餐問題。這個問題可以用來解釋死鎖和資源耗盡。
哲學家就餐問題可以這樣表述,假設有五位哲學家圍坐在一張圓形餐桌旁,做以下兩件事情之一:喫飯,或者思考。喫東西的時候,他們就停止思考,思考的時候也停止喫東西。餐桌中間有一大碗意大利麪,每兩個哲學家之間有一隻餐叉。因爲用一隻餐叉很難喫到意大利麪,所以假設哲學家必須用兩隻餐叉喫東西。他們只能使用自己左右手邊的那兩隻餐叉。哲學家就餐問題有時也用米飯和筷子而不是意大利麪和餐叉來描述,因爲很明顯,喫米飯必須用兩根筷子。
哲學家從來不交談,這就很危險,可能產生死鎖,每個哲學家都拿着左手的餐叉,永遠都在等右邊的餐叉(或者相反)。即使沒有死鎖,也有可能發生資源耗盡。例如,假設規定當哲學家等待另一隻餐叉超過五分鐘後就放下自己手裏的那一隻餐叉,並且再等五分鐘後進行下一次嘗試。這個策略消除了死鎖(系統總會進入到下一個狀態),但仍然有可能發生“活鎖”。如果五位哲學家在完全相同的時刻進入餐廳,並同時拿起左邊的餐叉,那麼這些哲學家就會等待五分鐘,同時放下手中的餐叉,再等五分鐘,又同時拿起這些餐叉。
在實際的計算機問題中,缺乏餐叉可以類比爲缺乏共享資源。一種常用的計算機技術是資源加鎖,用來保證在某個時刻,資源只能被一個程序或一段代碼訪問。當一個程序想要使用的資源已經被另一個程序鎖定,它就等待資源解鎖。當多個程序涉及到加鎖的資源時,在某些情況下就有可能發生死鎖。例如,某個程序需要訪問兩個文件,當兩個這樣的程序各鎖了一個文件,那它們都在等待對方解鎖另一個文件,而這永遠不會發生。
附:進程互斥與同步,死鎖基本知識
在多道程序環境下,進程有異步和同步兩種併發執行方式。異步執行是指運行中的各進程在操作系統的調度下以不可預知的速度向前推進。 異步執行的進程大多沒有時序要求,不存在“執行結果與語句的特定執行順序有關”的條件競爭。然而存在一類協作進程,“保證數據的一致性” 的前提要求它們必須按某種特定順序執行,並且遵守如下兩種限制。
- R1(順序化執行):進程A 的eventA事件必鬚髮生在進程B的eventB事件之前;
- R2(互斥執行):進程 A的eventA事件與進程B的eventB事件不能同時發生。把上述限制下多進程的運行狀態叫作進程的同步執行。進程同步執行時因存在着明顯的執行上的時序要求而相互等待。如果說進程異步是進程併發執行的自然結果,那麼進程同步則需要程序員通過準確嵌入一些諸如加解鎖來確保實現。
信號量無疑是一個較爲理想的同步工具。它最早由荷蘭科學家Dijkstra於1965年提出,該工具具有如下三個優點:
3. 僅需要兩個基本操作即可完成進程的同步和互斥,而且兩個原子操作代碼簡潔高效, 易於擴充;
4. 精心設計的信號量對象類似一條條“觸發器”規則,加上信號量機制的強制作用可以幫助程序員少犯錯誤;
5. 信號量已在很多系統中實現,解決方案中有意識地選用信號量無疑將使進程更“瘦身”,運行更高效。
信號量技術的引入是對早期忙等型(busywaiting)進程控制變量是個巨大的提升,但在使用過程中仍然存在不少缺點:
- 一是不能隨時讀取信號量的值, 必要時須重複定義一個跟蹤信號量值的普通變量,
- 二是程序員對信號量的PV操作的正確使用與否沒有任何控制和保證(後來引入管程和條件變量,PV操作完全由編譯器而非 程序員安排),不合理地使用將導致進程飢餓甚至死鎖。
死鎖應儘可能阻止,系統死鎖導致諸進程將進入無法向前推進的僵持狀態, 除非藉助於外力。死鎖的原因除了系統資源偏少之外,更多的是進程推進速度不當, 或者說進程申請和釋放信號量的順序不合理所致,畢竟系統提供的資源是有限的。以哲學家就餐問題爲例,若派發給每位哲學家一雙筷子(更準確地說,6支就足夠), 則一定不會死鎖。事實上,若信號量的PV操作順序處置得當,5支筷子同樣也可以保證不會發生死鎖。
2 分析
-
關係分析。 5名哲學家與左右鄰居對其中間筷子的訪問是互斥關係。
-
整理思路。 顯然這裏有五個進程。本題的關鍵是如何讓一個哲學家拿到左右兩個筷子而不造成死鎖或者飢餓現象。那麼解決方法有兩個 ,一個是讓他們同時拿兩個筷子;二是對每個哲學家的動作制定規則,避免飢餓或者死鎖現象的發生。
-
信號量設置。 定義互斥信號量數組
Ch0PstiCk[5] = {l, 1, 1, 1, 1}
用於對5個筷子的互斥訪問。對哲學家按順序從0~4
編號,哲學家i左邊的筷子的編號爲i,哲學家右邊的筷子的編號爲(i+l)%5
。
semaphore chopstick[5] = {1,1,1,1,1}; //定義信號量數組chopstick[5],並初始化
Pi(){ //i號哲學家的進程
while(1){
P(chopstick[i]); //取左邊筷子
P(chopstick[(i+1)%5]); //取右邊篌子
eat;
V(chopstick[i]); //放回左邊筷子
V(chopstick[(i+1)%5]); //放回右邊筷子
think;
}
}
該算法存在以下問題:當五個哲學家都想要進餐,分別拿起他們左邊筷子的時候(都恰好執行完wait(chopstick[i]);)
筷子已經被拿光了,等到他們再想拿右邊的筷子的時候(執行 wait(chopstick[(i+l)%5]
);)就全被阻塞了,這就出現了死鎖。
爲了防止死鎖的發生,可以對哲學家進程施加一些限制條件,比如:
- 至多允許四個哲學家同時進餐;
- 僅當一個哲學家左右兩邊的筷子都可用時才允許他抓起筷子;
- 對哲學家順序編號,要求奇數號哲學家先抓左邊的筷子,然後再轉他右邊的筷子,而偶數號哲學家剛好相反。
3 解法
3.1 解法一
假設當一個哲學家左右兩邊的筷子都可用時,才允許他抓起筷子。
semaphore chopstick[5] = {1,1,1,1,1}; //初始化信號量
semaphore mutex=l; //設置取筷子的信號量
Pi(){ //i號哲學家的進程
while(1){
P(mutex); //在取筷子前獲得互斥量,一次只能由一個哲學家取筷子
P(chopstick[i]) ; //取左邊筷子
P(chopstick[(i+1)%5]); //取右邊筷子
V(mutex); //釋放取筷子的信號量
eat;
V(chopstick[i]); //放回左邊筷子
V(chopstick[(i+1)%5]); //放回右邊筷子
think;
}
}
3.2 解法二
當5個哲學家進程併發執行時,某個時刻恰好每個哲學家進程都執行申請筷子,並且成功申請到第i支筷子(相當於5個哲學家同時拿起他左邊的筷子), 接着他們又都執行申請右邊筷子, 申請第i+1支筷子。此時每個哲學家僅拿到一支筷子, 另外一支只得無限等待下去, 引起死鎖。在給出幾種有效阻止死鎖的方案之前,首先給出兩個斷言:
(1)系統中有N個併發進程。 若規定每個進程需要申請2個某類資源, 則當系統提供N+1個同類資源時,無論採用何種方式申請資源, 一定不會發生死鎖。分析:N+1個資源被N 個進程競爭, 由抽屜原理可知, 則至少存在一個進程獲2個以上的同類資源。這就是前面提到的哲學家就餐問題中5個哲學家提供6支筷子時一定不會發生死鎖的原因。
(2)系統中有N個併發進程。 若規定每個進程需要申請R個某類資源, 則當系統提供K=N*(R-1)+1個同類資源時,無論採用何種方式申請使用,一定不會發生死鎖。分析:在最壞的情況下,每個進程都申請到R-1個同類資源, 此時它們均阻塞。 試想若系統再追加一個同類資源, 則 N 個進程中必有一個進程獲得R個資源,死鎖解除。
結合以上分析,哲學家就餐問題可以被抽象描述爲:系統中有5個併發進程, 規定每個進程需要申請2個某類資源。 若系統提供5個該類資源, 在保證一定不會產生死鎖的前提下,最多允許多少個進程併發執行?假設允許N個進程, 將R=2,K=5帶入上述公式, 有N*(2-1)+1=5所以 N=4。也就意味着,如果在任何時刻系統最多允許4個進程併發執行, 則一定不會發生死鎖。 大多數哲學家就餐問題死鎖阻止算法都是基於這個結論。
增加一個信號量,控制最多有4個進程併發執行,算法如下:
semaphore chopstick[5] = {1,1,1,1,1}; //初始化信號量
semaphore eating = 4; //至多隻允許四個哲學家可以同時進餐
Pi(){ //i號哲學家的進程
while(1){
think;
P(eating); //請求進餐,若是第五個則捱餓
P(chopstick[i]); //取左邊筷子
P(chopstick[(i+1)%5]) ; //取右邊筷子
eat;
V(chopstick[(i+1)%5]) ; //放回右邊筷子
V(chopstick[i]) ; //放回左邊筷子
V(eating); //釋放信號量給其他捱餓的哲學家
}
}
3.3 解法三
規定奇數號哲學家先拿他左邊的筷子,然後在去拿右邊的筷子;而偶數號哲學家則相反。按此規定,將是1、2號哲學家競爭1號筷子;3、4號哲學家競爭3號筷子。
即5位哲學家都先競爭奇數號筷子,獲得後,再去競爭偶數號筷子,最後總會有一位哲學家能夠獲得兩隻筷子而進餐。
semaphore chopstick[5] = {1,1,1,1,1}; //初始化信號量
Pi(){ //i號哲學家的進程
while(1){
think;
if(i%2==0){
P(chopstick[(i+1)%5]) ; //取右邊筷子
P(chopstick[i]); //取左邊筷子
eat;
V(chopstick[(i+1)%5]) ; //放回右邊筷子
V(chopstick[i]) ; //放回左邊筷子
}else{ //奇數哲學家,先左後右
P(chopstick[i]); //取左邊筷子
P(chopstick[(i+1)%5]) ; //取右邊筷子
V(mutex); //釋放互斥量
eat;
V(chopstick[i]) ; //放回左邊筷子
V(chopstick[(i+1)%5]) ; //放回右邊筷子
}
}
}
3.4 解法四
採用AND型信號量機制來解決,即要求每個哲學家先獲得兩個臨界資源(筷子)後方能進餐。
semaphore chopstick[5] = {1,1,1,1,1}; //初始化信號量
semaphore mutex = 1; //設置取筷子的信號量
Pi(){
while(1){
think;
P(chopstick[i],chopstick[(i+1)%5]);
eat;
V(chopstick[i],chopstick[(i+1)%5]);
}
}
3.5方案五:
下面的解法不僅正確,而且能獲得最大的並行度。其中使用一個數組state來跟蹤一個哲學家是在喫飯、思考還是正在試圖拿叉子:一個哲學家只有在兩個鄰居都不在進餐時才允許進入到進餐狀態。第i位哲學家的鄰居由宏LEFT和RIGHT定義。
哲學家進餐問題的解決方案使用了一個信號量數組,每個信號量分別對應一個哲學家,這樣,當所需的叉子被佔用時,想進餐的哲學家可以阻塞。注意,每個進程將過程philosopher作爲主代碼運行。
#define N 5 /* 哲學家數目 */
#define LEFT (i+N-1)%N /* i的左鄰號碼 */
#define RIGHT (i+1)%N /* i的右鄰號碼 */
#define THINKING 0 /* 哲學家正在思考 */
#define HUNGRY 1 /* 哲學家想取得叉子 */
#define EATING 2 /* 哲學家正在吃麪 */
typedef int semaphore;
int state[N]; /* 記錄每個人狀態的數組 */
semaphore mutex=1; /* 臨界區互斥 */
semaphore s[N]; /* 每個哲學家一個信號量 */
void philosopher(int i) /* i:哲學家號碼,從0到N-1 */
{
while(TRUE)
{
think(); /* 哲學家正在思考 */
take_forks(i); /* 需要兩把叉子,或者阻塞 */
eat(); /* 進餐 */
put_forks(i); /* 把兩把叉子同時放回桌子 */
}
}
void take_forks(int i) /* i:哲學家號碼,從0到N-1 */
{
down(&mutex); /* 進人臨界區 */
state[i]=HUNGRY; /* 記錄下哲學家i飢餓的事實 */
test(i); /* 試圖得到兩把叉子 */
down(&s[i]); /* 如果得不到叉子就阻塞 */
}
void put_forks(i) /* i:哲學家號碼,從0到N-1 */
{
down(&mutex); /* 進人臨界區 */
state[i]=THINKING; /* 哲學家進餐結束 */
test(LEFT); /* 看一下左鄰居現在是否能進餐 */
test(RIGHT); /* 看一下右鄰居現在是否能進餐 */
up(&mutex); /* 離開臨界區 */
}
void test(i) /* i:哲學家號碼,從0到N-1 */
{
if(state[i]==HUNGRY && state[LEFT]!=EATING&&state[RIGHT]!=EATING){
state[i]=EATING;
up(&s[i]);
}
}
4 拓展
5個哲學家問題本質上是解決併發程序中的死鎖和飢餓,可以將推廣爲更一般的n個進程,m個共享資源的問題。
教材原文: