為了能夠有效的控制多個進程之間的溝通過程,OS必須提供一定的同步機制保證進程之間不會自說自話而是有效的協同工作。比如在共享內存的通信方式中,兩個或者多個進程都要對共享的內存進行數據寫入,那么怎么才能保證一個進程在寫入的過程中不被其它的進程打斷,保證數據的完整性呢?又怎么保證讀取進程在讀取數據的過程中數據不會變動,保證讀取出的數據是完整有效的呢?常用的同步方式有:
- 互斥鎖
- 條件變量
- 讀寫鎖
- 記錄鎖(文件鎖)
互斥鎖
所謂互斥,從字面上理解就是互相排斥。因此互斥鎖從字面上理解就是一個進程擁有了這個鎖,它將排斥其它所有的進程訪問被鎖住的東西,其它的進程如果需要鎖就只能等待,等待擁有鎖的進程把鎖打開后才能繼續運行。互斥鎖的主要特點是互斥鎖的釋放必須由上鎖的進(線)程釋放,如果擁有鎖的進(線)程不釋放,那么其它的進(線)程永遠也沒有機會獲得所需要的互斥鎖。互斥鎖主要用于線程之間的同步。
條件變量
上文中提到,對于互斥鎖而言,如果擁有鎖的進(線)程不釋放鎖,其它進(線)程永遠沒機會獲得鎖,也就永遠沒有機會繼續執行后續的邏輯。在實際環境下,一個線程A需要改變一個共享變量X的值,為了保證在修改的過程中X不會被其它的線程修改,線程A必須首先獲得對X的鎖。現在假如A已經獲得鎖了,由于業務邏輯的需要,只有當X的值小于0時,線程A才能執行后續的邏輯,于是線程A必須把互斥鎖釋放掉,然后繼續“忙等”。如下面的偽代碼所示:
// get x lock
while(x <= 0){
// unlock x ;
// wait some time
// get x lock
}
// unlock x
這種方式是比較消耗系統的資源的,因為進程必須不停的主動獲得鎖、檢查X條件、釋放鎖、再獲得鎖、再檢查、再釋放,一直到滿足運行的條件的時候才可以。因此我們需要另外一種不同的同步方式,當線程X發現被鎖定的變量不滿足條件時會自動的釋放鎖并把自身置于等待狀態,讓出CPU的控制權給其它線程。其它線程此時就有機會去修改X的值,當修改完成后再通知那些由于條件不滿足而陷入等待狀態的線程。這是一種通知模型的同步方式,大大的節省了CPU的計算資源,減少了線程之間的競爭,而且提高了線程之間的系統工作的效率。這種同步方式就是條件變量。坦率的說,從字面意思上來將,“條件變量”這四個字是不太容易理解的。我們可以把“條件變量”看做是一個對象,一個鈴鐺,一個會響的鈴鐺。當一個線程在獲得互斥鎖之后,由于被鎖定的變量不滿足繼續運行的條件時,該線程就釋放互斥鎖并把自己掛到這個“鈴鐺”上。其它的線程在修改完變量后,它就搖搖“鈴鐺”,告訴那些掛著的線程:“你們等待的東西已經變化了,都醒醒看看現在的它是否滿足你們的要求。”于是那些掛著的線程就知道自己醒來看自己是否能繼續跑下去了。
讀寫鎖
互斥鎖是排他性鎖,條件變量出現后和互斥鎖配合工作能夠有效的節省系統資源并提高線程之間的協同工作效率。互斥鎖的目的是為了獨占,條件變量的目的是為了等待和通知。考慮一個文件有多個進程要讀取其中的內容,但只有1個進程有寫的需求。我們知道讀文件的內容不會改變文件的內容,這樣即使多個進程同時讀相同的文件也沒什么問題,大家都能和諧共存。當寫進程需要寫數據時,為了保證數據的一致性,所有讀的進程就都不能讀數據了,否則很可能出現讀出去的數據一半是舊的,一半是新的狀況,邏輯就亂掉了。
為了防止讀數據的時候被寫入新的數據,讀進程必須對文件加上鎖。現在假如我們有2個進程都同時讀,如果我們使用上面的互斥鎖和條件變量,當其中一個進程在讀取數據的時候,另一個進程只能等待,因為它得不到鎖。從性能上考慮,等待進程所花費的時間是完全的浪費,因為這個進程完全可以讀文件內容而不會影響第一個,但是這個進程沒有鎖,所以它什么也做不了,只能等,等到花兒都謝了。所以呢,我們需要一種其它類型的同步方式來滿足上面的需求,這就是讀寫鎖。讀寫鎖的出現能夠有效的解決多進程并行讀的問題。每一個需要讀取的進程都申請讀鎖,這樣大家互不干擾。當有進程需要寫如數據時,首先申請寫鎖。如果在申請時發現有讀(或者寫)鎖存在,則該寫進程必須等待,一直等到所有的讀(寫)鎖完全釋放為止。讀進程在讀取之前首先申請讀鎖,如果所讀數據被寫鎖鎖定,則該讀進程也必須等待讀鎖被釋放位置。很自然的,多個讀鎖是可以共存的,但寫鎖是完全互相排斥的。
條件變量是另一種常用的變量。它也常常被保存為全局變量,并和互斥鎖合作。
條件變量的另外一種解釋
假設這樣一個狀況: 有100個工人,每人負責裝修一個房間。當有10個房間裝修完成的時候,老板就通知相應的十個工人一起去喝啤酒。
我們如何實現呢?老板讓工人在裝修好房間之后,去檢查已經裝修好的房間數。但多線程條件下,會有競爭條件的危險。也就是說,其他工人有可能會在該工人裝修好房子和檢查之間完成工作。采用下面方式解決:
/*mu: global mutex,
cond: global codition variable,
num: global int
*/
mutex_lock(mu)
num = num + 1; /*worker build the room*/
if (num <= 10) { /*worker is within the first 10 to finish*/
cond_wait(mu, cond); /*wait*/
printf("drink beer");
}else if (num = 11) {
/*workder is the 11th to finish*/
cond_broadcast(mu, cond); /*inform the other 9 to wake up*/}
mutex_unlock(mu);
上面使用了條件變量。條件變量除了要和互斥鎖配合之外,還需要和另一個全局變量配合(這里的num, 也就是裝修好的房間數)。這個全局變量用來構成各個條件。
具體思路如下。我們讓工人在裝修好房間(num = num + 1)之后,去檢查已經裝修好的房間數( num < 10 )。由于mu被鎖上,所以不會有其他工人在此期間裝修房間(改變num的值)。如果該工人是前十個完成的人,那么我們就調用cond_wait()函數。cond_wait()做兩件事情,一個是釋放mu,從而讓別的工人可以建房。另一個是等待,直到cond的通知。這樣的話,符合條件的線程就開始等待。
當有通知(第十個房間已經修建好)到達的時候,condwait()會再次鎖上mu。線程的恢復運行,執行下一句prinft("drink beer") (喝啤酒!)。從這里開始,直到mutex_unlock(),就構成了另一個互斥鎖結構。
那么,前面十個調用cond_wait()的線程如何得到的通知呢?我們注意到elif if,即修建好第11個房間的人,負責調用cond_broadcast()。這個函數會給所有調用cond_wait()的線程放送通知,以便讓那些線程恢復運行。
條件變量特別適用于多個線程等待某個條件的發生。如果不使用條件變量,那么每個線程就需要不斷嘗試獲得互斥鎖并檢查條件是否發生,這樣大大浪費了系統的資源。
記錄鎖(文件鎖)
為了增加并行性,我們可以在讀寫鎖的基礎上進一步細分被鎖對象的粒度。比如一個文件中,讀進程可能需要讀取該文件的前1k個字節,寫進程需要寫該文件的最后1k個字節。我們可以對前1k個字節上讀鎖,對最后1k個自己上寫鎖,這樣兩個進程就可并發工作了。記錄鎖中的所謂“記錄”其實是“內容”的概念。使用讀寫鎖可以鎖定一部分,而不是整個文件。文件鎖可以認為是記錄鎖的一個特例,當使用記錄鎖鎖定文件的所有內容時,此時的記錄鎖就可以稱為文件鎖了。
信號燈
一般意義下,信號燈是一個具有整數值的對象,它支持兩種操作P()和V()。P()操作減少信號燈的值,如果新的信號燈的值小于0,則操作阻塞;V()操作增加信號燈的值,如果結果值大于或等于0,則喚醒一個等待的進程。通常用信號燈來做進程的同步和互斥。
最簡單形式的信號燈就是內存中一個存儲位置,它的取值可以由多個進程檢驗和設置。至少對于相關的進程來講,對信號燈的檢驗和設置操作是不可中斷的或者說是原子的:只要啟動就不能終止。目前許多處理器提供檢驗和設置操作指令,如Intel處理器的sete等指令。檢驗和設置操作的結果是信號燈當前值與設置值的和,可以是正或者負。根據檢驗和設置操作的結果,一個進程可能必須睡眠直到信號燈的值被另一個進程改變。信號燈可以用于實現臨界區(critical regions),就是重要的代碼區,同一時刻只能有一個進程運行的代碼區域。
比如,有許多協作的進程要從同一個數據文件中讀寫記錄,并且希望對文件的訪問必須嚴格地協調。那么,可以使用一個信號燈,將其初值設為1,用兩個信號燈操作(P、V 操作),將進程中對文件操作的代碼括起來。第一個信號燈操作檢查并把信號燈的值減小,第二個操作檢查并增加它。訪問文件的第一個進程試圖減小信號燈的值,如果它成功(事實上,它肯定成功),信號燈的取值將變為0,這個進程現在可以繼續運行并使用數據文件。但是,如果此時另一個進程需要使用這個文件,它也試圖減少信號燈的數值,它會失敗,因為信號燈的值將要變成-1(但是,信號燈的值仍然保持為0,沒有變成-1),這個進程會被掛起直到第一個進程處理完數據文件。當第一個進程處理完數據文件后,它會增加信號燈的值使其重新變為1。現在等待的進程會被喚醒,這次它減小信號燈的嘗試會成功。