鎖 算法
xv6 運行在多處理器上,即計算機上有多個單獨執行代碼的 CPU。這些 CPU 操做同一片地址空間並分享其中的數據結構;xv6 必須創建一種合做機制防止它們互相干擾。即便是在單個處理器上,xv6 也必須使用某些機制來防止中斷處理程序與非中斷代碼之間互相干擾。xv6 爲這兩種狀況使用了相同的低層概念:鎖。鎖提供了互斥功能,保證某個時間點只有一個 CPU 能持有鎖。若是 xv6 只能在持有特定的鎖時才能使用數據結構,那麼就能保證同一時間只有一個 CPU 能使用這個數據結構。這樣,咱們就稱這個鎖保護了數據結構。 編程
本章的其他部分將解釋爲什麼 xv6 須要鎖,以及 xv6 是如何實現、使用鎖的。咱們須要重點注意的是在讀代碼時,你必定要問本身另外一個處理器的存在是否會讓這行代碼沒法達到指望的運行結果(由於另外一個處理器也可能正在運行該行代碼,或者另外一行修改這個共享變量的代碼),還要考慮若是這裏執行一箇中斷處理程序,又會發生什麼狀況。與此同時,必定要記住一行 C 代碼可能由多條機器指令組成,而另外一個處理器或者中斷可能在這些指令中間影響之。你不能假設這些代碼是順序執行的,也不能假設一個 C 指令是以原子操做執行的。併發使得考慮代碼的正確性變得困難。 緩存
競爭條件 數據結構
下面舉一個例子說明爲何咱們須要鎖,考慮幾個共享磁盤的處理器,例如 xv6 中的 IDE 磁盤。磁盤驅動會維護一個未完成磁盤請求的鏈表(3821),這樣處理器可能會併發地向鏈表中加入新的請求(3954)。若是沒有併發請求,你能夠這樣實現: 併發
struct list{ 數據結構和算法
int data; ide
struct list *next; 模塊化
}; 函數
struct list *list = 0; 工具
void
insert(int data)
{
struct list *l;
l = malloc(sizeof *l);
l->data = data;
l->next = list;
list = l;
}
證實其正確性是數據結構與算法課中的練習。即便能夠證實其正確性,實際上這種實現也是錯誤的,至少不能在多處理器上運行。若是兩個不一樣的 CPU 同時執行 insert,可能會二者都運行到15行,而都未開始運行16行(見圖表4-1)。這樣的話,就會出現兩個鏈表節點,而且 next 都被設置爲 list。當二者都運行了16行的賦值後,後運行的一個會覆蓋前運行的一個;因而先賦值的一個進程中添加的節點就丟失了。這種問題就被稱爲競爭條件。競爭問題在於它們的結果由 CPU 執行時間以及其內存操做的前後決定的,而且這個問題難以重現。例如,在調試 insert 時加入輸出語句,就足以改變執行時間,使得競爭消失。
一般咱們使用鎖來避免競爭。鎖提供了互斥,因此一時間只有一個 CPU 能夠運行 insert;這就讓上面的狀況不可能發生。只需加入幾行代碼(未標號的)就能修改成正確的帶鎖代碼:
struct list *list = 0;
struct lock listlock;
void
insert(int data)
{
struct list *l;
acquire(&listlock);
l = malloc(sizeof *l);
l->data = data;
l->next = list;
list = l;
release(&listlock);
}
當咱們說鎖保護了數據時,是指鎖保護了數據對應的一組不變量(invariant)。不變量是數據結構在操做中維護的一些狀態。通常來講,操做的正確行爲會取決於不變量是否爲真。操做是有可能暫時破壞不變量的,但在結束操做以前必須恢復不變量。例如,在鏈表中,不變量即 list 指向鏈表中第一個節點,而每一個節點的 next 指向下一個節點。insert 的實現就暫時破壞了不變量:第13行創建一個新鏈表元素 l,並認爲 l 是鏈表中的第一個節點,但 l 的 next 尚未指向下一個節點(在第15行恢復了該不變量),而 list 也尚未指向 l(在第16行恢復了該不變量)。上面所說的競爭之因此發生,是由於可能有另外一個 CPU 在這些不變量(暫時)沒有被恢復的時刻運行了依賴於不變量的代碼。恰當地使用鎖就能保證一時間只有一個 CPU 操做數據結構,這樣在不變量不正確時就不可能有其餘 CPU 對數據結構進行操做了。
xv6 用結構體 struct spinlock(1401)。結構體中的臨界區用 locked 表示。這是一個字,在鎖能夠被得到時值爲0,而當鎖已經被得到時值爲非零。邏輯上講,xv6 應該用下面的代碼來得到鎖:
void
acquire(struct spinlock *lk)
{
for(;;) {
if(!lk->locked) {
lk->locked = 1;
break;
}
}
}
然而這段代碼在現代處理器上並不能保證互斥。有可能兩個(或多個)CPU 接連執行到第25行,發現 lk->locked 爲0,而後都執行第2六、27行拿到了鎖。這時,兩個不一樣的 CPU 持有鎖,違反了互斥。這段代碼不只不能幫咱們避免競爭條件,它自己就存在競爭。這裏的問題主要出在第2五、26行是分開執行的。若要保證代碼的正確,就必須讓第2五、26行是原子操做的。
爲了讓這兩行變爲原子操做, xv6 採用了386硬件上的一條特殊指令 xchg(0569)。在這個原子操做中,xchg 交換了內存中的一個字和一個寄存器的值。函數 acquire(1474)在循環中反覆使用 xchg;每一次都讀取 lk->locked 而後設置爲1(1483)。若是鎖已經被持有了,lk->locked 就已經爲1了,故 xchg 會返回1而後繼續循環。若是 xchg 返回0,可是 acquire 已經成功得到了鎖,即 locked 已經從0變爲了1,這時循環能夠中止了。一旦鎖被得到了,acquire 會記錄得到鎖的 CPU 和棧信息,以便調試。當某個進程得到了鎖卻沒有釋放時,這些信息能夠幫咱們找到問題所在。固然這些信息也被鎖保護着,只有在持有鎖時才能修改。
函數 release(1502)則作了相反的事:清除調試信息並釋放鎖。
系統設計力求簡單、模塊化的抽象:最好是讓調用者不須要了解被調者的具體實現。鎖的機制則和這種模塊化理念有所衝突。例如,當 CPU 持有鎖時,它不能再調用另外一個試圖得到該鎖的函數 f:由於調用者在 f 返回以前沒法釋放鎖,若是 f 試圖得到這個鎖,就會形成死鎖。
如今尚未一種透明方案可讓調用者和被調者能夠互相隱藏所使用的鎖。咱們可使用遞歸鎖(recursive locks)使得被調者可以在此得到調用者已經持有的鎖,這種方案雖然是透明通用的,可是十分繁複。還有一個問題就是這種方案不能用來保護不變量。在 insert 調用 acquire(&listlock)後,它就能夠假設沒有其餘函數會持有這個鎖,也沒有其餘函數能夠操做鏈表,最重要的是,能夠保持鏈表相關的全部不變量。 在使用遞歸鎖的系統中,insert 能夠假設在它以後 acquire 不會再被調用:acquire 之因此能成功,只多是 insert 的調用者持有鎖,並正在修改鏈表數據。這時的不變量有可能被破壞了,鏈表也就再也不保護其不變量了。鎖不只要讓不一樣的 CPU 不會互相干擾,還須要讓調用者與被調者不會互相干擾;而遞歸鎖就沒法保證這一點。
因爲沒有理想、透明的解決方法,咱們不得不在函數的使用規範中加入鎖。編程者必須保證一個函數不會在持有鎖時調用另外一個須要得到該鎖的函數 f。就這樣,鎖也成爲了咱們的抽象中的一員。
xv6 很是謹慎地使用鎖來避免競爭條件。一個簡單的例子就是 IDE 驅動(3800)。就像本章開篇提到的同樣,iderw(3954)有一個磁盤請求的隊列,處理器可能會併發地向隊列中加入新請求(3969)。爲了保護鏈表以及驅動中的其餘不變量,iderw 會請求得到鎖 idelock(3965)並在函數末尾釋放鎖。練習1中研究瞭如何經過把 acquire 移動到隊列操做以後來觸發競爭條件。咱們頗有必要作一個這些練習,它們會讓咱們瞭解到想要觸發競爭並不容易,也就是說很難找到競爭條件。並非說 xv6 的代碼中就沒有競爭。
使用鎖的一個難點在於要決定使用多少個鎖,以及每一個鎖保護哪些數據、不變量。不過有幾個基本原則。首先,當一個 CPU 正在寫一個變量,而同時另外一個 CPU 可能讀/寫該變量時,須要用鎖防止兩個操做重疊。第二,當用鎖保護不變量時,若是不變量涉及到多個數據結構,一般每一個數據結構都須要用一個單獨的鎖保護起來,這樣才能維持不變量。
上面只說了須要鎖的原則,那麼何時不須要鎖呢?因爲鎖會下降併發度,因此咱們必定要避免過分使用鎖。當效率不是很重要的時候,徹底可使用單處理器計算機,這樣就徹底不用考慮鎖了。當咱們要保護內核的數據結構時,使用一個內核鎖仍是值得的,當進入內核時必須持有該鎖,而退出內核時就釋放該鎖。許多單處理器操做系統就用這種方法運行在了多處理器上,有時這種方法被稱爲"內核巨鎖(giant kernel lock)",但使用這種方法就犧牲了併發性:即一時間只有一個 CPU 能夠運行在內核上。若是咱們想要依靠內核作大量的計算,那麼使用一組更爲精細的鎖來讓內核能夠在多個 CPU 上輪流運行會更有效率。
最後,對於鎖的粒度選擇是並行編程中的一個重要問題。xv6 只使用了幾個簡單的鎖;例如,xv6 中使用了一個單獨的鎖來保護進程表及其不變量,咱們將在第5章討論這個問題。更精細的作法是給進程表中的每個條目都上一個鎖,這樣在不一樣條目上運行的線程也能並行了。可是在進程表中維護那麼多個不變量就必須使用多個鎖,這就讓狀況變得很複雜了。不過 xv6 中的例子已經足夠讓咱們瞭解如何使用鎖了。
若是一段代碼要使用多個鎖,那麼必需要注意代碼每次運行都要以相同的順序得到鎖,不然就有死鎖的危險。假設某段代碼的兩條執行路徑都須要鎖 A 和 B,但路徑1得到鎖的順序是 A、B,而路徑2得到鎖的順序是 B、A。這樣就有能路徑1得到了鎖 A,而在它繼續得到鎖 B 以前,路徑2得到了鎖 B,這樣就死鎖了。這時兩個路徑都沒法繼續執行下去了,由於這時路徑1須要鎖 B,但鎖 B已經在路徑2手中了,反之路徑2也得不到鎖 A。爲了不這種死鎖,全部的代碼路徑得到鎖的順序必須相同。避免死鎖也是咱們把鎖做爲函數使用規範的一部分的緣由:調用者必須以固定順序調用函數,這樣函數才能以相同順序得到鎖。
因爲 xv6 自己比較簡單,它使用的鎖也很簡單,因此 xv6 幾乎沒有鎖的使用鏈。最長的鎖鏈也就只有兩個鎖。例如,ideintr 在調用 wakeup 時持有 ide 鎖,而 wakeup 又須要得到 ptable.lock。還有不少使用 sleep/wakeup 的例子,它們要考慮鎖的順序是由於 sleep 和 wakeup 中有比較複雜的不變量,咱們會在第5章討論。文件系統中有不少兩個鎖的例子,例如文件系統在刪除一個文件時必須持有該文件及其所在文件夾的鎖。xv6 老是首先得到文件夾的鎖,而後再得到文件的鎖。
xv6 用鎖來防止中斷處理程序與另外一個 CPU 上運行非中斷代碼使用同一個數據。例如,時鐘中斷(3114)會增長 ticks 但可能有另外一個 CPU 正在運行 sys_sleep,其中也要使用該變量(3473)。鎖 tickslock 就可以爲該變量實現同步。
即便在單個處理器上,中斷也可能致使併發:在容許中斷時,內核代碼可能在任什麼時候候停下來,而後執行中斷處理程序。假設 iderw 持有 idelock,而後中斷髮生,開始運行 ideintr。ideintr 會試圖得到 idelock,但卻發現 idelock 已經被得到了,因而就等着它被釋放。這樣,idelock 就永遠不會被釋放了,只有 iderw 能釋放它,但又只有讓 ideintr 返回 iderw 才能繼續運行,這樣處理器、整個系統都會死鎖。
爲了不這種狀況,當中斷處理程序會使用某個鎖時,處理器就不能在容許中斷髮生時持有鎖。xv6 作得更決絕:容許中斷時不能持有任何鎖。它使用 pushcli(1555)和 popcli(1566)來屏蔽中斷(cli 是 x86 屏蔽中斷的指令)。acquire 在嘗試得到鎖以前調用了 pushcli(1476),release 則在釋放鎖後調用了 popcli(1521)。pushcli(1555)和 popcli(1566)不只包裝了 cli 和 sti,它們還作了計數工做,這樣就須要調用兩次 popcli 來抵消兩次 pushcli;這樣,若是代碼中得到了兩個鎖,那麼只有當兩個鎖都被釋放後中斷纔會被容許。
acquire 必定要在可能得到鎖的 xchg 以前調用 pushcli(1483)。若是二者顛倒了,就可能在幾個時鐘週期裏,中斷仍被容許,而鎖也被得到了,若是此時不幸地發生了中斷,系統就會死鎖。相似的,release 也必定要在釋放鎖的 xchg 以後調用 popcli(1483)。
另外,中斷處理程序和非中斷代碼對彼此的影響也讓咱們看到了遞歸鎖的缺陷。若是 xv6 使用了遞歸鎖(即若是 CPU 得到了某個鎖,那麼同一 CPU 上能夠再次得到該鎖),那麼中斷處理程序就可能在非中斷代碼正運行到臨界區時運行,這樣就很是混亂了。當中斷處理程序運行時,它所依賴的不變量可能暫時被破壞了。例如,ideintr(3902)會假設未處理請求鏈表是無缺的。若 xv6 使用了遞歸鎖,ideintr 就可能在 iderw 正在修改鏈表,這樣 ideintr 就會使用這個不正確的鏈表。
在本章中,咱們都假設了處理器會按照代碼中的順序執行指令。可是許多處理器會經過指令亂序來提升性能。若是一個指令須要多個週期完成,處理器會但願這條指令儘早開始執行,這樣就能與其餘指令交疊,避免延誤過久。例如,處理器可能會發現一系列 A、B 指令序列彼此並無關係,在 A 以前執行 B 可讓處理器執行完 A 時也執行完 B。可是併發可能會讓這種亂序行爲暴露到軟件中,致使不正確的結果。
例如,考慮在 release 中把0賦給 lk->locked 而不是使用 xchg。那麼結果就不明確了,由於咱們難以保證這裏的執行順序。比方說若是 lk->locked=0 在亂序後被放到了 popcli 以後,可能在鎖被釋放以前,另外一個線程中就容許中斷了,acquire 就會被打斷。爲了不亂序可能形成的不肯定性,xv6 決定使用穩妥的 xchg,這樣就能保證不出現亂序了。
因爲使用了鎖機制的程序編寫仍然是個巨大的挑戰,因此併發和並行至今仍是研究的熱點。咱們最好以鎖爲基礎來構建高級的同步隊列,雖然 xv6 並無這麼作。若是你使用鎖進行編程,那麼你最好用一些工具來肯定競爭條件,不然很容易遺漏掉某些須要鎖保護的不變量。
用戶級程序也須要鎖,但 xv6 的程序只有一個運行線程,進程間也不會共享內存,因此就不須要鎖了。
固然咱們也有可能用非原子性的操做來實現鎖,只不過那很是複雜,並且大多數的操做系統都是使用了原子操做的。
原子操做的代價也不小。若是一個處理器在它的本地緩存中有一個鎖,而這時另外一個處理器必須得到該鎖,那麼更新緩存中該行的原子操做就必須把這行從一個處理器的緩存中移到另外一個處理器的緩存中,同時還可能須要讓這行緩存的其餘備份失效。從其餘處理器的緩存中取得一行數據要比從本地緩存中取代價大得多。
爲了減小使用鎖所產生的代價,許多操做系統使用了鎖無關的數據結構和算法,並在這些算法中儘可能避免原子操做。例如,對於本章開篇提到的鏈表,咱們在查詢時不須要得到鎖,而後用一個原子操做來添加元素。