如何避免多進程(線程)因競爭條件引發的錯誤?

注:本文主要參考自<<現代操作系統>>第2章

如果避免多進程(線程)因競爭條件引發執行錯誤?

多進程程序競爭條件

對於多進程或多線程協作程序,如果多個執行程序間需要訪問共享內存區域,則程序編寫人員一定要仔細判斷程序執行邏輯,確保多個程序對共享內存區域的訪問不會出現邏輯錯誤.對於多個進程協作可能因競爭條件引發的執行錯誤,在多線程編程中同樣存在,其解決方法也同樣適用於多線程問題,因此後文均以多進程來說明問題.

多進程協作間因競爭條件引發錯誤的根本問題在於程序並未按照開發者設想的邏輯順序正常執行,追根溯源,在於開發者編寫程序時,通常沒有考慮到由於CPU的進程調度,當前進程的執行可能在任意位置發生進程切換.開發者通常認爲程序在某一特定區域的執行不會被引發中斷,或者沒有意識到程序在當前區域中斷後,運行其他協作進程可能引發的邏輯順序錯誤.

考慮如下打印機程序.生產者進程接受用戶打印文件的請求,將待打印的文件名寫入到一個文件目錄中.消費者進程(執行打印工作)從當前文件目錄中取出當前文件名(刪除文件名)並打印當前文件名.使用in變量記錄生產者進程向文件目錄中寫入文件名的下一位置,使用out變量記錄下一打印文件名的位置.
在這裏插入圖片描述
生產者進程的僞代碼如下所示:

while(true){
	//獲取待打印的文件名
	printFileName = getFileName();
	//將文件名寫入打印文件夾中
	writeFileNameToPrintFolder(printFileName, printFolder, in);
	in++;
}

假設當前有兩個生產者進程A,B同時收到打印文件名,準備執行writeFileNameToPrintFolder寫入文件名到打印文件夾中.進程A讀取變量in的值爲7,進程A寫入文件名到文件夾中的位置7處,若此時CPU中斷,切換到執行進程B,此時進程B讀取變量in 值爲7,則進程B將文件名寫入到文件夾的位置7處.在此情形下,進程A寫入的文件名被進程B覆蓋.

發生以上錯誤的原因在於程序發生了超出開發者設想的不合時宜的切換.如果程序沒有在調用writeFileNameToPrintFolderin++間中斷,則程序可以正常工作.爲什麼在這兩句代碼間不能中斷呢?因爲這兩句代碼正在執行修改共享內存區的操作.在本例中.共享內存區內容包括in變量及打印文件夾printFolder. 爲了確保程序執行無誤,任意時刻,最多隻有一個進程正在執行這兩句代碼.

通常將涉及對共享內存區域中變量進行操作的代碼片段稱爲臨界區,因此爲了避免多進程中因競爭條件引發的錯誤,程序開發人員必須保證其撰寫的代碼,在同一時刻,做多隻有一個進程運行在臨界區當中.

避免多進程競爭條件

爲了避免多進程競爭條件,所提出的方案應當符合如下條件:

  • 任何兩個進程不能同時處於臨界區
  • 不能對CPU速度和數量作出任何假設
  • 不得使進程無限期等待進入臨界區
  • 臨界區外運行的進程不得阻塞其他進程

除了最後一個條件外,前三個條件都是可行方案所必須滿足的.最後一個方案若不滿足,則意味着產生了不必要的等待時間,程序執行效率被降低.

以下提出的幾種解決思路均屬於忙等待方案.即進程在等待進入臨界區時,持續檢查是否符合進入條件,CPU持續運轉.顯然,忙等待造成了不必要的CPU資源浪費.

(1)屏蔽中斷

在程序準備進入臨界區前,屏蔽所有終端,在結束臨界區操作後,打開中斷.這樣,在執行臨界區代碼時,進程不會因爲任何中斷條件發生切換,保證了在每一時刻最多只能有一個進程在執行臨界區代碼.

儘管從理論上可行,在屏蔽中斷方法幾乎不會被實際採用.首先.將屏蔽終端的權限交給用戶具有很大的風險.例如,如果一個進程在將所有終端屏蔽後並未再次打開,則其餘所有進程將用於得不到執行的機會,這可能造成系統的崩潰.另一方面,屏蔽終端方法僅使用於單處理器硬件環境.當前進程執行屏幕終端指令時,其僅能將正在運行該進程的CPU的所有中斷屏蔽掉,而其他CPU仍可正常中斷,因此其他進程仍然有可能進入臨界區.目前,多處理器的硬件環境多已成基本配置,因此屏蔽中斷方法基本不具有可行性.

將屏蔽中斷的權限限制在內核態是一項非常有用的技術.例如內核在更新變量或列表的幾條指令期間可以將中斷屏蔽,保證操作的原子性.

(2) 鎖變量

可以考慮使用鎖變量來實現多個進程的互斥訪問.鎖變量爲0表示當前沒有進程位於臨界區當中.對於想要進入臨界區的進程,其首先檢查當前鎖變量的值,如果爲0,則更新當前鎖變量值爲非0值並進入臨界區,在退出臨界區前,將鎖變量重新置爲0.其僞代碼如下:

//非鄰接區代碼
....
//進入臨界區
while(lock != 0){
	lock = 1;
	//臨界區代碼
	....
	lock = 0;
}
//非臨界區代碼
...

該方法看似可行,然而仔細分析就會發現,該代碼仍然無法實現進程在臨界區的互斥.假設進程0準備進入臨界區,其檢查鎖變量,發現其值爲0.此時發生時鐘中端,切換到進程1,進程1檢查鎖變量,發現其值爲0,然後進入臨界區,修改鎖變量的值爲1,進而開始執行臨界區代碼.若在執行臨界區的過程中,CPU發生時鐘中斷,再次切換會進程0.此時進程0順序執行下一條指令,其進入臨界區,修改lock變量爲1.這時,麻煩來了,進程0和進程1同時進入了臨界區.

出現以上問題的原因在於進程對與鎖變量的訪問與修改不是原子操作,在對鎖變量的訪問與修改的間隙可能發生時鐘中斷,因此無法保證僅有一個進程位於臨界區.

(3) 嚴格輪換法

如果我們可以嚴格制定多個進程在臨界區的運行次序,則可以避免多個進程同時進入臨界區.
嚴格輪換法使用變量turn記錄當前擁有在臨界區運行權限的進程編號,在當前進程執行完臨界區代碼,準備退出前,其將turn變量設置爲下一具有臨界區運行權限的進程編號,實現權限的交接,即進程輪換在臨界區執行.對於兩個守護進程0,和1,該方法僞代碼如下:
對於進程0

while(true)//執行非臨界區代碼
	.......
	//準備進入臨界區
	while(turn != 0);
	//進入臨界區
	....
	turn = 1; //將臨界區執行權限轉移給進程1
	//執行非臨界區代碼
	......

對於進程1

while(true){
	//執行非臨界區代碼
	......
	//準備進入臨界區
	while(turn !=1 );
	//進入臨界區
	......
	turn = 0; //將臨界區執行權限轉移給進程0
	//非臨界區代碼
	......
}

上述方法是第一個可行的解決方案.分析一下進程執行邏輯,如果當前進程0位於臨界區,則turn取值等於0,因此其他的進程無法進入臨界區.如果當前進程1位於臨界區,則turn等於1,則進程0無法進入臨界區.

上述算法儘管可行,但其無法滿足前述提到良好解決方案的最後一個條件,即位於非臨界區的進程不應當阻塞其他進程.考慮如下情形,假設進程0執行爲臨界區代碼後,turn更新爲1,此時進程0開始執行非臨界區代碼,進程1執行其臨界區代碼.假設進程1很快執行完了其臨界區代碼和非臨界區代碼,將臨界區執行權限轉移給進程0,turn設置爲0.此時進程1準備執行新一輪的臨界區代碼,它正在等待turn變量被進程0修改爲1.而此時若進程0的非臨界區代碼執行耗時較大,則進程0仍然位於非臨界區代碼,由於進程0尚未進入臨界區,因此無法將臨界區執行權限轉移給進程1.

因此,我們發現,在嚴格輪換法中,如果兩個進程執行時間差異較大,則可能造成位於非臨界區的進程阻塞其他進程.造成較大的CPU資源浪費.

(4)peterson算法

peterson算法簡潔易懂,其主要包含兩個方法,在進程準備進入臨界區前,調用enter_region方法,則進程執行完臨界區代碼後,準備退出前,調用leave_region方法.
對於包含兩個進程的互斥算法,其實現如下:

int[] interested = new int[N]
int turn; //紀錄當前有臨界區執行權限的進程編號
void enter_region(int process){
	int other = 1 - process;
	interested[process] = true;
	turn = process;
	while(turn == process && interested[other] == true);
}

void leave_region(int process){
	interested[process] = false;
}

上述代碼能否實現進程在臨界區的互斥呢?
考慮進程0何時能夠進程臨界區?如果turn == 1或者turn == 0 && interested[1] == false, 則進程0能夠進入臨界區.
分析第一種情形:如果此時turn==1,是否可能證明進程1不在臨界區呢?

證明:如果turn == 1, 則表明在進程0設置完turn之後,發生了進程切換,進程1執行並將turn設置爲了1.對於進程1,此時turn == 1 && interested[0] == true, 因此進程1無法進入臨界區.因此我們可以證明,對於進程0,如果此時turn==1,則進程1不在臨界區,因此進程0可以安全進程臨界區.

分析第二種情形,如果turn == 0 && interested[1] == false, 能夠證明進程1不再臨界區呢?

證明:如果turn == 0, 則此時進程1可能尚未進入臨界區,此時進程0可以安全進入臨界區.如果進程1當前進入了臨界區,則interested[1]等於true, 因此與當前條件interested[1] == false相矛盾,因此在turn == 0 && interested[1] == false時,進程1不在臨界區內,因此進程0可以安全進程臨界區.

(5) TSL指令

現在來看需要硬件支持的一種方案。某些計算機中,特別是那些設計爲多處理器的計算機,都有下面一條指令

TSL RX,LOCK

稱爲測試並加鎖(Test and Set Lock),它將一個內存字lock讀到寄存器RX中,然後在該內存地址上存一個非零值。讀字和寫字操作保證是不可分割的,即該指令結束之前其他處理器均不允許訪問該內存字。執行TSL指令的CPU將鎖住內存總線,以禁止其他CPU在本指令結束之前訪問內存。

在方法3中我們提到,由於讀取變量與設置變量並非原子操作,因此在讀取變量與設置變量間可能因CPU時鐘中斷造成進程切換.而TSL指令實現了讀取變量與設置變量的原子性.我們可以在方法3的基礎上基於TSL指令實現進程在臨界區的互斥訪問.

着重說明一下,鎖住存儲總線不同於屏蔽中斷。屏蔽中斷,然後在讀內存字之後跟着寫操作並不能阻止總線上的第二個處理器在讀操作和寫操作之間訪問該內存字。事實上,在處理器1上屏蔽中斷對處理器2根本沒有任何影響。讓處理器2遠離內存直到處理器1完成的惟一方法就是鎖住總線,這需要一個特殊的硬件設施(基本上,一根總線就可以確保總線由鎖住它的處理器使用,而其他的處理器不能用)。

爲了使用TSL指令,要使用一個共享變量lock來協調對共享內存的訪問。當lock爲0時,任何進程都可以使用TSL指令將其設置爲1,並讀寫共享內存。當操作結束時,進程用一條普通的move指令將lock的值重新設置爲0。

這條指令如何防止兩個進程同時進入臨界區呢?解決方案如下圖所示。假定(但很典型)存在如下共4條指令的彙編語言子程序。第一條指令將lock原來的值複製到寄存器中並將lock設置爲1,隨後這個原來的值與0相比較。如果它非零,則說明以前已被加鎖,則程序將回到開始並再次測試。經過或長或短的一段時間後,該值將變爲0(當前處於臨界區中的進程退出臨界區時),於是過程返回,此時已加鎖。要清除這個鎖非常簡單,程序只需將0存入lock即可,不需要特殊的同步指令。
在這裏插入圖片描述
現在有一種很明確的解法了。進程在進入臨界區之前先調用enter_region,這將導致忙等待,直到鎖空閒爲止,隨後它獲得該鎖並返回。在進程從臨界區返回時它調用leave_region,這將把lock設置爲0。與基於臨界區問題的所有解法一樣,進程必須在正確的時間調用enter_region和leave_region,解法才能奏效。如果一個進程有欺詐行爲,則互斥將會失敗.

一個可替代TSL的指令是XCHG,它原子性地交換了兩個位置的內容,例如,一個寄存器與一個存儲器字。代碼下圖所示,而且就像可以看到的那樣,它本質上與TSL的解決辦法一樣。所有的Intel x86 CPU在低層同步中使用XCHG指令。

在這裏插入圖片描述

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