多程序影象除了需要實現切換,還需要處理程序之間的相互影響。本部分介紹程序之間的合作如何變得合理有序。將要涉及號誌、臨界區、死鎖等經典概念的理解。
參考資料:
課程:哈工大作業系統(本部分對應 L16 && L17 && L18 && L19)
感覺這部分理解的還是不夠深入,需要多複習這部分,在實驗五中體會體會。
總的來說,作業系統通過 !號誌! 實現程序同步
兩個程序如果需要共同完成一個任務(即程序合作),需要訊號來溝通。正如司機需要等待售票員關門後發出訊號才可啟動車輛。
等待是程序同步的核心,程序同步的基本結構如下:
- 程序A執行到某個環節時,發現某個條件不符合,則不繼續向下執行,而是停下來等待;
- 直到程序B執行到一定程度後產生這個條件,向程序A傳送訊號,程序A繼續向下執行。
如上圖所示,生活中訊號的例子有很多,那麼要說的號誌是什麼?(從訊號到號誌)
發明號誌的迪傑斯特拉因此拿了圖靈獎。此外他還發明瞭程序排程中非常著名的多級反饋佇列演演算法。
這是一個典型的多程序合作的程式典例,生產者在緩衝區滿的時候就不應該繼續像緩衝區輸入了,所以通過while(counter==BUFFER_SIZE);
進行阻塞,生產者程序開始等待
直到消費者程序執行至緩衝區有空餘,生產者才繼續執行;同理緩衝區空了,消費者要等待緩衝區中有內容才能繼續:
多程序的合作合理有序的關鍵就是要判斷那些地方停(阻塞),哪些地方走(執行)。通過走走停停實現合作有序。
然而,上文介紹的訊號,其本身的資訊量還不能解決全部問題。
如果是一個消費者兩個生產者的情況:
counter == BUFFER_SIZE
,所以進入休眠 sleep()
counter == BUFFER_SIZE
,生產者2也進入休眠wakeup();
這時喚醒了一個生產者1;補充一個 while 引起的等待 跟 鎖 之間的聯絡:
- while並不一定就是自旋鎖,自旋鎖要看while內部的程式碼怎麼寫,如果內部有排程那就是無等待鎖,如果內部沒有排程那就是忙鎖,也就是自旋鎖
- 使用while不會釋放對cpu的所有權所以叫自旋,阻塞會釋放對cpu的所有權
- 使用while不會釋放對cpu的所有權所以叫自旋,阻塞會釋放對cpu的所有權
這樣問題就是 生產者2 永遠休眠。
此處舉例也並沒有複雜到多個生產者與多個消費者,所以僅用 counter 來進行語意判斷是不夠的,我們需要再用一個量記錄有幾個生產者程序在休眠。
如果我們能夠記錄有兩個生產者程序在休眠,消費者程序執行至符合要求時,就可以根據該資訊的提醒喚醒兩個程序,而不產生遺漏。
號誌:能夠記錄一些資訊,並根據資訊決定程序的休眠和執行。
下圖PPT是在說訊號的不足,需要引入號誌。
緩衝區滿,生產者1執行,生產者1休眠,賦 sem=-1;
生產者2執行,生產者2休眠,賦 sem=-2;
消費者一次迴圈, wakeup 生產者1,賦 sem=-1;
相當於阻塞佇列,喚醒先阻塞的。這個演演算法也可以自己設計。
消費者第2次迴圈,wakeup 生產者2,賦 sem=0;
這裡 sem 就是號誌,根據號誌來進行程序的等待喚醒。很顯然較於counter(訊號)表達了更多的資訊。
消費者第3次迴圈,賦值 sem = 1;
生產者3 執行,sem > 0 ,表明緩衝區還有一個空位;不需要休眠,賦值 sem=0;
...(如果接下來還有生產者執行,則 sem <0 ,阻塞)
講道理,視訊這裡的彈幕水準相當低。邏輯很簡單,非要過度發揮!
號誌的核心理念是用量來記錄必要資訊,用訊號來管理程序是否等待/阻塞。下面是一個具體實現。
號誌的定義:semaphore結構體
程序(對應上文生產者)呼叫 P 函數判斷是否需要等待
P 的意思就是 test,等價於上面1.3 中手動賦值 sem-- 並據此判斷的過程
程序(對應上問消費者)呼叫 V 函數來喚醒程序;
V 的意思就是 increment,等價於上面1.3 中手動賦值 sem ++ 並據此判斷的過程
V 的程式碼 應當為:
V(semaphore s) {
s.value++;
if (s.value <= 0) {
wakeup(s.queue);
}
}
// 或者
V(semaphore s) {
if (s.value < 0) {
wakeup(s.queue);
}
s.value++;
}
//注意前後加減的區別,並不難理解
講到現在,我們是否能用號誌對 1.2 中分析出的 counter 的不足進行改進呢?
我們需要分析 程序 」停「 的含義,給出正確的號誌(0為臨界):
生產者當緩衝區為滿時,生產者停,哪一個值為0?
P(empty);
V(empty);
消費者當緩衝區空時,消費者停,哪一個值為0?
P(full);
V(full);
緩衝區如何定義:
可以是一個檔案:buffer.txt
由於是對檔案進行操作,同一時間只能一個程序進行操作(互斥)
具體我此前有過體會:即Linux環境下開啟某個普通檔案,再次開啟(非管理員許可權)時會顯示唯讀,如果修改會有提示。
需要定義一個新的號誌 mutex,初值為1,當 mutex = 0 表示有程序正在修改檔案/讀寫緩衝區;
這個號誌應當對生產者和消費者其同樣的效用;同時在生產者消費者程序加入:
P(mutex);//將要使用緩衝區, 1->0;
V(mutex);//將要釋放使用權, 0->1
提到了實驗五,可以開始做了,linux0.11沒有提供號誌,需要在核心中實現號誌的系統呼叫
上一部分講到 通過對號誌的存取和修改,可以實現程序的同步、有序推進,但是我們還需要對號誌進行保護。
上述邏輯看上去已經非常完美,哪裡還有問題呢?
正如 學習筆記4中多程序影象實現過程需要解決的問題中的舉例2,我們的號誌必須要 !正確!
如果號誌的含義不對,那麼對於程序同步的指揮只會一團糟。
重複一下 筆記4 中提到的那個號誌可能出錯的例子:
由於程序任務正執行時時間片用完等因素,作業系統進行了本不應該進行的 reschedule,使得多程序影象出現了錯誤的執行序列:
如下圖所示;作業系統每兩行進行一次切換的話:
empty初始值為 -1,P1執行,P1.register=-2;
而在第二行結束切出,P2執行,P2.register 在-1 的基礎上-1 == -2;而本應該累加為 -3
這就出現了錯誤
這就是一種競爭條件,這種排程會讓共用資料(empty)發生錯誤:
類似的,核心中的共用資料,如果不做保護,在多程序時間片輪轉的排程策略下,就會出現人無法控制與預料的錯誤;
這不是一種程式設計錯誤,而是共用資料沒有保護帶來的語意錯誤。
如下圖,左側第 i 次執行會發生錯誤,第 j 次執行則正確。
那就是給號誌上鎖:
這種 只能一次做完,不能做一半停下來的工作,OS中就稱為 原子操作,突出一個不可分割。
!臨界區! 是指:一次只允許一個程序進入的某程序的那一段程式碼。比如讀寫、修改號誌的那段程式碼一定是臨界區。
如何標記臨界區、實現臨界區的預期效果呢?
臨界區保護的原則:
基本原則:互斥進入;
如果一個程序在臨界區中執行,則其他程序不能進入;
有空讓進:臨界區空閒的時候,應儘快選擇出一個程序進入臨界區(不能所有人卡著門口,結果一個人都進不去)
有限等待:程序發出請求進入臨界區的等待時間必須是有限的,不能出現飢餓問題(不能總是讓別人進,我也要儘快進去)
像值日一樣,輪到程序A(turn==0
),程序A就進入臨界區,輪不到則使用 while(turn!=0);
自旋空轉;當程序A 退出臨界區,則 turn=1,輪到下一個程序使用。
下面分析一下這種方法:
輪換法的弊病主要在於 有空不讓進,就像值日,如果看見有垃圾,但不是自己沒有值日的許可權,就只能視而不見。
如何改進?還有一種直觀的想法。
下圖的例子是生活中夫妻買牛奶的情境,如果採用輪換法,如果輪換時間是10分鐘,夫妻二人都會像下圖一樣去買牛奶,導致買多。
標記法就是類似於留一個便條,3:00時丈夫發現沒有牛奶決定去買時,就留下便條,讓妻子知道自己去買了。
標記法是如何運作的?
看看程式碼:
P0 要進臨界區,就先標記自己,flag[0]=true
,然後判斷 P1 的標記,如果 P1 已經標記,就等待;
同理其他程序也是,進臨界區之前先標記自己為true,然後等待別的程序的標記變為false
下面分析這種方法:
互斥性:滿足,兩個程序不可能同時進入臨界區。
有空讓進:也不滿足,如果程序P0執行第一句, flag[0]=true,接著P1 進行第一句 flag[1]=true,再切換回P0執行while時進入自旋空轉了。同理 切回 P1的while也發生自旋空轉。誰也進不去臨界區。
相當於在某段時間內夫妻二人都看到了沒有奶,都留下了便條,又都看見彼此的便條(可能某人標記完回頭看了一眼便條,發現對方留了便條),所以都沒有去買。
有限等待:這樣也不滿足有限等待。
所以實際上,我們應當讓夫妻二人有一人更勤勞--非對稱標記。也就是在一個程序中做更多的考慮:
舉個例子,讓丈夫做更多的考慮:
當妻子看到有便條,則不必再考慮買奶;當丈夫看到有便條,等待一段時間後再檢視是否有奶,如果還是沒有,則更勤勞地去買奶。如下圖所示:
標記和輪轉的結合。
其實輪轉(值日)就是非對稱標記的,在負責的時間內考慮更多的事情。嘗試上面兩個基本考慮結合起來。
依然打標記
flag[0]=true;
flag[1]=true;
加上值日/輪轉,給程序P0的 turn 初值賦1;P1的 turn 初值賦0;
視訊彈幕的主要問題是:不理解左右兩個turn是一個變數,這麼寫並不衝突,只是在防止上文兩者卡死的情況出現
達到的效果是:
turn=1
),則P0自旋空轉,讓P1進入臨界區;下面分析這種方法:
如下圖右邊所示,使用這樣的處理(紅色):
flag[i]=true, turn=j
,如果 j 程序的flag==true
,則空轉,flag[i]=false;
就不會使 empty 出現語意錯誤。這個演演算法是正確的。
多程序情況下,採用麵包店演演算法;是 Peterson演演算法的擴充套件與改進。
標記:
每個程序進入時取一個非零序號
進入時獲得的非零序號是當前最大的序號 +1,保證有序。
程序離開時置序號位為0,
不為0的序號就算為打上標記
輪轉:在多個程序都有標記(非零)時,序號最小的進入
這保證了非對稱性,總有一個優先。
具體程式碼實現如下圖:
進入時,除了取號(最大值+1),還需要設計一個 choosing 陣列,來確保每次挑選序號的程序只有一個。如果有人正在選號:while(choosing[j]);
則等待。
退出時將 標記/序號 置為0 :num[i]=0;
下面分析一下這個演演算法:
但是這種演演算法還是太麻煩,並且不斷取號,序號會有溢位風險,我們還需要設定機制來避免溢位。
上面的演演算法是在軟體層次上做的,比較複雜,下面兩種方法都是在硬體上實現的。
臨界區:只允許一個程序進入;換言之是此時阻止其他程序被排程。
程序排程依靠的是中斷,硬體層面關了中斷,就不會有別的程序插入了,也就不會有指令交錯的問題
cli 命令就是關閉中斷的指令;
sti 命令 開啟中斷
- schedule() 是主動的程式排程,不使用中斷,而檢查程式時間片是否用完了進行的排程涉及時鐘中斷,所以這裡的關中斷是防止發生不受控制的時鐘中斷,從而引起程式排程
- 更細緻版本:
cli()
與sli()
修改的是 IF 中斷標誌位,其位於 EFLAGS 暫存器中,schedule()
切換到下一程序後馬上會執行iret
,此時會將核心棧中的EFLAGS 彈棧,再次開啟中斷
但是這種方法在多CPU、多核的情況下效果不好:
實驗五號誌的實現可以使用這種方案,工大的模擬器模擬出來的計算機是單CPU的。
再來思考一件事情:為了使得只有一個程序進入臨界區工作,我們要對於號誌、臨界區上鎖,什麼是鎖呢?
可以考慮用一個號誌/一個整數來描述鎖的狀態,就像上文1.5中的 mutex
但是這種想法顯然不成熟,因為我們使用了號誌來保護號誌
這就意味著不可行嗎?不妨考慮一下前面提到的原子性。
如果把修改 mutex 的指令做成 原子指令
即中途不可能打斷,要麼執行,要麼不執行,
那麼執行時也就自動上鎖.
用硬體原子指令修改一個整型變數,根據這個變數,再來判斷應不應當進入臨界區
下圖是一種程式碼實現:
通過 TestAndSet 原子性操作,實現 while(TestAndSet(&lock));
的一次執行,不會被打斷。
這裡彈幕的問題主要是不理解 X 和 lock 是公用的一塊記憶體,因為傳遞的引數是地址。
一句話:用臨界區保護號誌,用號誌實現程序同步。
號誌原理複雜,但程式碼實現較為精簡。
號誌的用途:
- 使用者程式可以使用號誌實現同步
- OS內部也要使用大量號誌程式碼來完成程序同步
下面還是以 生產者-消費者 為例,
在核心中使用系統呼叫,申請一個號誌
多個程序都可見,所以號誌應當在核心中。
sem.c 原始碼如下圖左下角;它規定了號誌的相關內容,比如下面程式碼中定義了20個號誌,一個name陣列存放其名字,然後是它們的 value 和 PCB 佇列。
sys_sem_open 系統呼叫 完成的工作就是:在上面name[]表中找到對應名字(如 empty) 的號誌,如果已經有這個號誌,那麼直接返回即可,沒有則建立並設初值;
申請的號誌,各程序都可見,據此決定如何進行工作。
fd 是檔案,這裡假設生產者向檔案中依次輸出 1~5 這 5 個數,每個數佔 4 個位元組,每次寫之前,都要呼叫 sem_wait(sd)
,來判斷空餘狀態;
sem_wait()
根據傳入的 sd 找到對應的 value,如果value-- < 0
,說明沒有空餘位置,應當阻塞自己,將自己放入等待佇列;
注意:這裡後--; 是先執行判斷再減1 即前提是if為true才會減value。
放入等待佇列後 進行 schedule,切換下一個程序就緒.
這裡的不完整程式碼就是實驗五需要完成的內容之一。
本部分假定與LInux 0.11 適配,使用 單CPU,可以使用2.5.5 中的硬體指令關中斷的方法。
在臨界區前後加入 cli() 和 sti() 保護號誌。
Linux 0.11 的核心中沒有上述號誌的設計,如何實現程序間同步呢?
例子:使用者程式發出read系統呼叫,在核心中最終呼叫bread,到磁碟上讀磁碟塊;
具體細節會在後面講檔案系統的時候講;
首先獲得一個空閒的記憶體bh用於緩衝資料,bh的資料型別是 buffer_head;採用DMA將磁碟塊內容逐步讀進來。
ll_rw_block(READ, bh)
,啟動磁碟讀;
wait_on_buffer(bh)
,在緩衝區bh上等待,即啟動磁碟讀之後睡眠,等待磁碟讀完由相關中斷喚醒。
bh中有類似號誌的資料:bh->b_lock
; 各個程序根據這個lock決定如何工作;
同樣需要對其進行保護,新增 cli() 和 sti().
讀取磁碟時,
lock_buffer()
中的 b_lock=1,表示已經鎖上了;那麼當前程序sleep_on(&bh->b_wait);
當讀完後通過中斷會解鎖。
這裡的機制跟前面號誌機制不同的是:
- 這裡用的是 while(1) (而不是if)
- 為什麼是while 看下面:
while(號誌) 的工作機制:
我們需要從 sleep_on 這裡看起,看看程序如何 「睡」、如何喚醒(喚醒時就能看出 while 的機制)
如下圖右側程式碼所示:
struct task_struct *tmp;
tmp=*p;
*p=current;
這是很隱蔽的佇列,就是把當前程序放入阻塞佇列
將自己的狀態改為阻塞狀態,呼叫 schedule 切換程序;後面被喚醒,再排程切換回來時,會從這裡恢復執行
f (tmp) temp->state=0;
當前程序被喚醒了,那麼佇列中的下一個程序也被喚醒;
上文所說的隱祕佇列到底長什麼樣子呢?
sleep_on 的函數引數應當是一個佇列,所以按照以前的知識,我們應當傳入隊首的指標,而這裡**p是隊首指標的指標。
tmp 是區域性變數,使其指向 task_struct.
再將 *p 指向 current,移向了當前(正要入隊)的 task_struct;按照資料結構的知識,這裡是新來的放到了隊首。
按照資料結構的頭插法,task_struct 應該有一個 next 指標,讓當前程序的 task_struct->next 指向 tmp,佇列連結就完成了
但是設計者考慮了核心的特性,這裡的tmp區域性變數,已經儲存在了核心棧中
tmp 區域性變數的作用,就是用於指向佇列中下一個程序的 task_struct,作用相當於 next 指標,如下圖中的虛線所示
解釋為什麼 tmp 可以作為 next 指標:
- 區域性變數tmp是放在棧中,程式碼在核心態執行,tmp是放在核心棧。
- 當前程序的核心棧在當前程序中能找到,切換時核心棧會跟著切換,所以切換後可以找到當前程序的核心棧,當前程序的核心棧中可以找到當前程序的tmp
- 而當前程序的tmp指向了下一個程序的PCB,下一個程序的PCB中可以找到下一個程序的核心棧,下一個程序的核心棧中可以找到也可以找到下一個程序的tmp,下一個程序的tmp指向再下一個程序的PCB,以此迴圈
如何將放入 sleep_on 的程序喚醒呢?
end_request(1)
end_request(1)
會執行 unlock_buffer();p&&*p
)將隊首的PCB的state置為0,即為就緒態,此時喚醒了隊首程序程序A被喚醒,應當從哪裡執行?
很顯然,從進入睡眠前停止的地方進行。
回看上面 sleep_on 的程式碼,state設為阻塞態,schedule到其他程序切換
所以喚醒後繼續執行 :
if(tmp){
tmp -> state = 0;
}
這兩行程式碼喚醒了隊首程序的下一個程序B;同樣這個程序B會執行相同的兩行程式碼喚醒B的下一個程序C。依次遞推,將阻塞佇列中的全部程序喚醒。
- 注意這裡的「喚醒」,只是它們能夠被排程,並不意味著已經被排程。
- 接下來會用排程演演算法去找優先順序最高的下一個程序X,排程執行。
- 該程序X執行後進入臨界區,會把lock 鎖上(=1);這時其他程序依次while,把自己休眠掉。
到這裡其實就解釋了 3.2 最開始 lock_buffer 中,為什麼使用的是 while 迴圈,因為這裡會把阻塞佇列中的所有程序喚醒,而接下來只有一個程序能夠進入臨界區並上鎖,剩下的程序需要再次sleep
而 if 只喚醒阻塞佇列中第一個。
為什麼要將所有程序都喚醒?我們前面介紹的 if 方案只需要喚醒一個。
if 方案中 排在前面的程序一定先執行,但是後來的程序優先順序可能更高;
在阻塞佇列中,你不能確定剩下的程序的優先順序是否更高,所以乾脆全部喚醒,讓 schedule() 來決定到底切換給誰
回看前面的while迴圈:
lock_buffer(buffer_head *bh){
cli();
while(bh->b_lock){
sleep_on(&bh->b_wait);
}
bh -> b_lock = 1;
sti();
}
可見這裡的while迴圈是對所有喚醒的程序進行判斷的過程,讓高優先順序的進入,其他的經過迴圈判斷後睡眠。
while(lock) 和前面 if(signal) 有什麼異同?
推薦用 if 這個直觀想法實現實驗五,再用 while 實現。
這部分是關於程序的最後一部分。死鎖也是多程序影象可能會發生的問題。
再回到 生產者-消費者 這個例子;回到 1.5 Part.
如果調換一下號誌的使用順序,如下圖,就會產生死鎖:
因為是使用者程式,使用者完全可以這樣調換(預設使用者不知情);即先申請mutex後申請empty。
執行一下:
生產者 A 程序(左)執行mutex ,原為1,賦值為0;
A程序執行 empty,0,表示緩衝區滿了,執行完後變為-1,阻塞;
消費者 B程序 執行,mutex 0變為-1,阻塞;
而 A 程序鎖在 empty,必須 B程序 執行V(empty)方可解鎖;而 V(empty) 依賴於 P(mutex);
B 的 P(mutex) 依賴於 A 的 V(mutex),而後者已經卡住了。
死鎖:多個程序由於互相等待對方持有的資源而造成誰都無法執行的情況
設想再來一個程序C,想要申請 mutex,也會死鎖,與之關聯的程序可能也會死鎖。這樣就像感染一樣,牽連到的程序越來越多。電腦變得很慢,卡死。
關於死鎖,有一個很形象的圖,下圖右側,A的前進被B阻礙,B的前進被C阻礙,C的前進被D阻礙,D的前進被A阻礙...
具體成因歸納:
再簡要一點,死鎖的必要條件:
聯絡生活,處理一個問題也就如下幾個方面:
死鎖忽略:死鎖還是一個小概率事件,假如個人PC機宕機了,則可以重啟來解決;但另外一些比較重要的場景,例如衛星上的作業系統,銀行的作業系統就肯定不能死鎖忽略了。
死鎖忽略就不在下面處理方法詳細探討的範疇內了
死鎖預防的基本想法是在程序執行前,破壞死鎖出現的必要條件,有兩種考慮:
在程序執行前,一次性申請所有需要的資源,這就不會出現佔有資源的同時還要申請其他資源。(破壞請求和保持條件)
缺點:
需要預知未來,程式設計困難;
比如有一些資源是通過if來決定是否申請的,這種方法需要編譯時列出全部需要資源,全部申請。
申請全部資源意味著一些分配到的資源很長時間後才會被使用,資源利用率低;
順序資源分配,首先給系統中的資源編號,規定每個程序必須按編號遞增的順序請求資源,同類資源(即編號相同的資源)一次申請完。(破壞迴圈等待條件)
這個不太好理解:
一個程序只有已佔有小編號的資源時,才有資格申請更大編號的資源。按此規則,已持有大編號資源的程序不可能逆向地回來申請小編號的資源,從而就不會產生迴圈等待的現象。
死鎖避免的考慮是判斷程序的此次請求是否會引起死鎖。而判斷演演算法就是 銀行家演演算法。
銀行家演演算法:尋找是否存在可完成的執行序列/安全序列 P0.P1.P2...Pn
演演算法過程:
銀行家演演算法的實現,其實就跟上面的過程相差不大,時間複雜度 T(n)=O(mn2)
銀行家演演算法只是求出了安全序列,如何為死鎖避免服務呢?
分支預測。
將申請假設為分配策略,然後用銀行家演演算法判斷是否存在安全序列。如果沒有,則拒絕申請。
既然避免死鎖的代價太大,而出現死鎖的概率又很低,聯想計組的加速大概率事件思想,我們可以在發現死鎖後再處理死鎖;這樣消耗的代價很低。
這裡具體還可以參見:死鎖的處理策略—預防死鎖、避免死鎖、檢測和解除死鎖-麵包板社群 (eet-china.com)的死鎖檢測和解除部分。
PC的通用作業系統上還是採用死鎖忽略這個樸素方法的。因為可見4.3.1~4.3.3 的三種方法 消耗都不小,而死鎖忽略的代價很小。