視頻對應內容:
9.1 背景知識
9.2 一些概念part1
9.3 一些概念part2
9.4 一些概念part3
一、同步互斥的背景
到目前爲止學習了:
- 多道程序設計(multi-programming):現代操作系統的重要特性;
- 並行很有用,因爲:
-
- 多個併發實體:CPU(s),I/O,…,用戶,…
- 進程/線程:操作系統抽象出來用於支付多道程序設計;
- CPU調度:實現多道程序設計的機制;
- 調度算法:不同的策略。
將要學習:
- 協同多道程序設計和併發問題。
進程間不獨立存在風險
獨立的線程:
- 不和其他線程共享資源或狀態;
- 確定性:輸入狀態決定結果;
- 可重現:能夠重視啓示條件,I/O;
- 調度順序不重要。
合併線程:
- 在多個線程中共享狀態;
- 不確定性;
- 不可重現。
不確定性和不可重現意味着bug可能是間歇性發生的。
進程間爲什麼合作?
進程/線程,計算機/設備需要合作。
- 優點1:共享資源
-
- 一臺電腦,多個用戶;
-
- 一個銀行存款餘額,多臺ATM機;
-
- 嵌入式系統(機器人控制,手臂和手的協調)。
- 優點2:加速
-
- I/O操作和計算可以重疊;
-
- 多處理器:將程序分成多個部分並執行。
- 優點3:模塊化
-
- 將大程序分解成小程序:以編譯爲例,gcc會調用cpp,cc1,cc2,as,Id;
-
- 使系統易於擴展。
例:併發執行產生問題
程序可以調用函數fork()來創建一個新的進程
- 操作系統需要分配一個新的並且唯一的進程ID;
- 因此在內核中,這個系統調用會運行
new_pid = next_pid++;
// next_pid爲共享的全局變量,賦id用
- 將上述類c語言翻譯成機器指令
LOAD next_pid Reg1 // 將new_next值賦給寄存器1
STORE Reg1 new_pid // 將寄存器1值存到new_pid
INC Reg1 // 寄存器1值加一
STORE Reg1 next_pid // 將寄存器1值存到next_pid
假設兩個進程併發執行:
- 如果next_pid 等於100,那麼其中一個進程得到的ID應該是100,另一個進程的ID應該是101,next_pid應該增加到102。
但是,併發進程存在如下圖的問題。
進程1執行一半,發生了上下文切換。
程序可以在上面4條語句中的任意一句進行調度,這樣就會出現不同的結果。(不好)
最終進程1、進程2的PID都是100。可能在任何語句間產生上下文切換,導致結果出現不確定性並不可重複。
因此希望:
- 無論多個線程的指令序列怎樣交替執行,程序都必須正常工作(希望和預期的結果一樣):
-
- 多線程程序具有不確定性和不可重現的特點;
-
- 不經過專門設計,調試難度很高;
- 不確定性要求並行程序的正確性:
-
- 先思考清楚問題,把程序的行爲設計清楚;
-
- 切忌急於着手編寫代碼,碰到問題再調試。
二、Race Condition(競態條件)
出現競態條件,就說明會出現不確定性。
系統缺陷:結果依賴於併發執行或者事件的順序/時間。
- 不確定性
- 不可重現
怎樣避免競態?
- 讓指令不被打斷
三、Atomic Operation(原子操作)
原子操作: 不可被打斷的操作。
原子操作是指一次不存在任何中斷或者失敗的執行。
- 該執行成功結束;
- 或者根本沒有執行;
- 並且不應該發現任何部分執行的狀態。
實際上操作往往不是原子的。
- 有些看上去是原子操作,實際上不是;
- 連x++這樣的簡單語句,實際上是由3種指令構成的;
- 有時候甚至連單條機器指令都不是原子的
-
- Pipeline, super-scalar, out-of-order, page fault
內存讀取是原子的,但未必結果確定
如上圖,在c語言層次上,保證原子操作,但是在程序設計上出現了問題。
第一種情況:
可能調度器先選擇了線程A,先打印A;
第二種情況:
可能調度器先選擇了線程B,先打印B;
第三種情況:
當線程1執行到 i=i+1時 ,調度器切換到 i=i-1,相當於 i 的值沒有產生變化,就會出現線程A在while循環裏一直循環,線程2一直在它的while循環裏一直循環,從而出現誰也不可能贏的情況。
我們需要一種機制,讓 或者A贏,或者B贏。
四、由此引出相關基本概念
Critical section(臨界區)
臨界區 是指進程中一段需要訪問共享資源(比如上面例子中的 全局變量 i ),並且當另一個進程處於相應代碼區域時,不會被執行的代碼區域。
Mutual exclusion(互斥)
當一個進程處於臨界區並訪問共享資源時,沒有其他進程會處於臨界區並且訪問任何相同的共同資源。
Dead lock(死鎖)
兩個或以上的進程,在相互等待完成特定任務,而最終沒法將自身任務進行下去。
Starvation(飢餓)
一個可執行的進程,被調度器持續忽略,以至於雖然處於可執行狀態卻不被執行。(無限期等待)
五、操作系統調度的生活類比(購買麪包)
如上圖,出現了兩次“買麪包”的操作。(買了2份麪包)
什麼是“麪包太多”問題的正確性質?
- 最多有一個人去買麪包;
- 如果需要,纔去買麪包。
在冰箱上設置一個鎖和鑰匙(lock&key)
- 去買麪包之前鎖住冰箱並且拿走鑰匙;
- 修復了“太多”的問題:要是有人想要果汁怎麼辦?
- 可以改變“鎖(lock)”的含義;
- “鎖(lock)”包含“等待(waiting)”。
Lock(鎖)
在門、抽屜等物體上加上保護性裝置,使得外人無法訪問物體內的東西,只能等待解鎖後才能訪問。
Unlock(解鎖)
打開保護性裝置,使得可以訪問之前被保護的物體類的東西。
Deadlock(死鎖)
A拿到鎖1,B拿到鎖2,A想繼續拿到鎖2後再繼續執行,B想繼續拿到鎖1後再繼續執行。導致A和B誰也無法繼續執行。
解決麪包購買辦法
如上圖,增加一種“標籤”,當成一種“鎖”,但是依然會產生問題。
執行順序如上圖,簡單實用note機制無法解決問題,甚至會使問題更糟。
爲便籤加標籤怎麼樣?表示一下誰放的標籤,如下圖。
但是如上圖,有可能出現誰都不去買麪包的情況。
可以提出如上的機制,保證程序的正常執行。
還有更好的解決方案麼
之前方案獲得的啓示
上述方法太複雜,A、B代碼不同,A等待時實際在消耗CPU的時間(叫做“忙等待busy-waiting”)。
上述方案爲每個線程保護了一段“臨界區(critical-section)”,代碼爲
if (nobread) {
buy bread;
}
如果有一個進程已經處於臨界區,則其他進程不能進入臨界區。
互斥就是確保只有一個進程在臨界區。
假設我們有一些鎖的實現
- Lock.Acquire() 在鎖被釋放前一直等待,然後獲得鎖;
- Lock.Release() 解鎖並喚醒任何等待中的進程;
- 這些一定是原子操作:如果兩個線程都在等待同一個鎖,並且同時發現鎖被釋放了,那麼只有一個能夠獲得鎖。
- 由此,麪包問題得到解決:
breadlock.Acquire(); // 進入臨界區
if (nobread) {
buy bread;
}
breadlock.Release(); // 退出臨界區