原文連接:https://lwn.net/Articles/736534/
做者: Neil Brown
注意:方括弧內的文字是筆者添加的node
操做系統好比Linux關鍵的價值之一,就是爲具體的設備提供了抽象接口。雖而後來出現了各類其它抽象模型好比「網絡設備」和「位圖顯示(bitmap display)」,可是最初的「字符設備」和「塊設備」兩種類型的設備抽象依然地位顯赫。近幾年持久化內存(persistent memory)煊赫一時,[與非易失性存儲NVRAM概念不一樣, persistent memory強調之內存訪問方式讀寫持久存儲,徹底不一樣與塊設備層], 但在未來很長一段時間內,塊設備接口仍然是持久存儲(persistent storage)的主角。這兩篇文章的目的就是去揭開這位主角的面紗。ios
術語「塊層」常指Linux內核中很是重要的一部分 - 這部分實現了應用程序和文件系統訪問存儲設備的接口。 塊層是由哪些代碼組成的呢? 這個問題沒有準確的答案。一個最簡單的答案是在"block"子目錄下的全部源碼。這些代碼又可被看做兩層,這兩層之間緊密聯繫但有明顯的區別。我知道這兩個子層次尚未公認的命名,所以這裏就稱做「bio層」和「request 層」吧。本文將帶咱們先了解"bio層",而在下一篇文章中討論「request層」。算法
在深挖bio層以前,頗有必要先了解點背景知識,看看塊層之上的天地。這裏「之上」意思是靠近用戶空間(the top),遠離硬件(the bottom),包括全部使用塊層服務的代碼。緩存
一般,咱們能夠經過/dev目錄下的塊設備文件來訪問塊設備,在內核中塊設備文件會映射到一個有S_IFBLK標記的inode。這些inode有點像符號連接,自己不表明一個塊設備,而是一個指向塊設備的指針。更細地說,inode結構體的i_bdev域會指向一個表明目標設備的struct block_device對象。 struct block_device包含一個指向第二個inode的域:block_device->bd_inode, 這個inode會在塊設備IO中起做用,而/dev目錄下的inode只是一個指針而已。網絡
第二個inode所起的主要做用(實現代碼主要在fs/block_dev.c, fs/buffer.c,等)就是提供page cache。若是設備文件打開時沒有加O_DIRECT標誌,與inode關聯的page cache用來緩存預讀數據,或緩存寫數據直到回寫(writeback)過程將髒頁刷到塊設備上。若是用了O_DIRECT,讀和寫繞過page cache直接向塊設備發請求。類似地,當一個塊設備格式化並掛載成文件系統時,讀和寫操做一般會直接做用在塊設備上 [做者寫錯了?],儘管一些文件系統(尤爲是ext*家族)可以訪問相同的page cache(過去稱爲buffer cache)來管理一些文件系統數據。數據結構
open()另外一個與塊設備相關的標誌是O_EXCL。塊設備有個簡單的勸告鎖(advisory-locking)模型,每一個塊設備最多隻能有個「持有者」(holder)。在激活一個塊設備時,[激活泛指驅動一個塊設備的過程,包括向內核添加表明塊設備的對象,註冊請求隊列等],可用blkdev_get()函數爲塊設備指定一個"持有者"。[ blkdev_get()的原型: int blkdev_get(struct block_device *bdev, fmode_t mode, void *holder), holder能夠是一個文件系統的超級塊, 也能夠是一個掛載點等]。一旦塊設備有了「持有者」,隨後再試圖激活該設備就會失敗。一般在掛載時,文件系統會爲塊設備指定一個「持有者」,來保證互斥使用塊設備。當一個應用程序試圖以O_EXCL方式打開塊設備時,內核會新建一個struct file對象並把它做爲塊設備的「持有者」,假如這個塊設備做爲文件系統已經被掛載,打開操做就會失敗。若是open()操做成功而且尚未關上,嘗試掛載操做就會阻塞。可是,若是塊設備不是以O_EXCL打開的,那麼O_EXCL就不能阻止塊設備被同時打開,O_EXCL只是便於應用程序測試塊設備是否正在使用中。app
不管以什麼方式訪問塊設備,主要接口都是發送讀寫請求,或其它特殊請求好比discard操做, 最終接收處理結果。bio層就是要提供這樣的服務。函數
Linux中塊設備用struct gendisk表示,即 一個通用磁盤 (generic disk)。這個結構體也沒包含太多信息,主要起承上啓下的做用,上承文件系統,下啓塊層。往上層走,一個gendisk對象會關聯到block_device對象,如咱們上文所述,block_device對象被連接到/dev目錄下的inode中。若是一個物理塊設備包含多個分區,也就說有個分區表,那麼這個gendisk對象就對應多個block_device對象。其中,有一個block_device對象表明着整個物理磁盤gendisk,而其它block_device各表明gendisk中的一個分區。測試
struct bio是bio層一個重要的數據結構,用來表示來自block_device對象的讀寫請求,以及各類其它的控制類請求,而後把這些請求傳達到驅動層。一個bio對象包括的信息有目標設備,設備地址空間上的偏移量,請求類型(一般是讀或寫),讀寫大小,和用來存放數據的內存區域。在Linux 4.14以前,bio對象是用block_device來表示目標設備的。而如今bio對象包含一個指向gendisk結構體的指針和分區號,這些可經過bio_set_dev()函數設置。這樣作突出了gendisk結構體的核心地位,更天然一些。操作系統
一個bio一旦構造好,上層代碼就能夠調用generic_make_request()或submit_bio()提交給bio層處理。[submit_bio()只是generic_make_request()的一個簡單封裝]。 一般,上層代碼不會等待請求處理完成,而是把請求放到塊設備隊列上就返回了。generic_make_request()有時可能阻塞一小會,好比在等待內存分配的時候,這樣想可能更容易理解,它也許要等待一些已經在隊列上的請求處理完成,而後騰出空間。若是bi_opf域上設置了REQ_NOWAIT標誌,generic_make_request()在任何狀況下都不該該阻塞,而應該把這個bio的返回狀態設置成BLK_STS_AGAIN或BLK_STS_NOTSUPP,而後當即返回。截至寫做時,這個功能尚未徹底實現。
bio層和request層間的接口須要設備驅動調用blk_queue_make_request()來註冊一個make_request_fn()函數,這樣generic_make_request()就能夠經過回調這個函數來處理提交個這個塊設備的bio請求了。make_request_fn()函數負責如何處理bio請求,當IO請求完成時,調用bio_endio()設置bi_status域的狀態來表示請求是否處理成功,並回調保存在bio結構體裏的bi_end_io函數。
除了上述對bio請求的簡單處理,bio層最有意思的兩個功能就是:避免遞歸調用(recursion avoidance)和隊列激活(queue plugging)。
在存儲方案裏,常常用到"md" [mutiple device] (軟RAID就是md的一個實例)和"dm" [device mapper] (用於multipath和LVM2)這兩種虛擬設備,也常叫作棧式設備,由多個塊設備按樹的形式組織起來,它們會沿着設備樹往下一層一層對bio請求做修改和傳遞。若是採用遞歸的簡單的實現,在設備樹很深的狀況下,會佔用大量的內核棧空間。好久之前 (Linux 2.6.22),這個問題時不時會發生,在使用一些自己就因遞歸調用佔用大量內核棧空間的文件系統時,狀況更加糟糕。
爲了不遞歸,generic_make_request()會進行檢測,若是發現遞歸,就不會把bio請求發送到下一層設備上。這種狀況下,generic_make_request()會把bio請求放到進程內部的一個隊列上(currect->bio_list, struct task_struct的一個域), 等到上一次的bio請求處理完之後,而後再提交這一層的請求。因爲generic_make_request()不會阻塞以等待bio處理完成,即便延遲一會再處理請求都是沒問題的。
一般,這個避免遞歸的方法都工做得很完美,但有時候可能發生死鎖。理解死鎖如何發生的關鍵就是上文咱們對bio提交方式的觀察: 當遞歸發生時,bio要排隊等待以前已經提交的bio處理完成。若是要等的bio一直在current->bio_list隊列上而得不處處理,它就會一直等下去。
引發bio互相等待而產生死鎖的緣由,不太容易發現,一般都是在測試中發現的,而不是分析代碼發現的。以bio拆分 (bio split)爲例,當一個bio的目標設備在大小或對齊上有限制時,make_request_fn()可能會把bio拆成兩部分,而後再分別處理。bio層提供了兩個函數(bio_split()和bio_chain()),使得bio拆分很容易,可是bio拆分須要給第二個bio結構體分配空間。在塊層代碼裏分配內存要特別當心,尤爲當內存緊張時,Linux在回收內存時,須要把髒頁經過塊層寫出去。若是在內存寫出的時候,又須要分配內存,那就麻煩了。一個標準的機制就是使用mempool,爲一個某種關鍵目的預留一些內存。從mempool分配內存須要等待其它mempool的使用者歸還一些內存,而不用等待整個內存回收算法完成。當使用mempool分配bio內存時,這種等待可能會致使generic_make_request()死鎖。
社區已經有屢次嘗試提供一個簡單的方式來避免死鎖。一個是引入了"bioset" 進程,你能夠用ps命令在電腦上查看。這個機制主要關注的就是解決上面描述的死鎖問題,爲每個分配bio的"mempool"分配一個"rescuer"線程。若是發現bio分配不出來,全部在currect->bio_list的bio就會被取下來,交個相應的bioset線程來處理。這個方法至關複雜,致使建立了不少bioset線程,可是大多時候派不上用場,只是爲了解決一個特殊的死鎖狀況,代價過高了。一般,死鎖跟bio拆分有關係,可是它們不老是要等待mempool分配。[最後這句話,有些突兀]
最新的內核一般不會建立bioset線程了,而只是在幾種個別狀況下才會建立。Linux 4.11內核,引入了另外一個解決方案,對generic_make_request()作了改動,好處是更通用,代價小,可是卻對驅動程序提出了一點要求。主要的要求是在發生bio拆分時,其中一個bio要直接提交給generic_make_request()來安排最合適的時間處理,另外一個bio能夠用任何合適的方式處理,這樣generic_make_request()就有了更強的控制力。 根據bio在提交時在設備棧中的深度,對bio進行排序後,老是先處理更低層設備的bio, 再處理較高層設備的bio。這個簡單的策略避免了全部惱人的死鎖問題。
存儲設備處理單個IO請求的代價一般挺高的,所以提升處理效率的一個辦法就是把多個請求彙集起來,而後作一次批量提交。對於慢速設備來講,隊列上積攢的請求一般會多一些,那麼作批處理的機會就多。可是,對於快速設備,或常常處於空閒狀態的慢速設備來講,作批處理的機會就顯然少了不少。爲了解決這個問題,Linux塊層提出了一個機制叫"plugging"。[plugging, 即堵上塞子,隊列就像水池,請求就像水,堵上塞子就能夠蓄水了]
原來,plugging僅僅在隊列爲空的時候才使用。在向一個空隊列提交請求前,這個隊列就會被「堵塞」上一會時間,好讓請求積蓄起來,暫時不往底層設備提交。文件系統提交的bio就會排起隊來,以便作批處理。文件系統能夠主動請求,或着定時器週期性超時,來拔開塞子。咱們預期的是在必定時間內彙集一批請求,而後在一點延遲後就開始真正處理IO,而不是一直聚積特別多的請求。從Linux 2.6.30開始,有了一個新的plugging機制,把積蓄請求的對象,從面向每一個設備,改爲了面向每一個進程。這個改進在多處理器上擴張性很好。
當文件系統,或其它塊設備的使用者在提交請求時,一般會在調用generic_make_request()先後加上blk_start_plug()和blk_finish_plug()。 blk_start_plug()會初始化一個struct blk_plug結構體,讓current->plug指向它,這個結構體裏面包含一個請求列表(咱們會在下一篇文章細說這個)。由於這個請求列表是每一個進程就有一個,因此在往列表裏添加請求時不用上鎖。若是能夠更高效率的處理請求,make_request_fn()就會把bio添加到這個列表上。
當blk_finish_plug()被調用時,或調用schedule()進行進程切換時(好比,等待mutex鎖,等待內存分配等),保存在current->plug列表上的全部請求就要往底層設備提交,就是說進程不能身負IO請求去睡覺。
調用schedule()進行進程切換時,積蓄的bio會被所有處理,這個事實意爲着bio處理的延遲只會發生在新的bio請求不斷產生期間。假如進程因等待要進入睡眠,那麼積蓄起來的bio就會被當即處理。這樣能夠避免出現循環等待的問題,試想一個進程在等待一個bio請求處理完成而進入睡眠,可是這個bio請求還在plug列表上並無下發給底層設備。
像這樣進程級別的plugging機制,主要的好處一是相關性最強的bio會更容易彙集起來,以便批量處理,二是這樣很大程度上減小了隊列鎖的競爭。若是沒有進程級別的plugging處理,那麼每個bio請求到來時,都要進行一次spinlock或原子操做。有了這樣的機制,每個進程就有一個bio列表,把進程bio列表往設備隊列裏合併時,只須要上一次鎖就夠了。
總之,bio層不是很複雜,它將IO請求以bio結構體的方式直接傳遞給相應的make_request_fn() [具體的實現有通用塊層的blk_queue_bio(), DM設備的dm_make_request(), MD設備的md_make_request()]。bio層實現了各類通用的函數,來幫助設備驅動層處理bio拆分,scheduling the sub-bios [不會翻譯這個,意思應該是安排拆分後的bio如何處理], "plugging"請求等。 bio層也會作一些簡單操做,好比更新/proc/vmstat中的pgpgin和pgpgout的計數,而後把IO請求的大部分操做交給下一層處理 [request層]。
有時候,bio層的下一層就是最終的驅動,好比說DRBD(The Distributed Replicated Block Device)或 BRD (a RAM based block device). 更常見的下一層有MD和DM提供這種虛擬設備的中間層。不可或缺的一層,就是除bio層以外剩下的部分了,我稱之爲"request 層",這將是咱們在下一篇討論的話題。