深入理解作業系統原理之程序管理(一)
一、概述
1、為什麼引入程序
程式併發執行時具有如下特徵:
- 間斷性
程式在併發執行時,由於它們共享資源或為完成同一項任務而相互合作,使在併發程式之間形成了相互制約的關係。相互制約將導致併發程式具有“執行-暫停-執行”這種間斷性活動規律。 - 失去封閉性
程式在併發執行時,是多個程式共享系統中的各種資源,因而這些資源的狀態將由多個程式來改變,致使程式的執行已失去了封閉性。 - 不可再現性
程式在併發執行時,由於失去了封閉性,也將導致失去結果的可再現性。即程式經過多次執行,雖然其各次的環境和初始條件相同,但得到的結果卻各不相同。
由於程式在併發執行時,可能會造成執行結果的不可再現,所以用“程式”這個概念已無法描述程式的併發執行,所以必須引入新的概念—程序來描述程式的併發執行,並要對程序進行必要的管理,以保證程序在併發執行時結果可再現。
程序(Process)定義:“可併發執行的程式在一個數據集合上的執行過程”。程序具有如下特徵:
- 動態性
動態性是程序的最基本特徵,它是程式執行過程,它是有一定的生命期。它由建立而產生、由排程而執行,因得不到資源而暫停,並由撤消而死亡。而程式是靜態的,它是存放在介質上一組有序指令的集合,無運動的含義。 - 併發性
併發性是程序的重要特徵,同時也是OS的重要特徵。併發性指多個程序實體同存於記憶體中,能在一段時間內同時執行。而程式是不能併發執行。 - 獨立性
程序是一個能獨立執行的基本單位,即是一個獨立獲得資源和獨立排程的單位,而程式不作為獨立單位參加執行。 - 非同步性:程序按各自獨立的不可預知的速度向前推進,即程序按非同步方式進行,正是這一特徵,將導致程式執行的不可再現性,因此OS必須採用某種措施來限制各程序推進序列以保證各程式間正常協調執行。
- 結構特徵:從結構上,程序實體由程式段、資料段和程序控制塊三部分組成,UNIX中稱為“程序映象”。
2、程序的描述
程序的三個基本狀態
- 執行態/執行態(Running):當一個程序在處理機上執行時,則稱該程序處於執行狀態。
- 就緒態(Ready):一個程序獲得了除處理機外的一切所需資源,一旦得到處理機即可執行,則稱此程序處於就緒狀態。
- 阻塞態(Blocked):(又稱掛起狀態、等待狀態):一個程序正在等待某一事件發生(例如請求I/O而等待I/O完成等)而暫時仃止執行,這時即使把處理機分配給程序也無法執行,故稱該程序處於阻塞狀態。
三個基本狀態之間可能轉換和轉換原因如下:
- 就緒態–>執行態:當處理機空閒時,程序排程程式必將處理機分配給一個處於就緒態的程序 ,該程序便由就緒態轉換為執行態。
- 執行態–>阻塞態:處於執行態的程序在執行過程中需要等待某一事件發生後(例如因I/O請求等待I/O完成後),才能繼續執行,則該程序放棄處理機,從執行態轉換為阻塞態。
- 阻塞態–>就緒態:處於阻塞態的程序,若其等待的事件已經發生,於是程序由阻塞態轉換為就緒態。
- 執行態–>就緒態:處於執行狀態的程序在其執行過程中,因分給它的處理機時間片已用完,而不得不讓出(被搶佔)處理機,於是程序由執行態轉換為就緒態。
- 而阻塞態–>執行態和就緒態–>阻塞態這二種狀態轉換不可能發生。
- 處於執行態程序:如系統有一個處理機,則在任何一時刻,最多隻有一個程序處於執行態。
- 處於就緒態程序:一般處於就緒態的程序按照一定的演算法(如先來的程序排在前面,或採用優先權高的程序排在前面)排成一個就緒佇列RL。
- 處於阻塞態程序:處於阻塞態的程序排在阻塞佇列中。由於等待事件原因不同,阻塞佇列也按事件分成幾個佇列WLi。
一個問題:假設一個只有一個處理機的系統中,OS的程序有執行、就緒、阻塞三個基本狀態。假如某時刻該系統中有10個程序併發執行,在略去排程程式所佔用時間情況下試問
這時刻系統中處於執行態的程序數最多幾個?最少幾個
這時刻系統中處於就緒態的程序數最多幾個?最少幾個
這時刻系統中處於阻塞態的程序數最多幾個?最少幾個?
解:因為系統中只有一個處理機,所以某時刻處於執行態的程序數最多隻有一個。而最少可能為0,此時其它10個程序一定全部排在各阻塞佇列中,在就緒佇列中沒有程序。而某時刻處於就緒態的程序數最多隻有9個,不可能出現10個情況,因為一旦CPU有空,排程程式馬上排程,當然這是在略去排程程式排程時間時考慮。處於阻塞態的程序數最少是0個。
3、程序控制模組(PCB)
- 程序控制塊的作用
由於程序控制塊中記錄程序存在和特性資訊;PCB與程序同生死,建立一個程序就是為其建立一個PCB,當程序被撤消時,系統就回收它的PCB;OS對程序的控制要是根據PCB來進行,對程序管理也通過對PCB管理來實現,所以程序控制塊是程序存在的唯一實體。 - PCB的資訊
程序識別符號:它用於唯一地標識一個程序。它有外部識別符號(由字母組成,供使用者使用)和內部識別符號(由整陣列成,為方便系統管理而設定)二種。
程序排程資訊:它包括程序狀態(running、ready、blacked)、佇列(就緒、阻塞佇列)、佇列指標,排程引數:程序優先順序、程序已執行時間和已等待時間等。
處理機狀態資訊:它由處理機各種暫存器(通用暫存器、指令計數器、程式狀態字PSW、使用者棧指標等)的內容所組成,該類資訊使程序被中斷後重新執行時能恢復現場從斷點處繼續執行。
程序控制資訊:它包括程式和資料的地址、I/O資源清單,保證程序正常執行的同步和通訊機制等。
家族資訊:它包括該程序的父、子程序識別符號、程序的使用者主等。
UNIX的PCB由Proc和user兩個結構組成,proc常駐主存的系統區,是PCB中最基本和常用資訊,而user可根據需要換進換出。
4、程序上下文
程序是由程式、資料和程序控制塊組成。程序上下文實際上是執行活動全過程的靜態描述。具體說,程序上下文包括系統中與執行該程序有關的各種暫存器(例如:通用暫存器、程式計數器PC、程式狀態暫存器PS等)的值,程式段在經編譯之後形成的機器指令程式碼集(或稱正文段)、資料集及各種堆疊值和PCB結構。一個程序的執行是在該程序的上下文中執行,而當系統排程新程序佔有處理機時,新老程序的上下發生切換。UNIX 作業系統的程序上下文稱為程序映象。
二、程序控制
1、核心
- 核心態和使用者態
為了防止使用者應用程式訪問和/或更改重要的作業系統資料,Windows 2000、UNIX使用兩種處理器訪問模式:核心態和使用者態(又稱管態和目態)。作業系統程式碼在核心態下執行,即在x86處理器Ring0執行,它有著最高的特權。而使用者應用程式程式碼在使用者態下執行,即在x86處理器Ring3中執行。
使用者應用程式(在Windows 2000中以使用者執行緒方式出現)執行使用者程式一般程式碼時,它是在使用者態下執行。但當程式要呼叫系統服務,例如要呼叫作業系統中負責從磁碟檔案中讀取資料的NT執行體例程時,它就要通過一條專門的指令(自陷指令/訪管指令)來完成從使用者態切換到核心態,作業系統根據該指令及有關引數,執行使用者的請求服務。在服務完成後將處理器模式切換回使用者態,並將控制返回使用者執行緒。因此使用者執行緒有時在核心態下執行,在核心態下執行的是呼叫作業系統有關功能模組的程式碼。 - 原語
原語是一種特殊的廣義指令,它的功能是由系統通過一段不可分割的指令操作來完成,它又稱原子操作,原語在核心態下完成。程序控制操作(建立、撤消、阻塞……)大都為原語操作。 - 核心功能
核心是計算機硬體上的第一層擴充軟體,它是OS中關鍵部分,它是管理控制中心。核心在核心態下執行,常駐記憶體,核心通過執行各種原語操作來實現各種控制和管理功能。
2、程序狀態的細化
“掛起”、 “啟用”操作的引入
系統管理員有時需要暫停某個程序,以便排除系統故障或暫時減輕系統負荷,使用者有時也希望暫停自己的程序以便檢查自己作業的中間結果。這就希望系統提供“掛起”操作,暫停程序執行,同是也要提供“啟用”的操作,恢復被掛起的程序。由於被掛起前程序的狀態有三種,掛起後的程序就分為二種狀態:靜止就緒態和靜止阻塞態(有的稱掛起就緒態和掛起阻塞態)。掛起前的程序就緒態和阻塞態也改為活動就緒態和活動阻塞態。
當程序處於執行態和活動就緒態時,執行掛起操作,程序狀態轉換為靜止就緒態。當程序處於活動阻塞態時,執行掛起操作,程序狀態轉換為靜止阻塞態。對被掛起的程序施加“啟用”操作,則處於靜止就緒的程序轉換為活動就緒態,處於靜止阻塞態的程序轉換為活動阻塞態。被掛起的處於靜止阻塞態的程序當它等待的事件發生後,它就由靜止阻塞態轉換為靜止就緒態。
3、程序控制原語
- 建立原語(Create)
一個程序可藉助建立原語來建立一個新程序,該新程序是它的子程序,建立一個程序主要是為新程序建立一個PCB。建立原語首先從系統的PCB表中索取一個空白的PCB表目,並獲得其內部標識,然後將呼叫程序提供的引數:如外部名、正文段、資料段的首址、大小、所需資源、優先順序等填入這張空白PCB表目中。並設定新程序狀態為活動/靜止就緒態,並把該PCB插入到就緒佇列RQ中,就可進入系統併發執行。 - 撤消原語(Destroy)/ 終止(Termination)
對於樹型層次結構的程序系統撤消原語採用的策略是由父程序發出,撤消它的一個子程序及該子程序所有的子孫程序,被撤消程序的所有資源(主存、I/O資源、PCB表目)全部釋放出來歸還系統,並將它們從所有的佇列中移去。如撤消的程序正在執行,則要呼叫程序排程程式將處理器分給其它程序。 - 阻塞原語(block)
當前程序因請求某事件而不能執行時(例如請求I/O而等待I/O完成時),該程序將呼叫阻塞原語阻塞自己,暫時放棄處理機。程序阻塞是程序自身的主動行為。阻塞過程首先立即仃止原來程式的執行,把PCB中的現行狀態由執行態改為活動阻塞態,並將PCB插入到等待某事件的阻塞佇列中,最後呼叫程序排程程式進行處理機的重新分配。 - 喚醒原語(wakeup)
當被阻塞的程序所期待的事件發生時(例如I/O完成時),則有關程序和過程(例如I/O裝置處理程式或釋放資源的程序等)呼叫wakeup原語,將阻塞的程序喚醒,將等待該事件的程序從阻塞佇列移出,插入到就緒佇列中,將該程序的PCB中現行狀態,如是活動阻塞態改為活動就緒態,如是靜止阻塞態改為靜止就緒態。 - 掛起原語(suspend)
呼叫掛起原語的程序只能掛起它自己或它的子孫,而不能掛起別的族系的程序。掛起原語的執行過程是:檢查要掛起程序PCB的現行狀態,若正處於活動就緒態,便將它改為靜止就緒態;如是活動阻塞態則改為靜止阻塞態。如是執行態,則將它改為靜止就緒態,並呼叫程序排程程式重新分配處理機。為了方便使用者或父程序考察該程序的執行情況,需把該程序的PCB複製到記憶體指定區域。 - 啟用原語(active)
使用者程序或父程序通過呼叫啟用原語將被掛起的程序啟用。啟用原語執行過程是:檢查被掛起程序PCB中的現行狀態,若處於靜止就緒態,則將它改為活動就緒態,若處於靜止阻塞態,則將它改為活動阻塞態。
4、執行緒概念
- 執行緒的引入
作為併發執行的程序具有二個基本的屬性:程序既是一個擁有資源的獨立單位,它可獨立分配虛地址空間、主存和其它,又是一個可獨立排程和分派的基本單位。這二個基本屬性使程序成為併發執行的基本單位。在一些OS中,像大多數UNIX系統等,程序同時具有這二個屬性。而另一些OS中,象WindowsNT、Solaris、OS/2、Mac OS等,這二個屬性由OS獨立處理。為了區分二個屬性 ,資源擁有單元稱為程序(或任務),排程的單位稱為執行緒、又稱輕程序(light weight process)。執行緒只擁有一點在執行中必不可省的資源(程式計數器、一組暫存器和棧),但它可與同屬一個程序的其它執行緒共享程序擁有的全部資源。執行緒定義為程序內一個執行單元或一個可排程實體。 - 執行緒的作用
執行緒的關鍵好處是從效能應用中得到的,在一個存在的程序中產生(或終止)一個執行緒比產生(或終止)一個程序化費少得多的時間。類似地,在同一程序內二個執行緒間切換時間也要比二個程序切換時間小得多。執行緒機制也增加了通訊的有效性。執行緒間通訊是在同一程序的地址空間內,共享主存和檔案,所以非常簡單,無需核心參與。因此假如一個應用或函式作為一組相關執行單元的應用,那麼採用一組執行緒的集合比採用分開程序的集合要有效得多。 - 執行緒的分類
核心級(系統級)執行緒(kernel-level thread):依賴於OS核心,由核心的內部需求進行建立和撤銷,用來執行一個指定的函式。
使用者級執行緒(user-level thread):不依賴於OS核心,應用程序利用執行緒庫提供建立、同步、排程和管理執行緒的函式來控制使用者執行緒。如:資料庫系統informix,圖形處理Aldus PageMaker。排程由應用軟體內部進行,通常採用非搶先式和更簡單的規則,也無需使用者態/核心態切換,所以速度特別快。一個執行緒發起系統呼叫而阻塞,則整個程序在等待。時間片分配給程序,多執行緒則每個執行緒就慢。
三、程序同步
1、程序同步的概念
在多道程式環境下,系統中各程序以不可預測的速度向前推進,程序的非同步性會造成了結果的不可再現性。為防止這種現象,非同步的程序間推進受到二種限制:
- 資源共享關係
像印表機這類資源一次只允許一個程序使用的資源稱為臨界資源。屬於臨界資源有硬體印表機、磁帶機等,軟體在訊息緩衝佇列、變數、陣列、緩衝區等。當然還有一類象磁碟等資源,它允許程序間共享,即可交替使用,所以它稱為共享資源,而臨界資源又稱獨享資源。
多程序之間如果共享資源,例如各程序爭用一臺印表機,這時各程序使用這臺印表機時有一定的限制。每次只允許一個程序使用一段時間印表機,等該程序使用完畢後再將印表機分配給其它程序。這種使用原則稱為互斥使用。 - 相互合作關係
在某些程序之間還存在合作關係,例如一個程式的輸入、計算、列印三個程式段作為三個程序併發執行,由於這三個程序間存在著相互合作的關係,即先輸入再計算、最後再列印的關係,所以這三個程序在併發執行時推進序列受到限制,要保證其合作關係正確,程序間這種關係稱為同步關係。
程序之間競爭資源也面臨三個控制問題:
- 互斥( mutual exclusion )指多個程序不能同時使用同一個資源;
- 死鎖( deadlock )指多個程序互不相讓,都得不到足夠的資源;
- 飢餓( starvation )指一個程序一直得不到資源(其他程序可能輪流佔用資源)。
程序在併發執行時為了保證結果的可再現性,各程序執行序列必須加以限制以保證互斥地使用臨界資源,相互合作完成任務。多個相關程序在執行次序上的協調稱為程序同步。用於保證多個程序在執行次序上的協調關係的相應機制稱為程序同步機制。所有的程序同步機制應遵循下述四條準則:
- 空閒讓進
當無程序進入臨界區時,相應的臨界資源處於空閒狀態,因而允許一個請求進入臨界區的程序立即進入自己的臨界區。 - 忙則等待
當已有程序進入自己的臨界區時,即相應的臨界資源正被訪問,因而其它試圖進入臨界區的程序必須等待,以保證程序互斥地訪問臨界資源。 - 有限等待
對要求訪問臨界資源的程序,應保證程序能在有限時間進入臨界區,以免陷入“飢餓”狀態。 - 讓權等待
當程序不能進入自己的臨界區時,應立即釋放處理機,以免程序陷入忙等。
2、硬體技術實現程序同步機制
- 提高臨界區程式碼執行中斷優先順序(IRQL)
這種方法在UNIX和Windows 2000中都使用,它是在單機系統中有效地實現互斥的一種方法。因為在傳統作業系統中,打斷程序對臨界區程式碼的執行只有中斷請求、中斷一旦被接受,系統就有可能呼叫其它程序進入臨界區,並修改此全域性資料庫。所以用提高臨界區中斷優先順序方法就可以遮蔽了其它中斷,保證了臨界段的執行不被打斷,從而實現了互斥。 - 檢測和設定(TS)硬體指令
許多大型機(如IBM370等)和微型機(如Intel x86等)中都提供了專用的硬體指令,這些指令全部允許對一個字中的內容進行檢測和修正,或交換兩個字的內容。特別要指出的是這些操作都是在一個儲存週期中完成,或者說由一條指令來完成,用這些指令就可以解決臨界區問題了。因為臨界區問題在多道環境中之所以存在,是由於多個程序共同訪問、修改同一公用變數。用這些硬體指令可以簡單有效地實現互斥。其方法是為每個臨界段或其它互斥資源設定一個布林變數,例如稱為lock。當其值為false則臨界區末被使用,反之則說明正有程序在臨界區中執行。 - 自旋鎖
Windows2000 核心用來達到多處理器互斥的機制“自旋鎖”,它類同於TS指令機制。自旋鎖是一個與共用資料結構有關的鎖定機制。在每個程序進入自己的臨界區之前,核心必須獲得與所保護的DPC(延遲過程呼叫)佇列有關的自旋鎖。如果自旋鎖非空,核心將一直嘗試得到鎖直到成功。因為核心(處理器也如此)被保持在過渡狀態“旋轉”,直到它獲得鎖,“自旋鎖”由此而得名。
自旋鎖像它們所保護的資料結構一樣,儲存在共用記憶體中。為了速度和使用任何在處理器體系下提供的鎖定機構,獲取和釋放自旋鎖的程式碼是用匯編語言寫的。例如在Intel處理器上,Windows2000使用了一個只在486處理器或更高處理器上執行的指令。
當執行緒試圖獲得自旋鎖時,在處理器上所有其它工作將終止。因此擁有自旋鎖的執行緒永遠不會被搶先,但允許它繼續執行以便使它儘快把鎖釋放。核心對於使用自旋鎖十分小心,當它擁有自旋鎖時,它執行的指令數將減至最少。
3、訊號量機制
1965年,荷蘭學者Dijkstra提出的訊號量機制是一種卓有成效的程序同步工具,在長期廣泛的應用中,訊號量機制又得到了很大的發展,它從整型訊號量機制發展到記錄型訊號量機制,進而發展為“訊號集”機制。現在訊號量機制已廣泛應用於OS中。
訊號量按聯絡程序的關係分成二類:
- 公用訊號量(互斥訊號量):它為一組需互斥共享臨界資源的併發程序而設定,它代表永久性的共享的臨界資源,每個程序均可對它施加P、V操作,即都可申請和釋放該臨界資源,其初始值置為1。
- 專用訊號量(同步訊號量):它為一組需同步協作完成任務的併發程序而設定,它代表消耗性的專用資源,只有擁有該資源的程序才能對它施加P操作(即可申請資源),而由其合作程序對它施加V操作(即釋放資源)。
具體分類如下:
- 整型訊號量
最初由Dijkstra把整型訊號量定義為一個整型量,除初始化外,僅能通過兩個標準的原子操作(Atomic Operation) wait(S)和signal(S)來訪問。這兩個操作一直被分別稱為P、V操作。 wait和signal操作可描述為:
S .value>0 ;表示可供使用資源數
S .value=0 ;表示資源已被佔用,無其它程序等待。
S .value<0(=-n) ;表示資源已被佔用,還有n個程序因等待資源而阻塞。 - 記錄型訊號量
在整型訊號量機制中的wait操作,只要是訊號量S≤0, 就會不斷地測試。因此,該機制並未遵循“讓權等待”的準則, 而是使程序處於“忙等”的狀態。記錄型訊號量機制,則是一種不存在“忙等”現象的程序同步機制。但在採取了“讓權等待”的策略後,又會出現多個程序等待訪問同一臨界資源的情況。為此,在訊號量機制中,除了需要一個用於代表資源數目的整型變數value外,還應增加一個程序連結串列L,用於連結上述的所有等待程序。記錄型訊號量是由於它採用了記錄型的資料結構而得名的。
在記錄型訊號量機制中,S.value的初值表示系統中某類資源的數目, 因而又稱為資源訊號量,對它的每次wait操作,意味著程序請求一個單位的該類資源,因此描述為S.value=S.value-1; 當S.value<0時,表示該類資源已分配完畢,因此程序應呼叫block原語,進行自我阻塞,放棄處理機,並插入到訊號量連結串列S.L中。可見,該機制遵循了“讓權等待”準則。 此時S.value的絕對值表示在該訊號量連結串列中已阻塞程序的數目。 對訊號量的每次signal操作,表示執行程序釋放一個單位資源,故S.value=S.value+1操作表示資源數目加1。 若加1後仍是S.value≤0,則表示在該訊號量連結串列中,仍有等待該資源的程序被阻塞,故還應呼叫wakeup原語,將S.L連結串列中的第一個等待程序喚醒。如果S.value的初值為1,表示只允許一個程序訪問臨界資源,此時的訊號量轉化為互斥訊號量。 - AND型訊號量
在兩個程序中都要包含兩個對Dmutex和Emutex的操作。 AND同步機制的基本思想是:將程序在整個執行過程中需要的所有資源,一次性全部地分配給程序,待程序使用完後再一起釋放。只要尚有一個資源未能分配給程序,其它所有可能為之分配的資源,也不分配給他。亦即,對若干個臨界資源的分配,採取原子操作方式:要麼全部分配到程序,要麼一個也不分配。 由死鎖理論可知,這樣就可避免上述死鎖情況的發生。為此,在wait操作中,增加了一個“AND”條件,故稱為AND同步,或稱為同時wait操作, 即Swait(Simultaneous wait)。
4、利用訊號量實現程序互斥
為使多個程序能互斥地訪問某臨界資源,只需為該資源設定一個互斥訊號量mutex,並設其初值為1,並規定每個程序在進入臨界區CS前必須申請資源,即對互斥訊號量mutex施加P操作,在退出臨界區CS後必須釋放資源,即對互斥訊號量mutex施加V操作;即將各程序的臨界區CS置於P(mutex)和V(mutex)操作之間。
5、經典程序同步問題
1、生產者-消費之問題
在多道程式環境下,程序同步是一個十分重要又令人感興趣的問題,而生產者-消費者問題是其中一個有代表性的程序同步問題。下面我們給出了各種情況下的生產者-消費者問題。
(1)一個生產者,一個消費者,公用一個緩衝區。
定義兩個同步訊號量:
empty——表示緩衝區是否為空,初值為1。
full——表示緩衝區中是否為滿,初值為0。
生產者程序
while(TRUE){
生產一個產品;
P(empty);
產品送往Buffer;
V(full);
}
消費者程序
while(True){
P(full);
從Buffer取出一個產品;
V(empty);
消費該產品;
}
(2)一個生產者,一個消費者,公用n個環形緩衝區。
定義兩個同步訊號量:
empty——表示緩衝區是否為空,初值為n。
full——表示緩衝區中是否為滿,初值為0。
設緩衝區的編號為0~n-1,定義兩個指標in和out,分別是生產者程序和消費者程序使用的指標,指向下一個可用的緩衝區。
生產者程序
while(TRUE){
生產一個產品;
P(empty);
產品送往buffer(in);
in=(in+1)mod n;
V(full);
}
消費者程序
while(TRUE){
P(full);
從buffer(out)中取出產品;
out=(out+1)mod n;
V(empty);
消費該產品;
}
(3)一組生產者,一組消費者,公用n個環形緩衝區
在這個問題中,不僅生產者與消費者之間要同步,而且各個生產者之間、各個消費者之間還必須互斥地訪問緩衝區。
定義四個訊號量:
empty——表示緩衝區是否為空,初值為n。
full——表示緩衝區中是否為滿,初值為0。
mutex1——生產者之間的互斥訊號量,初值為1。
mutex2——消費者之間的互斥訊號量,初值為1。
設緩衝區的編號為0~n-1,定義兩個指標in和out,分別是生產者程序和消費者程序使用的指標,指向下一個可用的緩衝區。
生產者程序
while(TRUE){
生產一個產品;
P(empty);
P(mutex1);
產品送往buffer(in);
in=(in+1)mod n;
V(mutex1);
V(full);
}
消費者程序
while(TRUE){
P(full)
P(mutex2);
從buffer(out)中取出產品;
out=(out+1)mod n;
V(mutex2);
V(empty);
消費該產品;
}
需要注意的是無論在生產者程序中還是在消費者程序中,兩個P操作的次序不能顛倒。應先執行同步訊號量的P操作,然後再執行互斥訊號量的P操作,否則可能造成程序死鎖。
下面給出一個Java描述程式碼:
package com.kang;
import java.util.concurrent.Semaphore;
class Signs {
static Semaphore empty = new Semaphore(10); // 訊號量:表示倉庫快取區
static Semaphore full = new Semaphore(0); // 訊號量:表示倉庫滿的標誌
static Semaphore mutex = new Semaphore(1); // 臨界區互斥訪問訊號量(二進位制訊號量),相當於互斥鎖。
}
class Producer implements Runnable {
// 生產者
public void run() {
try {
while (true) {
Signs.empty.acquire(); // 遞減倉庫快取區訊號量
Signs.mutex.acquire(); // 進入臨界區
System.out.println("生成一個產品放入倉庫");
Signs.mutex.release(); // 離開臨界區
Signs.full.release(); // 遞增倉庫滿訊號量
Thread.currentThread().sleep(100);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
class Consumer implements Runnable {
// 消費者
public void run() {
try {
while (true) {
Signs.full.acquire(); // 遞減倉庫滿訊號量
Signs.mutex.acquire(); // 進入臨界區
System.out.println("從倉庫拿出一個產品消費");
Signs.mutex.release(); // 離開臨界區
Signs.empty.release(); // 遞增倉庫快取區訊號量
Thread.currentThread().sleep(1000);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public class Test {
public static void main(String[] args) {
new Thread(new Producer()).start();
new Thread(new Consumer()).start();
}
}
程式執行結果如下:
2、哲學家進餐問題
在1965年,Dijkstra提出並解決了一個他稱之為哲學家進餐的同步問題。從那時起,每個發明新的同步原語的人都希望通過解決哲學家進餐間題來展示其同步原語的精妙之處。這個問題可以簡單地描述:五個哲學家圍坐在一張圓桌周圍,每個哲學家面前都有一碟通心麵,由於麵條很滑,所以要兩把叉子才能夾住。相鄰兩個碟子之間有一把叉子。哲學家的生活包括兩種活動:即吃飯和思考。當一個哲學家覺得餓時,他就試圖去取他左邊和右邊的叉子。如果成功地獲得兩把叉子,他就開始吃飯,吃完以後放下叉子繼續思考。
約束條件:
(1)只有拿到兩隻筷子時,哲學家才能吃飯。
(2)如果筷子已被別人拿走,則必須等別人吃完之後才能拿到筷子。
(3)任一哲學家在自己未拿到兩隻筷子吃飯前,不會放下手中拿到的筷子。
(4)用完之後將筷子返回原處。
分析:筷子是臨界資源,每次只被一個哲學家拿到,這是互斥關係。如果筷子被拿走,那麼需要等待,這是同步關係。
在什麼情況下5 個哲學家全部吃不上飯?
考慮兩種實現的方式,如下:
A、設定一個訊號量表示一隻筷子,有5只筷子,所以設定5個訊號量,哲學家每次飢餓時先試圖拿左邊的筷子,再試圖拿右邊的筷子,拿不到則等待,拿到了就進餐,最後逐個放下筷子。這種情況可能會產生死鎖,因為我們不知道程序何時切換(這也是很多IPC問題的根本原因),如果5個哲學家同時飢餓,同時試圖拿起左邊的筷子,也很幸運地都拿到了,那麼他們拿右邊的筷子的時候都會拿不到,而根據第三個約束條件,都不會放下筷子,這就產生了死鎖。描述如下:
void philosopher(int i) /*i:哲學家編號,從0 到4*/
{
while (TRUE) {
think( ); /*哲學家正在思考*/
take_fork(i); /*取左側的筷子*/
take_fork((i+1) % N); /*取右側筷子;%為取模運算*/
eat( ); /*吃飯*/
put_fork(i); /*把左側筷子放回桌子*/
put_fork((i+1) % N); /*把右側筷子放回桌子*/
}
}
B、規定在拿到左側的筷子後,先檢查右面的筷子是否可用。如果不可用,則先放下左側筷子, 等一段時間再重複整個過程。
分析:當出現以下情形,在某一個瞬間,所有的哲學家都同時啟動這個演算法,拿起左側的筷 子,而看到右側筷子不可用,又都放下左側筷子,等一會兒,又同時拿起左側筷子……如此 這樣永遠重複下去。對於這種情況,所有的程式都在執行,但卻無法取得進展,即出現飢餓, 所有的哲學家都吃不上飯。
描述一種沒有人餓死(永遠拿不到筷子)演算法。
考慮了三種實現的方式(A、B、C):
A.原理:至多隻允許四個哲學家同時進入房間進餐,以保證至少有一個哲學家在任何情況下能夠正常進餐,最終總會釋放出他所使用過的兩支筷子,從而可使更多的哲學家進餐。
以下將room作為訊號量,只允許4個哲學家同時進入房間就餐,這樣就能保證至少有一個哲學家可以就餐,而申請進入房間的哲學家進入room 的等待佇列,根據FIFO 的原則,總會進入到房間就餐,因此不會 出現餓死和死鎖的現象。
偽碼:
semaphore chopstick[5]={1,1,1,1,1}; //筷子訊號量
semaphore room=4;
void philosopher(int i)
{
while(true)
{
think(); //思考
wait(room); //請求進入房間進餐
wait(chopstick[i]); //請求左手邊的筷子
wait(chopstick[(i+1)%5]); //請求右手邊的筷子
eat();
signal(chopstick[(i+1)%5]); //釋放右手邊的筷子
signal(chopstick[i]); //釋放左手邊的筷子
signal(room); //退出房間釋放訊號量room
}
}
B.原理:僅當哲學家的左右兩支筷子都可用時,才允許他拿起筷子進餐。
方法1:利用AND 型訊號量機制實現:在一個原語中,將一段程式碼同時需 要的多個臨界資源,要麼全部分配給它,要麼一個都不分配,因此不會出現死鎖的情形。當某些資源不夠時阻塞呼叫程序;由於等待佇列的存在,使得對資源的請求滿足FIFO 的要求, 因此不會出現飢餓的情形。
偽碼:
semaphore chopstick[5]={1,1,1,1,1}; //筷子訊號量
void philosopher(int I)
{
while(true)
{
think(); //思考
Swait(chopstick[(I+1)]%5,chopstick[I]); //同時獲取左右筷子,是AND訊號量
eat(); //就餐
Ssignal(chopstick[(I+1)]%5,chopstick[I]); //釋放
}
}
方法2:利用訊號量的保護機制實現。通過訊號量mutex對eat()之前的取左側和右側筷 子的操作進行保護,使之成為一個原子操作,這樣可以防止死鎖的出現。
偽碼:
semaphore mutex = 1 ; //互斥訊號量
semaphore chopstick[5]={1,1,1,1,1};
void philosopher(int I)
{
while(true)
{
think();
wait(mutex); //進入互斥區
wait(chopstick[(I+1)]%5); //獲取右側筷子
wait(chopstick[I]); //獲取左側筷子
signal(mutex); //離開互斥區
eat();
signal(chopstick[(I+1)]%5);
signal(chopstick[I]);
}
}
C. 原理:規定奇數號的哲學家先拿起他左邊的筷子,然後再去拿他右邊的筷子;而偶數號 的哲學家則相反。按此規定,將是1,2號哲學家競爭1號筷子,3,4號哲學家競爭3號筷子.即五個哲學家都競爭奇數號筷子,獲得後,再去競爭偶數號筷子,最後總會有一個哲學家能獲得兩支筷子而進餐。而申請不到的哲學家進入阻塞等待佇列,根FIFO原則,則先申請的哲學家會較先可以吃飯,因此不會出現餓死的哲學家。
偽碼:
semaphore chopstick[5]={1,1,1,1,1};
void philosopher(int i)
{
while(true)
{
think();
if(i%2 == 0) //偶數哲學家,先右後左。
{
wait (chopstick[ i + 1 ] mod 5) ;
wait (chopstick[ i]) ;
eat();
signal (chopstick[ i + 1 ] mod 5) ;
signal (chopstick[ i]) ;
}
Else //奇數哲學家,先左後右。
{
wait (chopstick[ i]) ;
wait (chopstick[ i + 1 ] mod 5) ;
eat();
signal (chopstick[ i]) ;
signal (chopstick[ i + 1 ] mod 5) ;
}
}
}
解決哲學家進餐問題的一個Java描述:
package com.kang;
import java.util.Random;
class Chopsticks {
// 用 used[1]至 used[5]五個陣列元素分別代表編號 1 至 5 的五支筷 子的狀態
// false 表示未被佔用,true 表示已經被佔用。 used[0]元素在本程式中未使用
private boolean used[] = { true, false, false, false, false, false };
// 拿起筷子的操作
public synchronized void takeChopstick() {
/* 取得該執行緒的名稱並轉化為整型,用此整數來判斷該哲學家應該用哪兩支筷子 */
/* i 為左手邊筷子編號,j 為右手邊筷子編號 */
String name = Thread.currentThread().getName();
int i = Integer.parseInt(name);
/*
* 1~4 號哲學家使用的筷子編號是 i 和 i+1,5 號哲學家使用 的筷子編號是 5 和 1
*/
int j = i == 5 ? 1 : i + 1;
/* 將兩邊筷子的編號按奇偶順序賦給 odd,even 兩個變數 */
int odd, even;
if (i % 2 == 0) {
even = i;
odd = j;
} else {
odd = i;
even = j;
}
/* 首先競爭奇數號筷子 */
while (used[odd]) {
try {
wait();
} catch (InterruptedException e) {
}
}
used[odd] = true;
/* 然後競爭偶數號筷子 */
while (used[even]) {
try {
wait();
} catch (InterruptedException e) {
}
}
used[even] = true;
}
/* 放下筷子的操作 */
public synchronized void putChopstick() {
String name = Thread.currentThread().getName();
int i = Integer.parseInt(name);
int j = i == 5 ? 1 : i + 1;
/*
* 將相應筷子的標誌置為 fasle 表示使用完畢, 並且通知其他等待執行緒來競爭
*/
used[i] = false;
used[j] = false;
notifyAll();
}
}
class Philosopher extends Thread {
Random rand = new Random();//用來進行隨機延時
Chopsticks chopsticks;
public Philosopher(String name, Chopsticks chopsticks) {
/* 在構造例項時將 name 引數傳給 Thread 的建構函式作為執行緒的名稱 */
super(name);
/* 所有哲學家執行緒共享同一個筷子類的例項 */
this.chopsticks = chopsticks;
}
public void run() {
/* 交替地思考、拿起筷子、進餐、放下筷子 */
while (true) {
thinking();
chopsticks.takeChopstick();
eating();
chopsticks.putChopstick();
}
}
public void thinking() {
/* 顯示字串輸出正在思考的哲學家,用執行緒休眠若干秒鐘來模擬思考時間 */
System.out.println("Philosopher " + Thread.currentThread().getName()
+ " is thinking.");
try {
Thread.sleep(1000 * rand.nextInt(5));
} catch (InterruptedException e) {
}
}
public void eating() {
/* 顯示字串輸出正在進餐的哲學家,並用執行緒休眠 若干 秒鐘來模擬進餐時間 */
System.out.println("Philosopher " + Thread.currentThread().getName()
+ " is eating.");
try {
Thread.sleep(1000 * rand.nextInt(5));
} catch (InterruptedException e) {
}
}
}
public class Test {
public static void main(String[] args) {
{
/* 產生筷子類的例項 chopsticks */
Chopsticks chopsticks = new Chopsticks();
/* 用筷子類的例項作為引數, 產生五個哲學家執行緒並啟動 */
/* 五個哲學家執行緒的名稱為 1~5 */
new Philosopher("1", chopsticks).start();
new Philosopher("2", chopsticks).start();
new Philosopher("3", chopsticks).start();
new Philosopher("4", chopsticks).start();
new Philosopher("5", chopsticks).start();
}
}
}
3、讀者-寫者問題
讀者一寫者問題(Courtois et al., 1971)為資料庫訪問建立了一個模型。例如,設想一個飛機定票系統,其中有許多競爭的程序試圖讀寫其中的資料。多個程序同時讀是可以接受的,但如果一個程序正在寫資料庫、則所有的其他程序都不能訪問資料庫,即使讀操作也不行。
我們來分析他們的關係,首先,這個問題沒有明顯的同步關係,因為在這個問題裡,讀和寫並不要合作完成某些事情。但是是有互斥關係的,寫者和寫者,寫者和讀者是有互斥關係的,我們需要設定一個mutex來控制其訪問,但是單純一個訊號量的話會出現讀者和讀者的互斥也出現了,因為我們可能有多個讀者,所以我們設定一個變數ReadCount表示讀者的數量,好,這個時候,對於ReadCount又要實現多個讀者對他的互斥訪問,所以還要設定一個RC_mutex。這樣就好了。然後是行為設計:
void Reader() {
while(1) {
P(&RC_mutex);
rc = rc + 1;
if(rc == 1) P(&mutex); //如果是第一個讀者,那麼限制寫者的訪問
V(&RC_mutex);
//讀資料
P(&RC_mutex);
rc = rc - 1;
if(rc == 0) V(&mutex); //如果是最後一個讀者,那麼釋放以供寫者或讀者訪問
V(&RC_mutex);
}
}
void Writer() {
while(1) {
P(&mutex);
//寫資料
V(&mutex);
}
}
這裡給出Java描述:
package com.kang;
import java.util.concurrent.Semaphore;
class Sign {
static Semaphore db = new Semaphore(1); // 訊號量:控制對資料庫的訪問
static Semaphore mutex = new Semaphore(1); // 訊號量:控制對臨界區的訪問
static int rc = 0; // 記錄正在讀或者想要讀的程序數
}
class Reader implements Runnable {
public void run(){
try {
//互斥對rc的操作
Sign.mutex.acquire();
Sign.rc++; //又多了一個讀執行緒
if(Sign.rc==1) Sign.db.acquire(); //如果是第一個讀程序開始讀取DB,則請求一個許可,使得寫程序無法操作 DB
Sign.mutex.release();
//無臨界區控制,多個讀執行緒都可以操作DB
System.out.println("[R] "+Thread.currentThread().getName()+": read data....");
Thread.sleep(100);
//互斥對rc的操作
Sign.mutex.acquire();
Sign.rc--;
if(Sign.rc==0) Sign.db.release(); //如果最後一個讀程序讀完了,則釋放許可,讓寫程序有機會操作DB
Sign.mutex.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}}
class Writer implements Runnable {
public void run() {
try {
// 與讀操作互斥訪問DB
Sign.db.acquire();
System.out.println("[W] " + Thread.currentThread().getName()
+ ": write data....");
Thread.sleep(100);
Sign.db.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public class Test {
public static void main(String[] args) {
new Thread(new Reader()).start();
new Thread(new Reader()).start();
new Thread(new Writer()).start();
}
}
其實,這個方法是有一定問題的,只要趁前面的讀者還沒讀完的時候新一個讀者進來,這樣一直保持,那麼寫者會一直得不到機會,導致餓死。有一種解決方法就是在一個寫者到達時,如果後面還有新的讀者進來,那麼先掛起那些讀者,先執行寫者,但是這樣的話併發度和效率又會降到很低。