經典PV問題系列二:經典詳解

上一節討論了計算機解決互斥問題的方法,這一節我們將正式討論各種PV問題。首先給出信號量和PV操作的定義:

struc semaphore
{
	int count;
	queueType queue;
}
// 對信號量可以實施的操作:初始化、P和V(P、V分別是荷蘭語的test(proberen)和increment(verhogen))
P(s)
{
	s.count --;
	if (s.count < 0)
	{
		該進程狀態置爲阻塞狀態;
		將該進程插入相應的等待隊列s.queue末尾;
		重新調度;
	}
}
V(s)
{
	s.count ++;
	if (s.count < = 0)
	{
		喚醒相應等待隊列s.queue中等待的一個進程(通常從隊首取);
		改變其狀態爲就緒態,並將其插入就緒隊列;
	}
}

理解:通常,信號量的取值可以解釋爲:S值的大小表示某類資源的數量。當S>0時,表示還有資源可以分配;當S<0時,其絕對值表示S信號量等待隊列中進程的數目。每執行一次P操作,意味着要求分配一個資源;每執行一次V操作,意味着釋放一個資源。

注意:
1、對信號量的操作只有初始化、P、V三種,內部的count和queue是透明的,不能讀count或操作queue,所以我們經常看到再設置一個int count變量來跟蹤信號量的值內部count值。 
2、我們需要明白的是:每一個信號量都有一個等待隊列。如果進程在P操作中被設置爲阻塞狀態,則該進程被掛在這個P操作的信號量的隊尾。在這裏我們相當於使用了sleep函數的功能和wackup函數的功能,本節我們不去詳細討論這兩個函數的實現,因爲這一節中我們主要關注如何用PV解決互斥同步問題,而不是操作系統如何設置等待隊列。 
3、P操作和V操作均爲原語,即在執行過程中不允許被中斷。原語可以通過屏蔽中斷、測試與設置指令等來實現。
4、存在其他合理定義P、V的方法。以下解題均使用上述P、V的定義。

1、普通進程間互斥

令S初值爲1

進程A
P(S);
臨界區操作;
V(S);
進程B
P(S);
臨界區操作;
V(S);

2、簡單進程間同步

考慮AB兩個進程,B進程必須在A進程之後進行,這就是簡單的進程間同步。我們考慮B是消費者,A是生產者,緩衝區只有一個,其實這樣就等價爲了緩衝區只有一個的生產者消費者問題。這也是經典的讓兩個函數輪轉的同步方法,在很多問題中都有變形應用。
設置兩個信號量S1和S2,初值均爲0。S1表示緩衝區是否裝滿信息,S2表示緩衝區中信息是否取走。

進程A
把消息送入緩衝區
V(S1);
P(S2);
// 可以將上句放入在開頭,S2初值爲1
進程B
P(S1);
把信息從緩衝區取走;
V(S2);

再考慮三個進程:A從輸入設備上不斷讀數據,並放入緩衝區B1;B從緩衝區B1的內容複製到B2;C從緩衝區B2的內容放入打印機打印。
設置四個信號量S1:1 S2:0 S3:0 S4:1。

進程A
P(S1);
從輸入設備讀入到B1
V(S2);


進程B
P(S2);
P(S4);
從B1複製到B2
V(S1);
V(S3);
進程C
P(S3);
從B2複製到打印機
V(S4);



3、生產者-消費者問題(有界緩衝區問題)

問題描述:多個生產者生產某種類型的產品放置在緩衝區中,每次送一個產品,如果緩衝區滿則等待;多個消費者從緩衝區中取數據,每次取一項,如果緩衝區空則等待。同一時間只能有一個生產者或消費者對緩衝區進行操作。

首先應該明白,解決這個問題的方法決不止以下這一種,但以下介紹的方式是最直接的。其他間接方式比如:利用P、V模擬管程,然後用管程解決生產者-消費者問題,其本質也是用PV解決問題。以下所有問題均同理。

解決同步問題:生產者不能往“滿”的緩衝區中放產品,設置信號量empty,初值爲n,用於指示空緩衝區的數目。消費者進程不能從“空”緩衝區中取產品,設置信號量full,初值爲0,用於指示滿緩衝區的數目。
解決互斥問題:設置信號量mutex,初值爲1,用於實現緩衝區和緩衝區內產品數量的互斥(同一時間只能有一個生產者或消費者對緩衝區進行操作)

生產者進程
生產產品;
P(empty);
P(mutex);
往緩衝區放入產品;
V(mutex);
V(full);
消費者進程
P(full);
P(mutex);
從緩衝區取產品
V(mutex);
V(empty);
消費產品;

假設將生產者代碼中兩個P操作交換一下順序,使得mutex的值在empty之前而不是之後被P。如果緩衝區完全滿了,生產者將阻塞,mutex值爲0,。這樣一來,當消費者下次試圖訪問緩衝區時,它先對full執行P操作,成功,接着它又對mutex執行P操作,由於mutex值爲0,則消費者也被掛在mutex的阻塞隊列上。所有生產者和消費者進程都將阻塞在此。這種情況就是死鎖(dead lock)。

我們可以看到,當消費者取產品時,生產者不能放產品;生產者放產品時,消費者也不能取產品。 mutex 鎖是針對整個緩衝區而言的。這樣做也許會比較低。我們考慮以下做法。

semaphore empyt = N, full = 0, mutexP = 1, mutexC = 1;
void Producer()
{
	生產產品;
	P(empty);
	P(mutexP);
	往緩衝區buffer[pi]放入產品;
	pi = (pi+1) % N;
	V(mutexP);
	V(full);
}
void Customer()
{
	P(full);
	P(mutexC);
	從緩衝區buffer[ci]取產品;
	ci = (ci+1) % N;
	V(mutexC);
	V(empty);
}

我們分別用互斥量 mutexP 和 mutexC 來保護 pi 和 ci 指針,如果 pi != ci ,則程序顯然可以正常工作。若 pi == ci ,則要麼緩衝區爲空,此時 full = 0,消費者會被阻塞在 full 上;要麼緩衝區爲滿,此時 empty = 0,生產者會被阻塞在empty上。代碼可以正常工作,而且增大了並行性。

4、信號量和P、V操作小節

到目前爲止,我們接觸的依然是比較簡單的同步互斥問題。信號量和PV操作的表達能力極強,理論上可以解決任何進程的同步互斥問題,但通常PV操作使用時不夠安全,容易出現死鎖,面對複雜問題時用PV操作實現也很複雜。我們在此總結一下信號量和PV操作的使用方法,以便應對更復雜的問題。這些結論是所有情況通用的。

P、V操作在使用時必須成對出現,有一個P操作就一定有一個V操作。當爲互斥操作時,他們同處於同一進程;當爲同步操作時,則不在同一進程中出現。

如果進程中P(S1)和P(S2)兩個操作在一起,那麼P操作的順序至關重要,尤其是一個同步P操作與一個互斥P操作在一起時,同步P操作應出現在互斥P操作前。而兩個相鄰V操作的順序則無關緊要。

5、第一類讀者-寫者問題

讀者-寫者問題描述:多個進程共享一個數據區,這些進程分爲兩組:讀者進程:只讀數據區中的數據。寫者進程:只往數據區寫數據。要求滿足條件:允許多個讀者同時執行讀操作;不允許多個寫者同時執行寫操作;不允許讀者、寫者同時操作。

以上被稱爲讀者-寫者問題定義。如果是第一類讀者-寫者問題,則是在此基礎需要滿足讀者優先。讀者優先的思想是除非有寫者正在寫文件,否則沒有一個讀者需要等待。另一個第二類讀者-寫者問題,即寫者優先,其思想是一旦一個寫者到來,它應該儘快對文件完成寫操作。換句話說,如果有一個寫者在等待,則新到來的讀者不允許進行讀操作。顯然這兩類讀者-寫者問題都會導致“飢餓”現象,或者是寫者飢餓,或者是讀者飢餓。

我們先來詳細分析第一類讀者-寫者問題。(詳細分析各種情況是非常有用的,就好比弄清產品經理的項目需求是多麼重要一樣)
如果讀者到:無讀者、寫者,新讀者可以讀;有寫者等,但有其它讀者正在讀,則新讀者也可以讀;有寫者寫,新讀者等。
如果寫者到:無讀者,新寫者可以寫;有讀者,新寫者等待;有其它寫者,新寫者等待。

解決方案:設rc記錄當前正在讀的讀者進程個數,由於多個讀者會對rc進行修改,所以rc是一個共享變量,需要互斥使用,故設置信號量mutex,初始爲1。在設置信號量write,用於寫者之間互斥,或第一個讀者和最後一個讀者與寫者的互斥,初始爲1。

void reader(void)
{
	P(mutex);
	rc = rc + 1;
	if (rc == 1) P (write);
	V(mutex);
	讀操作;
	P(mutex);
	rc = rc - 1;
	if (rc == 0) V(write);
	V(mutex);
}
void writer(void)
{
	P(write);
	寫操作;
	V(write);
}

假設讀者先到來,則第一個讀者拿了write鎖,之後的所有讀者均可暢通無阻的讀,而所有寫者被掛在write上,直到所有讀者均讀完釋放write鎖。當一個寫者已經進入臨界區執行寫操作時,若有n個讀者在等待,則第一個讀者等在信號量write上,其餘讀者(n-1個)等在信號量mutex上排隊。當一個寫者執行V(write)後,可能釋放一個寫者,也可能釋放若干讀者,取決於誰等在前面。

6、第二類讀者-寫者問題

這將是一個相當有難度的PV問題,裏面的一些細節和trick需要仔細品味。

同理,我們也先來詳細分析一下問題需求:
如果讀者到:無讀者、寫者,新讀者可以讀;有寫者等,讀者必須等待所有等待的寫者完成寫操作後才能讀;有寫者正在寫,新讀者等。
如果寫者到:無讀者,新寫者可以寫;有讀者正在讀,新寫者等待;有其它寫者正在寫,新寫者等待;有其他讀者等待,寫者優先。

int readcount, writecount; //(initial value = 0)
semaphore mutex_rdcnt, mutex_wrcnt, mutex_3, w, r; //(initial value = 1)
 
//READER
  P(mutex_3);
  P(r);
  P(mutex_rdcnt);
  readcount++;
  if (readcount == 1)
      P(w);
  V(mutex_rdcnt);
  V(r);
  V(mutex_3);
 
 // reading is performed
 
  P(mutex_rdcnt);
  readcount--;
  if (readcount = 0) 
      P(w);
  V(mutex_rdcnt);
 
//WRITER
  P(mutex_wrcnt);
  writecount++;
  if (writecount = 1) 
      P(r);
  V(mutex_wrcnt);
 
  P(w);
   // writing is performed
  V(w);
 
  P(mutex_wrcnt);
  writecount--;
  if (writecount = 0) 
      V(r);
  V(mutex_wrcnt);

對於讀者, mutex_rdcnt 和 w 的功能分別對應第一類讀者寫者問題中的 mutex 和 w 信號量。w 對應於規則“一旦有一個讀者正在讀,則第一個讀者拿掉 w 鎖,以禁止寫者寫”,同理 r 對應於規則“一旦有一個寫者正在寫,則第一個寫者拿掉 r 鎖,以禁止讀者讀”。而 mutex_rdcnt 和 mutex_wrcnt 分別用於保護 readcount 和 writecount 變量。另外,WRITER 區別於 READER 的一點是:在寫操作前 WRITER 要拿 w 鎖,而 READER 不需要。這條規則對應於“讀操作可以同時進行,而寫操作之間必須互斥”。不過,無論 READER 還是 WRITER ,在“讀操作區”和“寫操作區”的之前和之後都有那五行相似的代碼,用於實現讀操作和寫操作之間的互斥。

那爲什麼第一類讀者-寫者問題中 WRITER 不需要前後那五行的代碼,只需要拿掉 w 鎖呢? WRITER 僅拿掉 w 鎖固然能實現讀者和寫者的互斥,但是只要有一個讀者先拿了 w 鎖,寫者就算比讀者先到,寫者也會被阻塞在 w 上,而後來的讀者暢通無阻,直到沒有讀者到來時寫者才能拿到 w 鎖,這就是所謂的“讀者優先”。爲了實現“寫者優先”,第一個寫者必須主動拿掉讀者的 r 鎖,讓後來的讀者被阻塞,之後無論讀者先來還是寫者先來,都優先讓寫者依次執行。在代碼中體現爲:對 READER 先檢查r鎖,如果有一個 WRITER 拿了就一直阻塞;對 WRITER 先拿下 r 鎖再說,之後用 w 鎖保證寫者的依次執行。注意無論讀者羣還是寫者羣都是先 P(r) 再 P(w) ,保證了沒有死鎖。

在此我們一直都沒有討論 mutex_3 ,這個信號量是必要的因爲我們絕對地堅持寫者優先策略。mutex_3 保證了 READER 取放 r 鎖的原語性。如果 READER 在讀操作或 WRITER 在寫操作的過程中到來讀寫序列,不用 mutex_3 依然可以滿足第二類讀寫問題的需求。但是如果 READER 在取完 r 鎖未放回的過程中到來讀寫序列,則大量讀寫序列阻塞在 r 鎖上,先阻塞的讀者會使後來的寫者無法先獲得 r 鎖,這樣就無法滿足“寫者優先”的性質了。加入 mutex_3 鎖使得 r 鎖要麼被讀者拿走,而上面掛的是一個寫者,其它讀者被掛在外面的 mutex_3 鎖上;要麼被寫者拿走,最多隻有一個讀者掛在上面,而下次釋放 r 鎖的時候就是 writecount 爲0的時候了。

注意我們爲什麼反對以上情形(如果 READER 在取完 r 鎖未放回過程中到來讀寫序列)中根據隊列順序獲得 r 鎖。在以上情形中,假設到來的讀寫序列是:-寫-讀-讀-寫-讀-寫-讀。如果是正確的第二類讀者寫者問題,則解決方式是:寫-寫-寫-(讀-讀-讀-讀),括號表示可並行。如果不加 mutex_3 鎖,則在上述情形中會出現-寫-(讀-讀)-寫-(讀)-寫-(讀)的情況。這樣嚴重影響了並行性。無論是第一類還是第二類讀寫者問題,雖然都有飢餓現象,但是根據貪心策略,總體並行效率都是最高的。寫者對讀者羣的分割使得總體的效率降低,這違背了我們問題需求的初衷。

7、第三類讀者-寫者問題

緊接上題,如果我們忽略掉最大的效率,一定要保證沒有一個讀寫者飢餓呢?這就是第三類讀者-寫者問題。當然這類問題實際應用沒有第二類廣:如果一個對一個條目進行了修改,那麼一般期待讀到新修改後的條目,而不是舊的;況且第二類讀者寫者解法可以保證最大的並行度。

semaphore mutex1 = 1;
semaphore w = r = 1;
int rc= 0;
Reader:
while (TRUE) {
	P(r);
	P(mutex1);
	rc= rc+ 1;
	if (rc== 1) P (w);
	V(mutex1);
	V(r);
	讀操作
	P(mutex1);
	rc= rc-1;
	if (rc== 0) V(w);
	V(mutex1);
}
Writer:
while (TRUE) {
	P(r);
	P(w);
	V(r);
	寫操作
	V(w);
}

對於上述Writer,也可以將 V(r) 放置於 V(w) 之後。但相當於擴大了臨界區。

8、睡眠理髮師問題

問題描述:理髮店裏有一位理髮師,一把理髮椅和N把供等候理髮的顧客坐的椅子。如果沒有顧客,則理髮師便在理髮椅上睡覺。當一個顧客到來時,他必須先喚醒理髮師;如果顧客到來時理髮師正在理髮,則如果有空椅子,可坐下來等;否則離開。試用P、V操作解決睡眠理髮師問題。

Semaphore barberReady = 0
Semaphore mutex = 1     // 用於對customers數量的互斥
int count = 0     // 記錄等待客戶的數量
Semaphore custReady = 0         // 記錄等待客戶的信號量
 
void Barber()
{
	while (true)
	{
		P(custReady);	// 嘗試獲得一個顧客,否則等待
		P(mutex);
		count--;
		V(mutex);
		V(barberReady);	// 理髮師準備好了
		// 剪髮
	}
}
 
void Customer()
{
	P(mutex);
	if (count != N)	// If there are any free seats
	{
		count++;
		V(mutex);
		V(custReady);	// 將準備好的客戶數目加一,可能喚醒理髮師
		P(barberReady)         // 等待直到理髮師準備好
		// 剪髮
	}
	else
	{
		V(mutex);
		// 離開
	}
}

有沒有覺得有點像生產者-消費者問題:理髮師生產出一個barberReady被Customer拿走,Customers生產出一個custReady被理髮師拿走。

這個問題的答案並不算很複雜,在這裏不再多討論。我們可以繼續考慮睡眠理髮師問題的拓展:即理髮店裏不僅有一個理髮師,而是有多個理髮師,都可以分別有睡眠或給某位客戶理髮。在wiki裏,只是簡單地提了這樣一句話:"A multiple sleeping barbers problem has the additional complexity of coordinating several barbers among the waiting customers."。

這句話可以這樣理解:如果只有一個理髮師,那麼我們說“客戶在理髮”一定是在被這一個理髮師理髮;如果有多個理髮師,我們必須解釋清楚“客戶在被哪一個理髮師理髮”。在生產者-消費者問題中其實有同樣的問題:緩衝區數目爲N,消費者取哪個產品呢?生產者放到哪個空槽裏呢?我們不討論上述這些細節的原因是:這些問題不涉及同步和互斥。對於生產者和消費者問題,可以採取以下方式:設置一個大小爲N的環形緩衝區,生產者和消費者各自擁有一個同向移動的指針分別用於取放數據,那麼只要我們保證了對緩衝區操作的互斥,就保證了這些細節的互斥。

那麼對於睡眠理髮師問題,我們同樣可以設置一個大小爲N的環形緩衝區,客戶和理髮師們各有一個指針,每次客戶對count加減之後,都將自己信息放入緩衝區並移動指針;每次理髮師在對count操作後也可以從自己指針相應位置取信息。而mutex已保證了這些操作的互斥性。只需加上緩衝區機制和多開幾個理髮師進程,就是睡眠理髮師問題拓展的答案了。

9、哲學家就餐問題

wiki上已有足夠詳細的說明。服務生解法可認爲採取的策略是:僅當一哲學家左右兩邊的筷子都可用時,才允許他抓起筷子。書中提到另一種解決方法是:讓所有哲學家順序編號。對於奇數號的哲學家必須先抓起左邊的筷子,然後抓起右邊的;而對偶數號哲學家則反之。

10、吸菸者問題

問題描述:三個吸菸者在一間房間內,還有一個香菸供應者。吸菸者負責製造並抽掉香菸,他們需要三樣東西:菸草、紙和火柴。供應者有豐富的貨物提供。三個吸菸者中,第一個有無限的自己的菸草,第二個有無限的自己的紙,第三個有無限的自己的火柴。供應者將兩樣東西放在桌子上,允許一個吸菸者進行對健康不利的吸菸。當吸菸者完成吸菸後喚醒供應者,供應者再放兩樣東西(隨機地)在桌面上,然後喚醒另一個吸菸者。吸菸者不會囤積不屬於自己的貨物。

我們可以把供應者看作操作系統,它擁有資源;把吸菸者看作應用程序。對於每個資源,它什麼時候可利用通常是不確定的,我們可以認爲相當於隨機產生資源。資源就緒後操作系統會進行資源的發放,我們期待可以利用資源的應用進程會被喚醒。

這個問題有三個版本:1、不可能解決的版本。Patil對這個問題添加了兩個限制:不允許修改操作系統的代碼、不允許使用額外的條件狀態和信號量數組。第二個限制太嚴格,以至於後來被證明不可能存在一種解法。

2、無聊的版本。如果去掉了這兩個限制,問題將變得非常簡單。而在很多中文的資料中所討論的恰是這個版本。

Semaphore A[3] = 0, T = 1;	// T表示table,A數組表示三個吸菸者
void Arbiter()
{
	while(TRUE)
	{
		P(T);
		// 放在桌上兩種原料
		V(A[k])	// 喚醒持有另一種原料的吸菸者k
	}
}
void Smoker()
{
	while(TRUE)
	{
		P(A[k]);
		// 從桌上製作香菸
		V(T);
		// 吸菸
	}
}

3、有趣的版本。我們只對問題添加第一個限制:不允許修改操作系統的代碼。這個限制是合理且實用的,因爲你不能因爲多一個特定應用程序就修改操作系統。這個問題之所以比較難,是因爲以下代碼是不工作的。

semaphore T = 1, bobacco = 0, paper = 0, match = 0;
void Arbiter()
{
	while(TRUE)
	{
		P(T);
		隨機i,j;
		V(tobacco);
		V(paper);
	}
}
void SmokerA()
{
	while(TRUE)
	{
		P(tobacco);	// 拿自己所需的部分
		P(paper);
		造煙;
		V(T);
	}
}

上述解法可能產生死鎖。

爲了不休改Arbiter,解決方法是再添加三個進程,我們稱之爲Pusher。以及再添加三個信號量和一個mutex,代碼如下:

semaphore tobaccoSem = 0, paperSem = 0, matchSem = 0, mutex = 1;
bool isTobacco = isPaper = isMatch = false;
void PusherA() {
	P(tobacco);
	P(mutex);
	if (isPaper) {
		isPaper = 0;
		P(matchSem);
	} else if (isMatch) {
		isMatch = 0;
		P(paperSem);
	} else
		isTobacco = 1;
	V(mutex);
}
void Smoker_with_tobacco() {
	P(tobaccoSem);
	做香菸;
	V(T);
	吸菸;
}

我們使用了三個Pusher進程事先接受操作系統發出的操作量喚醒信號,每次操作系統會喚醒兩個Pusher進程,而這些Pusher進程間用mutex實現互斥。後被喚醒的進程負責再喚醒Smoker進程。這樣,這個問題纔算被解決。

至此,所有經典問題已經詳解完成。所有問題的基礎是信號量在同步和互斥上的應用模式,比較複雜的問題有生產者-消費者問題和讀者-寫者問題。下一節我們總結各類習題。

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