1. 程式人生 > >XV6學習(10)鎖

XV6學習(10)鎖

在包括XV6的絕大部分作業系統都是多個任務交錯執行的。交錯的一個原因是多核硬體:多核計算機的多個CPU核心獨立執行計算,如XV6的RISC-V處理器。多個CPU核心共享實體記憶體,XV6利用這種共享來維護所有核心都會讀寫的資料結構。而這種共享會導致一個CPU在讀取某資料結構時,可能有另一個CPU正在對此資料進行更新;或者多個CPU同時更新同一個資料。如果不對這種並行訪問進行小心的設計,就可能會導致錯誤的結果產生或者損壞資料。即使是單核處理器,核心也可能會在多個執行緒之間進行切換,導致它們交錯執行。最後,如果中斷在錯誤的時間發生,裝置中斷處理程式也可能會對資料造成損壞。併發一詞就是指由於多核並行、執行緒切換或中斷,導致多個指令流交錯執行。 核心中充滿了被併發訪問的資料。如兩個CPU可以同時呼叫`kalloc`,從而同時從空閒連結串列的中彈出空閒頁。核心設計者必須允許大量併發,因為併發可以提高系統的效能和響應速度。然而,系統設計者需要耗費很多精力來保證併發的正確性。有很多種方法可以寫出正確的程式碼,其中有一些比其他更容易推理。針對併發的正確性以及支援它們的抽象的策略被稱為併發控制技術。 XV6基於不用的情況使用了多種併發控制技術,並且還有更多技術可以使用。其中一個廣泛使用的技術就是鎖。鎖可以提供互斥性,保證同一時間只有一個CPU能夠持有鎖。如果程式設計師將共享資料與鎖進行關聯,在程式碼使用這些資料時就必須持有相應的鎖,這樣就可以保證同一時間只有一個CPU能使用該資料。儘管鎖是一種容易理解的併發控制機制,但鎖的缺點是其會降低效能,因為鎖將併發操作序列化。 ## 競爭條件 假如兩個程序在不同的CPU上同時呼叫`wait`函式釋放子程序記憶體,導致在每個CPU上,核心都會呼叫`kfree`來釋放子程序的頁面。核心維護了一個空閒頁面連結串列,`kalloc`會pop一個頁面,而`kfree`會push一個頁面。為了最佳的效能,我們希望兩個父程序的`kfree`能夠並行執行而不需要等待另一個,但是在XV6的`kfree`實現中是不正確的。 一種競爭條件是指一個記憶體位置被併發訪問,並且至少一個訪問是寫入。競爭通常是bug的訊號,要麼更新發生丟失,要麼讀取到不完整的資料更新。競爭的結果取決於兩個CPU執行的實際時間以及對記憶體的操作如何被記憶體系統排序,這些會使得競爭導致的bug難以復現和除錯。例如插入print語句來除錯可能會改變執行的時間從而使得競爭消失。 ```c struct element *list = 0; struct lock listlock; void push(int data) { struct element *l; l = malloc(sizeof *l); l->data = data; acquire(&listlock); l->next = list; list = l; release(&listlock); } ``` 當我們說鎖保護了資料,實際的意思是鎖保護了應用在資料上的一系列不變性。一個操作的正確性取決於操作開始時的不變性是否正確。操作可能會暫時違反不變性,但必須在在操作結束前恢復其不變性。例如對於連結串列,不變性時指頭指標指向第一個元素,且每一個元素的next域指向下一個元素。`push`操作的`l->next = list`會暫時破壞其不變性,使得頭指標並不是指向第一個元素。競爭條件發生因為在另一個CPU上的操作依賴於不變性,而這被暫時破壞了。鎖的使用可以保證在資料結構上一次只有一個CPU在臨界區執行,因此不會有CPU在不變性被破壞時執行操作。 可以認為鎖將併發的臨界區序列化使其一次只能執行一個,從而保護了不變性。也可以認為被鎖保護的臨界區對於其他臨界區來說是原子性的,因此每一個都只能看見一系列先前臨界區的完整修改,而永遠不會看見部分修改。 儘管鎖的正確使用可以使錯誤程式碼變正確,但鎖也限制了效能。例如當兩個程序同時呼叫`kfree`,鎖會將兩個呼叫序列化,將它們在不同的CPU上執行就不會獲得收益。在核心設計中一個大的挑戰就是避免鎖爭用。XV6在這方面做的很少,但是複雜的核心會通過特殊的方法組織資料結構和演算法來避免鎖爭用。例如核心會為每個核心維護一個獨立的空閒記憶體連結串列,只有噹噹前CPU的連結串列為空時才會去請求其他CPU的連結串列來獲取空閒記憶體。而其他的爭用可能需要更加複雜的設計。 鎖的位置同樣對效能影響很大。例如在`push`中可以將`acquire`放在更加前面,但這就會降低效能,因為`malloc`的呼叫也被序列化。 ## 程式碼:鎖 XV6中有兩種鎖:自旋鎖和睡眠鎖。自旋鎖定義為`struct spinlock`,最重要的域就是`locked`,1代表被持有而0代表未被持有。理論上xv6可以通過下列程式碼來上鎖: ```c void acquire(struct spinlock *lk) // does not work! { for(;;) { if(lk->locked == 0) { lk->locked = 1; break; } } } ``` 然而不幸地是在多處理器上這種實現不會達到互斥。當兩個CPU同時對`locked`進行讀取並且結果為0時,它們都會獲得這個鎖,而這就會違反互斥的性質。因此我們需要第5第6行的執行原子化。 由於鎖的廣泛使用,多核處理器通常會提供該原子指令。在RISC-V中為`amoswap r, a`,該指令會交換`r`和`a`的值。該指令是原子性的,其會通過特殊硬體來防止其他CPU在讀寫時使用該記憶體地址。 XV6的`acquire`使用可移植的C庫函式`__sync_lock_test_and_set`,而其在底層是使用`amoswap`實現的。函式返回值是`locked`的舊值。`acquire`函式在迴圈中不停(自旋)呼叫`swap`直到其獲得鎖。每次迴圈將1`swap`到`locked`中,並判斷舊值是否為0,為0就說明獲取到了鎖,同時`swap`也將`locked`設定為了1。如果舊值為1,說明其他CPU持有鎖,而`swap`也並沒有改變`locked`的值。 當獲取到了鎖,`acquire`就會為了除錯而記錄獲取鎖的CPU。`lk->cpu`域是被鎖保護的並且必須在獲取鎖後才能被改變。 `release`函式則與`acquire`相反;該函式清空`cpu`域並釋放鎖。理論上釋放只需要將`locked`域置0。而C語言標準執行編譯器使用多儲存指令來實現賦值,因此一條賦值語句可能不是原子的。因此,`release`使用C庫函式`__sync_lock_release`來進行原子性賦值。該函式底層也是通過`amoswap`指令實現。 ## 程式碼:使用鎖 XV6在許多地方都使用鎖來避免競爭條件。`kalloc`和`kfree`是一個很好的例子。使用鎖的一個難點是決定要使用多少鎖以及每個鎖要保護哪些資料和不變性。這裡有幾個基本原則:首先當一個變數在被一個CPU寫入時,有其他CPU可以對其讀寫時,應該使用鎖來避免兩個操作重疊;第二,記住鎖所保護的不變性,如果一個不變性涉及多個記憶體位置,則所有的位置都需要被一個單獨的鎖來保護,從而保證不變性。 上述只說了鎖什麼時候是必要的而沒有鎖什麼時候是不必須的,而減少鎖的數量對效率來說是很重要的,因為鎖減少了並行。如果並行不是必須的,那麼可以只使用一個執行緒從而不必考慮鎖的問題。簡單核心可以在多處理器上只使用一個鎖,當進入核心態時獲取鎖,離開時釋放鎖(儘管系統呼叫如管道的讀和`wait`將會產生問題)。很多單處理器系統使用這種方法來在多處理器上執行,有的時候被成為“大核心鎖”。但是,這種方法破壞了並行性:一次只有一個CPU可以在核心中執行。如果核心要進行任何重計算任務,使用一系列的鎖會更加高效,核心可以同時在多個CPU上執行。 作為粗粒度鎖的例子,XV6的`kalloc.c`分配器只有一個被一個鎖保護的空閒連結串列。如果多個程序在不同CPU上同時嘗試申請頁面,那麼每一個都需要在`acquire`中自旋等待。自旋是在做無用功從而降低了效能。如果鎖的爭用浪費了大量時間,那麼可能就要通過改變分配器的設計來提高效能,使用多個空閒連結串列,每個連結串列單獨持有鎖,從而允許真正的並行分配。 作為細粒度鎖的例子,XV6對於每個檔案都有一個單獨的鎖,因此操作不同檔案的程序可以無需等待其他的檔案的鎖。檔案鎖模式的粒度可以變得更加的細,如果希望程序同時寫相同檔案的不同區域。總而言之,鎖的粒度需要由效能度量以及鎖的複雜性的考慮來決定。 XV6中使用的鎖如下表所示: | lock | Description | |------------------|-----------------------------------------------------------------| | bcache.lock | Protects allocation of block buffer cache entries | | cons.lock | Serializes access to console hardware, avoids intermixed output | | ftable.lock | Serializes allocation of a struct file in file table | | icache.lock | Protects allocation of inode cache entries | | vdisk_lock | Serializes access to disk hardware and queue of DMA descriptors | | kmem.lock | Serializes allocation of memory | | log.lock | Serializes operations on the transaction log | | pipe’s pi->lock | Serializes operations on each pipe | | pid_lock | Serializes increments of next_pid | | proc’s p->lock | Serializes changes to process’s state | | tickslock | Serializes operations on the ticks counter | | inode’s ip->lock | Serializes operations on each inode and its content | | buf’s b->lock | Serializes operations on each block buffer | ## 死鎖和鎖順序 如果程式碼在核心中需要同時持有多個鎖,那麼有一點很重要,就是獲取鎖的順序要相同。如果順序不同,那麼就會有死鎖的風險。假設XV6中兩個程式碼路徑要獲取鎖A和B,但是路徑1先獲取A再獲取B,而另一條路徑先獲取B再獲取A。假設執行緒T1執行程式碼路徑1並獲取了鎖A,而執行緒T2執行路徑2並獲取了鎖B。那麼接下來T1會嘗試獲取獲取鎖B而T2會嘗試獲取鎖A。兩個獲取就肯定都會被阻塞,因為另一個執行緒持有需要的鎖,並且不會釋放直到它的`acquire`返回。為了避免這種死鎖,所有程式碼路徑必須以同樣的順序獲取鎖。對全域性鎖獲取順序的需要意味著鎖實際上是每個函式規範的一部分:呼叫者必須以某種方式呼叫函式,使鎖按約定的順序被獲取。 XV6有很多長度為2的涉及每個程序的鎖的鎖順序鏈,因為在路徑上`sleep`函式會工作。例如`consoleintr`是處理輸入字元的中斷程式。當一個新行到達,任何等待控制檯輸入的程序就會被喚醒。當呼叫`wakeup`時`consoleintr`持有`cons.lock`,而`wakeup`又會獲取等待程序的鎖來喚醒它。因此,避免全域性死鎖的鎖順序包含`cons.lock`鎖必須在每個程序鎖之前被獲取的規則。檔案系統程式碼包含XV6的最長的鎖鏈。例如建立檔案需要同時獲取目錄上的鎖,新檔案的inode的鎖,磁碟塊緩衝區的鎖,磁碟驅動的`vdisk_lock`以及呼叫程序的鎖。為了避免死鎖,檔案系統程式碼通常要求以一定順序來獲取鎖。 遵守避免全域性死鎖的順序可能會十分困難。有時候鎖順序會與程式結構的邏輯衝突,如模組M1呼叫模組M2,但是鎖順序要求M2的鎖在M1之前獲取。有時候也無法預知需要的鎖,可能獲取一個鎖之後才能直到下一個鎖是什麼。這種情況出現於在檔案系統中根據路徑名連續查詢模組以及`wait`和`exit`函式在程序表中查詢子程序中。最後,死鎖的危險通常會限制鎖策略能夠使用多細粒度的鎖,越多的鎖就意味著越多死鎖的可能性。在核心實現中,避免死鎖通常是很重要的一部分。 ## 鎖和中斷處理程式 XV6中有一些自旋鎖保護了會同時被執行緒和中斷處理程式使用的資料。例如`clockintr`定時器中斷處理程式可能會增加`ticks`當一個核心執行緒同時在`sys_sleep`函式中讀取`ticks`。`tickslock`鎖會將兩個訪問序列化。 自旋鎖和中斷的互動會帶來潛在的風險。假設`sys_sleep`持有鎖,並且CPU產生了一個定時器中斷。`clockintr`將會嘗試申請鎖,發現鎖被持有,於是等待其被釋放。在這種情況下,鎖將永遠不會被釋放:只有`sys_sleep`會釋放鎖,但是`sys_sleep`不會釋放鎖直到`clockintr`返回。因此CPU會進入死鎖狀態,並且其他需要該鎖的程式碼都會被凍結。 為了避免這種情況,如果一個自旋鎖在中斷處理程式中被使用,CPU必須在中斷允許時不會持有該鎖。XV6更加保守:當CPU申請任何鎖時,XV6總是會在該CPU上關閉中斷。中斷可能仍在其他CPU上發生,因此中斷的`acquire`可以等待一個執行緒釋放自旋鎖,只要不在同一個CPU上。 XV6會重新允許中斷當一個CPU不再持有自旋鎖,必須通過一些小的記錄來處理巢狀臨界區。`acquire`呼叫`push_off`而`release`呼叫`pop_off`來追蹤當前CPU的巢狀的層次。當計數器為0時,`pop_off`恢復最外層臨界區開始前的中斷允許狀態。`intr_off`和`intr_on`函式分別執行RISC-V的關和開中斷指令。 在`acquire`設定`lk->locked`之前呼叫`push_off`是非常重要的。如果兩者順序交換,就會有一個小視窗,此時鎖被獲取而中斷是允許的,如果不幸此時發生了中斷,就可能會使系統死鎖。相同的,`release`釋放鎖之後再呼叫`pop_off`也是很重要的。 ## 指令和記憶體順序 自然地會認為程式以原始碼語句出現的順序來執行程式。但在很多編譯器和CPU中,程式碼是亂序執行的從而來獲得更高的效能。如果一條指令需要很多個週期來完成,CPU可能會更早地發射指令,使其與其他指令重疊,從而避免CPU停頓。例如CPU可能注意到一串指令序列A和B不依賴彼此,CPU就可能會先執行指令B,因為其輸入比A的輸入更早就緒或者為了將A和B的執行重疊起來。編譯器也可能會進行類似的重排,通過先於原始碼中之前的語句的指令發射一條語句的指令。 編譯器和CPU允許通過不會改變一串程式碼執行結果的規則來重排語句。然而,這些允許重排的規則會改變併發程式碼的結果,並且很容易在多處理器上導致錯誤的行為。CPU的排序規則被成為記憶體模型。 例如`push`的程式碼,如果CPU將第4行對應的`store`指令移動到`release`之後就會引起災難: ```c l = malloc(sizeof *l); l->data = data; acquire(&listlock); l->next = list; list = l; release(&listlock); ``` 如果這種重排發生了,這裡就會有一個視窗使得其他CPU可以獲取鎖並且更新`list`,但是看見的並不是初始的`list->next`。 為了告訴硬體和編譯器不要進行這種重拍,XV6在`acquire`和`release`中使用`__sync_synchronize()`。`__sync_synchronize()`是一個記憶體屏障:它告訴編譯器和CPU不要越過屏障重排load和store指令。XV6`acquire`和`release`的屏障在幾乎所有會出現問題的情況下強制保持順序,後面的章節會討論一些例外。 ## 睡眠鎖 有時候XV6需要長時間持有一個鎖。例如檔案系統在磁碟上讀寫檔案內容時持有一個檔案鎖,而這些磁碟操作會耗費數十毫秒。當其他程序要獲取鎖時,長時間持有自旋鎖會引起很大的浪費,因為申請的程序會長時間浪費CPU在自旋上。自旋鎖的另一個缺點就是當其保持自旋鎖時程序不會讓出CPU,我們希望當持有鎖的程序在等待磁碟時其他程序能使用CPU。當持有自旋鎖時讓出CPU是非法的,因為如果第二個執行緒嘗試獲取自旋鎖時,這可能會導致死鎖;因為`acquire`不會讓出CPU,第二個程序的自旋可能會阻止第一個執行緒執行和釋放鎖。當持有鎖時讓出CPU同樣違反了當自旋鎖被持有時中斷必須關閉的需求。因此我們需要一種當`acquire`等待時能讓出CPU以及允許持有鎖時讓出(和中斷)的鎖。 XV6提供了睡眠鎖這種鎖、`acquiresleep`使用下一章講到的技術使其在等待時會讓出CPU。在高層來看,睡眠鎖有一個被自旋鎖保護的`locked`域,而`acquiresleep`呼叫`sleep`會原子地讓出CPU並釋放自旋鎖。這使得其他執行緒可以在`acquiresleep`等待時執行。 因為睡眠鎖使中斷允許,因此它們不能被用在中斷處理程式中。因為`acquiresleep`會讓出CPU,睡眠鎖不能在自旋鎖保護的臨界區內使用(儘管自旋鎖可以在睡眠鎖保護的臨界區內使用)。 自旋鎖最好在短臨界區使用,因為等待它們會浪費CPU時間;睡眠鎖在長時間操作上表現更好。 ## 真實作業系統 儘管併發原語和並行被研究了很多年,鎖程式設計仍然是十分有挑戰性的。最好是將鎖隱藏在更高階的結構如同步佇列中,儘管XV6沒有這樣做。如果你使用鎖來程式設計,最好使用工具來標識臨界區,因為很容易忽略需要獲得鎖的不變性。 大部分作業系統支援POSIX threads(pthreads),這允許使用者在不同CPU上併發執行多個執行緒。Pthreads支援使用者級別鎖和屏障等。Pthread的支援需要得到作業系統的支援。例如當一個pthread在系統呼叫中阻塞時,同一個程序的其他pthread應該能夠在這個CPU上執行。另一個例子是當一個pthread改變了程序的地址空間(如記憶體對映),核心應該安排其他執行相同程序的執行緒的CPU更新頁表硬體來對映地址空間上的修改。 是有可能不使用原子指令來實現鎖的,但是其代價非常高昂,並且大部分作業系統都是使用原子指令。 如果很多CPU嘗試同時獲取一個鎖時,鎖的代價是非常高昂的。如果一個CPU在本地cache中快取了一個鎖,其他CPU必須獲取這個鎖,之後原子指令會更新cache行,持有鎖必須將cache行從一個CPU的cache複製到其他CPU的cache,並且可能需要使cache行的其他所有內容失效。從另一個CPU的cache中獲取cache行的代價可能比從本地cache中獲取行要高几個數量級。 為了避免鎖相關的高昂代價,許多作業系統使用無鎖資料結構和演算法。如上文中提到的多個空閒記憶體連結串列。然而無鎖程式設計比鎖程式設計要更加複雜;例如必須考慮指令和記憶體重排。鎖程式設計已經很困難了,因此XV6避免了無鎖程式設計帶來的額外復