1 進程的併發執行
1.1 問題的提出
併發是所有問題產生的原因, 也是操作系統設計的基礎。
1.2 進程的特徵
表1-1 進程的特徵
進程的特徵 | 說明 |
---|---|
併發 | 進程的執行是間斷性的,相對執行速度不可預測 |
共享 | 進程/線程之間的制約性,併發環境下多個進程/線程會共享資源 |
不確定性 | 進程的執行結果與其執行的相對速度有關,都是不確定的 |
1.3 併發執行過程分析
本節用一個小例子演示併發執行過程以及可能出現的錯誤。
場景:假設有四個消息隊列:f,s,t,g。定義三個進程:get, 從f獲取一個元素到s;copy, 從s複製元素到t;put, 從t獲取元素推到g。不同的執行過程將會產生不同的結果,假設g,c,p是get,copy,put的一次循環。那麼執行結果如下表所示:
表1-2 進程執行過程分析
狀態 | f隊列 | s隊列 | t隊列 | g隊列 | 結果說明 |
---|---|---|---|---|---|
當前狀態 | (3,4,5…,m) | 2 | 2 | (1,2) | 初始值 |
g,c,p | (4,5,…,m) | 3 | 3 | (1,2,3) | 正確數據 |
g,p,c | (4,5,…,m) | 3 | 3 | (1,2,2) | 異常數據 |
c,g,p | (4,5,…,m) | 3 | 2 | (1,2,2) | 異常數據 |
c,p,g | (4,5,…,m) | 3 | 2 | (1,2,2) | 異常數據 |
由上表可發現,在沒有限制條件時候,進程併發執行很可能出錯,導致髒數據的產生,那麼應該怎麼解決呢?這就需要進程前趨圖——即併發環境下進程間的制約關係。
2 進程互斥
2.1 競態條件(Race Condition)
兩個或多個進程讀寫某些共享數據,而最後的結果取決於進程運行的精確時序,由於不恰當的執行順序而出現不正確的結果,這種情況叫做競態條件。
2.2 進程互斥(Mutual Exclusive)
由於各進程要求使用共享資源(變量、文件等),而這些資源需要排他使用,各進程之間競爭使用這些資源,這種關係成爲進程互斥。
2.2.1 臨界資源:critical resource
共享資源又稱爲臨界資源、互斥資源,是指系統中一次只允許一個進程使用的資源。
2.2.2 臨界區(互斥區):critical section
每個進程中對某個臨界資源進行操作的程序片段,這些程序片段互爲臨界區。如下圖所示:
2.2.3 臨界區的使用原則
- 沒有進程在臨界區時,想進入臨界區的進程可進入
- 不允許兩個進程同時處於其臨界區中
- 臨界區外運行的進程不得阻塞其他進程進入臨界區
- 不得使進程無限期等待進入臨界區
3 進程互斥的解決方案
3.1 進程互斥的軟件解決方案
3.1.1 After you問題
假設有兩個進程P和Q,兩個進程是否想進入臨界區的標識分別爲pFlag和qFlag(true表示想進入),初始值都爲false,P進程進入臨界區的條件是pFlag=true且qFlag=false,當Q進程獲取到CPU時間片,開始執行,想進入臨界區,則把qFlag修改成true,但此時時間片到了,Q進程下了CPU,接着P進程獲取到CPU,P進程也想進入臨界區,於是修改pFlag=true,但此時qFlag也等於true,導致P進程也進入不了臨界區,造成了雖然臨界區空閒但是兩個進程都無法進入臨界區。這就是After you問題。
3.1.2 Dekker算法
是第一個解決了進程互斥問題的算法。核心思想在於維護了一個turn變量,當兩個進程P、Q都想進入臨界區時,根據turn變量的值決定由誰進入臨界區,並且未進入臨界區的進程不斷循環turn變量的值以檢測是否可以進入臨界區。這個算法可能會引發忙等待(busy waiting)問題。
3.1.3 Peterson算法
相較Dekker算法而言是一種更良好的算法。核心思想在於每個進程i在想進入臨界區之前都會調用enter_region(i),離開臨界區都會調用leave_region(i)方法。相關方法的邏輯見如下代碼。
程序清單3-1 Peterson算法核心邏輯
#define FALSE 0
#define TRUE 1
// 進程的個數
#define N 2
// 輪到誰
int turn;
//感興趣數組,初始值都爲FALSE,爲TRUE表示對應的進程想進入臨界區
int interested[N];
// process=0或1
void enter_region(int process){
int other;
// 另外一個進程的進程號
other=1-process;
// 表明本進程感興趣
interested[process]=TRUE;
// 設置標誌位
turn=process;
// 如果標識變量turn和本進程相等且另外一個進程也想進入臨界區,則進入while循環,當另外一個進程不想進入臨界區時,則本進程進入臨界區
while(turn==process && interested[other]==TRUE);
}
void leave_region(int process){
// 本進程已經離開臨界區
interested[process]=FALSE;
}
3.2 進程互斥的硬件解決方案
3.2.1 中斷屏蔽的方法
簡單來說就是通過開關中斷的指令實現。執行“關閉中斷”指令—>進程進入臨界區操作—>執行“開啓中斷”的指令。事實上**原語(原子操作)**就是基於這個思想實現的。
該方法的優點在於簡單、高效,但是缺點也很明顯,代價高,限制了CPU的併發能力,不適用於多處理器。
3.2.2 測試並加鎖(TSL)指令
Test and Set Lock, 是指進程每次要進入臨界區之前,都要調用enter_region方法,方法的主要實現是:複製鎖到寄存器並將鎖置1,判斷寄存器是否爲0,如果不是,重新進入enter_region;如果是0,則調用者進程進入臨界區(該方法也是一個忙等待的過程)。當進程離開臨界區時,將鎖置0。
3.2.3 交換指令(Exchange)
進程每次要進入臨界區之前調用enter_region方法時,給寄存器中置1,交換寄存器與鎖變量的內容,判斷寄存器內容是否爲0,若不是0則跳轉到enter_region重新執行,若是0則返回調用者進程,且進程進入臨界區。
3.3 小結
通過軟件的解決方案對編程技巧要求較高。在用硬件的解決方案時,注入TSL指令和Exchange指令會造成忙等待問題。
Busy waiting:進程在得到臨界區訪問權之前,佔用着CPU持續測試而不做其他事情。
在單處理器的系統下,如果唯一的CPU被佔用着進行忙等待的操作就對整個系統的運行都不利,但是在多處理器的情況下讓一個進程利用自旋鎖(Spin lock)佔用其中一個處理器進行不斷的自旋請求以獲取臨界區的操作則是比較明智的選擇(因爲進程切下CPU引起的上下文開銷一般要高於自旋鎖佔用CPU的消耗)。
4 進程同步(synchronization)
進程同步是指多個進程中發生的事件存在某種時序關係,需要相互合作共同完成一項任務。
具體地說,一個進程運行到某一點時,要求另一個夥伴進程爲它提供消息,在未獲得消息之前,該進程進入阻塞態,獲得消息之後被喚醒進入就緒態。
4.1 信號量及P、V操作
4.1.1 概念
信號量是一個特殊的變量,用戶進程間傳遞信息的一個整數值,定義如下:
程序清單4-1 信號量的數據結構
struc semaphore{
int count;
queueType queue;
}
對信號量可進行的操作:初始化(非負數)、P和V(P和V分別是荷蘭語的test和increment的首字母)。
4.1.2 P、V操作的定義
程序清單4-2 P/V操作的底層實現原理
// s是一個信號量
P(s){
s.count--;
if(s.count<0){
//該進程狀態設置爲阻塞狀態;
//同時將該進程插入相應的等待隊列s.queue末尾;
//最後重新調度
}
}
V(s){
s.count++;
if(s.count<=0){
//喚醒相應等待隊列s.queue中等待的一個進程;
//改變其狀態爲就緒態,並將其插入到就緒隊列;
}
}
P、V操作均爲原語操作(atomic action),最初提出的二元信號量(只有值0和1)爲了解決互斥的問題,後來推廣到計數信號量可以用來解決同步問題。
4.1.3 用P、V操作解決進程間的互斥問題
- 設置信號量mutex初值爲1
- 在進入臨界區前實施P(mutex)
- 在退出臨界區後實施V(mutex)
假設有P1、P2、P3這三個進程,用P、V操作解決互斥問題的邏輯示意圖如下。當P1進程想進入臨界區時,先進行P操作,此時mutex.count=0,此時P1進程進入臨界區,然後下了CPU,P2進程此時獲得CPU的執行權,想進入臨界區之前同樣調用P操作,首先是count自減少,此時由於mutex.count=-1<0,P2進程進入阻塞態,同時將進程加入到mutex.queue的隊尾;如果此時P2的時間片也到了,下了CPU後由進程P3緊接着獲得CPU的執行權,依然是P操作,此時mutex.count=-2,依然進入不了臨界區,P3進程也被加入mutex.queue的隊尾。此時P3釋放了CPU,接着由P1獲取CPU的執行權,注意,此時P1還是在臨界區的,P1執行完相關操作退出臨界區,此時調用V操作,count自增,結果是mutex.count=-1<0,所以操作系統會喚醒等待隊列mutex.queue中的一個進程(此時P2、P3進程都在隊列中)並將其插入到就緒隊列等待CPU調度。當P2接着獲取到CPU執行權時候,此時由於P2進程已經執行過P操作,所以進入臨界區。
4.1.4 用P、V操作解決生產者消費者問題
程序清單4-3 用P、V操作解決生產者消費者問題
/* 緩衝區默認個數 */
#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();
P(&empty);
P(&mutex);
insert_item(item);
V(&mutex);
V(&full);
}
}
void consumer(void){
int item;
while(TRUE){
P(&full);
P(&mutex);
insert_item(item);
V(&mutex);
V(&empty);
consume_item(item);
}
}
用信號量解決的過程如下圖:
思考:如果把代碼裏的 P(&empty);P&(mutex);順序換成P&(mutex); P(&empty);可以嗎?
答:不可以,會發生死鎖問題,至於原因讀者可以結合前面說明的P、V操作的特點思考一下。
4.1.5 用P、V操作解決讀者寫者問題
1 問題描述:多個進程共享一個數據區,這些進程分爲兩組:
讀者進程:只讀數據區中的數據
寫者進程:只往數據區寫數據
需要滿足條件:
- 只允許多個讀者同時進行讀操作
- 不允許多個寫者同時操作
- 不允許讀者、寫者同時操作
本節主要針對讀者優先的問題,什麼是讀者優先呢:
如果讀者執行:
- 無其他讀者、寫者,則該讀者可以讀
- 若已有寫者等,但有其他讀者正在讀,則該讀者也可以讀
- 若有寫者正在寫,該讀者必須等
如果寫者執行: - 無其他讀者、寫者,該寫者可以寫
- 若有讀者正在讀,該寫者等待
- 若有其他寫者正在寫,該寫者等待
2 讀者優先問題的解決方案
讀者優先問題的關鍵在於只對第一個讀者進程執行P操作,使其能夠進入臨界區,並且第二個、第三個……進程不需要再進行P操作就可以進入臨界區,當最後一個讀者進程離開臨界區的時候執行V操作。在臨界區有讀者進程時,由於臨界資源被佔用,所以任何的寫者進程都無法進來。
需要判斷某個進程是否是第一個進程或者最後一個進程,需要注意的是readCount++
不是原子操作,並且if(readCount==1)
是先檢查再執行操作,可能也有線程安全性問題。所以還需要對這兩個操作加入P、V操作。
程序清單4-4 用P、V操作解決讀者寫者問題
void reader(void){
while(TRUE){
P(mutex);
/* 讀者進程數 */
readCount++;
if(readCount==1){
/* 第一個讀者 */
P(w);
}
V(mutex);
讀操作
P(mutex);
readCount--;
if(readCount==0){
/* 最後一個讀者 */
V(w);
}
V(mutex);
// 其他操作
}
}
void writer(void){
while(TRUE){
……
P(w);
寫操作
V(w);
}
}
3 實例:Linux中的讀-寫鎖
Linux的IPX路由代碼中就使用了讀-寫鎖,保護了路由表的併發訪問。
要通過查找路由表實現包轉發的程序需要請求讀鎖;需要添加和刪除路由表中入口的程序必須獲取寫鎖(由於通過讀路由表的情況比更新路由表的情況多得多,使用讀-寫鎖提高了性能)
5 小結
本篇主要對進程同步互斥概念進行了講解,介紹了競態條件、臨界區、自旋鎖等重要概念。重點分析了信號量和PV操作的原理以及在經典問題模型中的應用。