歡迎光臨
每天分享高質量文章

塊層介紹 第一篇: bio層

原文連結:https://lwn.net/Articles/736534/

摘要: 本文翻譯自Neil Brown發表在LWN上的兩篇介紹塊層的文章。Neil是前MD RAID的maintainer,他透過這兩篇文章,提綱契領地描繪了塊層的主脈絡。

作業系統比如Linux關鍵的價值之一,就是為具體的裝置提供了抽象介面。雖然後來出現了各種其它抽象模型比如“網路裝置”和“點陣圖顯示(bitmap display)”,但是最初的“字元裝置”和“塊裝置”兩種型別的裝置抽象依然地位顯赫。近幾年持久化記憶體(persistent memory)炙手可熱,[與非易失性儲存NVRAM概念不同, persistent memory強調以記憶體訪問方式讀寫持久儲存,完全不同與塊裝置層], 但在將來很長一段時間內,塊裝置介面仍然是持久儲存(persistent storage)的主角。這兩篇文章的目的就是去揭開這位主角的面紗。

術語“塊層”常指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只是便於應用程式測試塊裝置是否正在使用中。

無論以什麼方式訪問塊裝置,主要介面都是傳送讀寫請求,或其它特殊請求比如discard操作, 最終接收處理結果。bio層就是要提供這樣的服務。

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)。

避免遞迴(recursion avoidance)

在儲存方案裡,經常用到”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。這個簡單的策略避免了所有惱人的死鎖問題。

塊佇列啟用(queue plugging)

儲存裝置處理單個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層及以下(bio layer and below)

總之,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 層”,這將是我們在下一篇討論的話題。

贊(0)

分享創造快樂