面試必問:訊號量與生產者消費者問題!

喝水會長肉發表於2021-11-30

生產者—消費者問題

生產者—消費者題型在各類考試(考研、程式設計師證照、程式設計師面試筆試、期末考試)很常見,原因之一是生產者—消費者題型在實際的併發程式(多程式、多執行緒)設計中很常見;之二是這種題型綜合性較好,涉及程式合作、互斥,有時還涉及死鎖的避免。生產者—消費者題型可以全面考核你對併發程式的理解和設計能力。

生產者—消費者題型最基本的是 有界緩衝區的生產者消費者問題和無界緩衝區的生產者消費者問題,對這兩個問題的解我們應該掌握其解決方案。

對於有界緩衝區的生產者—消費者問題,兩個程式共享一個公共的固定大小的緩衝區。其中一個是生產者,將資訊放入緩衝區;另一個是消費者,從緩衝區中取出資訊(也可以把這個問題一般化為m個生產者和n個消費者問題,但是我們只討論一個生產者和一個消費者的情況,這樣可以簡化解決方案)。

問題在於當緩衝區已滿,而此時生產者還想向其中放入一個新的資料項的情況,其解決辦法是讓生產者睡眠,待消費者從緩衝區中取出一個或多個資料項時再喚醒它。同樣地,當消費者試圖從緩衝區取資料而發現緩衝區為空時,消費者就睡眠,直到生產者向其中放入一些資料時再將其喚醒。

這個方法聽起來很簡單,為了跟蹤緩衝區中的資料項數,我們需要一個變數count。如果緩衝區最多存放N個資料項,則生產者程式碼將首先檢查count是否達到N,若是,則生產者睡眠,否則生產者向緩衝區最多存放N個資料項,則生產者程式碼將首先檢查count是否達到N,若是,則生產者睡眠;否則生產者向緩衝區放入一個資料項並增量count的值。

消費者的程式碼與此類似:首先測試count是否為0,若是,則睡眠;否則從中取出一個資料項並遞減count的值。每個程式同時也檢測另一個程式是否應該被喚醒,若是則喚醒之。生產者消費者的程式碼如下:

#define 
N 
100

int count = 0 ;
void producer ( void )
{
int item ;
while ( TRUE )
{
 item = produce_item ( ) ;
  if (count == N )     //如果緩衝區滿就休眠
  sleep ( ) ;
  insert_item (item ) ;
 count = count + 1 ;     //緩衝區資料項計數加1
  if (count == 1 )
  wakeup (consumer ) ;
}
}

void consumer ( void )
{
int item ;
while ( TRUE )
{
  if (count == 0 )     //如果緩衝區空就休眠
  sleep ( ) ;
 item = remove_item ( ) ;
 count = count - 1 ;   //緩衝區資料項計數減1
  if (count == N - 1 )
  wakeup (producer ) ;
  consume_item (item ) ;
}
}


這裡有可能出現競爭條件,其原因是對count的訪問未作限制。有可能出現以下情況:緩衝區為空,消費者剛剛讀取count的值發現它為0,此時排程程式決定暫停消費者並啟動執行生產者(程式切換)。生產者向緩衝區加入一個資料項,count加1。現在count的值變成了1,它推斷認為count剛才為0,所以消費者此時一定在睡眠,於是生產者呼叫wakeup來喚醒消費者。

但是消費者在邏輯上並未睡眠,所以wakeup訊號丟失,當消費者下次執行時,它將測試先前讀取的count值,發現它為0。於是睡眠,生產者遲早會填滿整個緩衝區,然後睡眠,這樣一來,兩個程式將永遠睡眠下去。

訊號量的引入及其操作

訊號量是Dijkstra在1965年提出的一種方法,它使用一個整型變數來累計喚醒次數,供以後使用。在他的建議中引入了一個新的變數型別,稱作訊號量(semaphore)。一個訊號量的取值可以為0(表示沒有儲存下來的喚醒操作)或者正值(表示有一個或多個喚醒操作)。

Dijkstra建議設立兩種操作: down和up(分別為一般化後的sleep和wakeup)。對一個訊號量執行down操作,則是檢查其值是否大於0。若該值大於0,則將其減1(即用掉一個儲存的喚醒訊號)並繼續;若該值為0,則程式將睡眠,而且此時down操作並未結束。檢查數值、修改變數值以及可能發生的睡眠操作均作為一個單一的、不可分割的原子操作完成。 保證一旦一個訊號量操作開始,則在該操作完成或阻塞之前,其他程式均不允許訪問該訊號量。這種原子性對於解決同步問題和避免競爭條件是絕對必要的。所謂原子操作,是指一組相關聯的操作要麼都不間斷地執行,要麼不執行。

up操作對訊號量的值增1。如果一個或多個程式在該訊號量上睡眠,無法完成一個先前的down操作,則由系統選擇其中的一個(如隨機挑選)並允許該程式完成它的down操作。於是,對一個有程式在其上睡眠的訊號量執行一次up操作後,該訊號量的值仍舊是0,但在其上睡眠的程式卻少了一個。訊號量的值增加1和喚醒一個程式同樣也是不可分割的,不會有某個程式因執行up而阻塞,正如前面的模型中不會有程式因執行wakeup而阻塞一樣。

在Dijkstra原來的論文中,他分別使用名稱P和V而不是down和up,荷蘭語中,Proberen的意思是嘗試,Verhogen的含義是增加或升高。

從物理上說明訊號量的P、V操作的含義。P(S)表示申請一個資源,S.value>0表示有資源可用,其值為資源的數目;S.value=0表示無資源可用;S.value<0, 則|S.value|表示S等待佇列中的程式個數。V(S)表示釋放一個資源,訊號量的初值應該大於等於0。P操作相當於“等待一個訊號”,而V操作相當於“傳送一個訊號”,在實現同步過程中,V操作相當於傳送一個訊號說合作者已經完成了某項任務,在實現互斥過程中,V操作相當於傳送一個訊號說臨界資源可用了。實際上,在實現互斥時,P、V操作相當於申請資源和釋放資源。

該解決方案使用了三個訊號量:一個稱為full,用來記錄充滿緩衝槽數目,一個稱為empty,記錄空的緩衝槽總數;一個稱為mutex,用來確保生產者和消費者不會同時訪問緩衝區。full的初值為0,empty的初值為緩衝區中槽的數目,mutex的初值為1。供兩個或多個程式使用的訊號量,其初值為1,保證同時只有一個程式可以進入臨界區,稱作二元訊號量。如果每個程式在進入臨界區前都執行down操作,並在剛剛退出時執行一個up操作,就能夠實現互斥。

在下面的例子中,我們實際上是通過兩種不同的方式來使用訊號量,兩者之間的區別是很重要的,訊號量mutex用於互斥,它用於保證任一時刻只有一個程式讀寫緩衝區和相關的變數。互斥是避免混亂所必需的操作。


//java學習交流:737251827  進入可領取學習資源及對十年開發經驗大佬提問,免費解答!

#define N 100
typedef int semaphore ;
semaphore mutex = 1 ;
semaphore empty = N ;
semaphore full = 0 ;
void producer ( void )
{
int item ;
while ( TRUE )
{
 item = produce_item ( ) ;
down ( &empty ) ;     //空槽數目減1,相當於P(empty)
down ( &mutex ) ;     //進入臨界區,相當於P(mutex)
insert_item (item ) ;   //將新資料放到緩衝區中
up ( &mutex ) ;     //離開臨界區,相當於V(mutex)
up ( &full ) ;     //滿槽數目加1,相當於V(full)
}
}
void consumer ( void )
{
int item ;
while ( TRUE )
{
down ( &full ) ;     //將滿槽數目減1,相當於P(full)
down ( &mutex ) ;     //進入臨界區,相當於P(mutex)
 item = remove_item ( ) ;       //從緩衝區中取出資料
up ( &mutex ) ;     //離開臨界區,相當於V(mutex)  
up ( &empty ) ;     //將空槽數目加1 ,相當於V(empty)
consume_item (item ) ;   //處理取出的資料項
}
}


訊號量的另一種用途是用於實現同步,訊號量full和empty用來保證某種事件的順序發生或不發生。在本例中,它們保證當緩衝區滿的時候生產者停止執行,以及當緩衝區空的時候消費者停止執行。

對於無界緩衝區的生產者—消費者問題,兩個程式共享一個不限大小的公共緩衝區。由於是無界緩衝區(倉庫是無界限制的),即生產者不用關心倉庫是否滿,只管往裡面生產東西,但是消費者還是要關心倉庫是否空。所以生產者不會因得不到緩衝區而被阻塞,不需要對空緩衝區進行管理,可以去掉在有界緩衝區中用來管理空緩衝區的訊號量及其PV操作。

Semaphore mutex 
= 
1
; 

Semaphore full = 0 ;
int in = 0,out = 0 ;
void producer ( void )
{
while ( TRUE )
{
 item = produce_item ( ) ;
  P (mutex ) ;     //進入臨界區
  Buffer ( in ) = item ;   //新生產的資料項放入緩衝區
  in = in + 1 ;     //因無界,無需考慮輸入指標越界
  V (mutex ) ;     //離開臨界區
  V (full ) ;     //增加已用緩衝區的數目
}
}
void consumer ( void )
{
int item ;
while ( TRUE )
{
  P (full ) ;   //等待已用緩衝區的數目非0
  P (mutex ) ;   //進入臨界區
 item = Buffer (out ) ;   //新生產的資料項放入緩衝區
 out = out + 1 ;   //因無界,無需考慮輸出指標越界
  V (mutex ) ;   //離開臨界區
  consume_item (item ) ;   //處理取出的資料項
}
}


在計算機領域,同步就是指一個程式在執行某個請求的時候,若該請求需要一段時間才能返回資訊,那麼這個程式會一直等待下去。直到收到返回資訊才繼續執行下去。非同步是指程式不需要一直等待下去,而是繼續執行下面的操作,不管其他程式的狀態,當有訊息返回時,系統會通知程式進行處理,這樣可以提高效率。

程式同步與互斥

在作業系統中,程式是佔有資源的最小單位(執行緒可以訪問其所在程式內的所有資源,但執行緒本身並不佔有資源或僅僅佔有一點必須資源)。但對於某些資源來說,其在同一時間只能被一個程式所佔用。這些一次只能被一個程式所佔用的資源就是所謂的臨界資源。典型的臨界資源比如物理上的印表機,或是存在硬碟或記憶體中被多個程式所共享的一些變數和資料等(如果這類資源不被看成臨界資源加以保護,那麼很有可能造成丟資料的問題)。

對臨界資源的訪問,必須是互斥地進行。也就是當臨界資源被佔用時,另一個申請臨界資源的程式會被阻塞,直到其所申請的臨界資源被釋放。而程式內訪問臨界資源的程式碼被成為臨界區。

程式同步也是程式之間直接的制約關係,是為完成某種任務而建立的兩個或多個程式,這些程式需要在某些位置上協調他們的工作次序而等待、傳遞資訊所產生的制約關係。程式間的直接制約關係來源於他們之間的合作。比如說程式A需要從緩衝區讀取程式B產生的資訊,當緩衝區為空時,程式B因為讀取不到資訊而被阻塞。而當程式A產生資訊放入緩衝區時,程式B才會被喚醒。

程式互斥是程式之間的間接制約關係。當一個程式進入臨界區使用臨界資源時,另一個程式必須等待。只有當使用臨界資源的程式退出臨界區後,這個程式才會解除阻塞狀態。比如程式B需要訪問印表機,但此時程式A佔有了印表機,程式B會被阻塞,直到程式A釋放了印表機資源,程式B才可以繼續執行,概念如下圖所示。

程式的同步和互斥是指程式在推進時的相互制約關係。 程式同步源於程式合作,是程式間共同完成一項任務是直接發生相互作用的關係。程式互斥源於對臨界資源的競爭,是程式之間的間接制約關係。

實現臨界區互斥訪問的基本方法有硬體實現方法和訊號量方法。

通過硬體實現臨界區最簡單的辦法就是關CPU的中斷。從計算機原理我們知道,CPU進行程式切換是需要通過中斷來進行。如果遮蔽了中斷那麼就可以保證當前程式順利的將臨界區程式碼執行完,從而實現了互斥。這個辦法的步驟就是:遮蔽中斷—執行臨界區操作—開中斷。但這樣做並不好,這大大限制了處理器交替執行任務的能力。並且將關中斷的許可權交給使用者程式碼,那麼如果使用者程式碼遮蔽了中斷後不再開,那系統豈不是跪了?

訊號量實現方式,這也是我們比較熟悉P/V操作。通過設定一個表示資源個數的訊號量S,通過對訊號量S的P和V操作來實現程式的的互斥。P/V操作是作業系統的原語,意味著具有原子性。P操作首先減少訊號量S,表示有一個程式將佔用或等待資源,然後檢測S是否小於0,如果小於0則阻塞,如果大於0則佔有資源進行執行。V操作是和P操作相反的操作,首先增加訊號量S,表示佔用或等待資源的程式減少了1個,然後檢測S是否小於0,如果大於0則喚醒等待使用S資源的其它程式。前面的生產者—消費者問題就是典型的應用訊號量解決的程式同步問題。



來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70010294/viewspace-2844965/,如需轉載,請註明出處,否則將追究法律責任。

相關文章