塊層介紹 第二篇: request層

原文連接:https://lwn.net/Articles/738449
做者: Neil Brown
注意:方括弧內的文字是筆者添加的node

Linux 塊層向上爲文件系統和塊設備提交接口,使得上層可以以統一的方式訪問各類類型的後端存儲設備。同時,它也向下爲設備驅動提供接口,讓驅動層可以以一致的方式來接受請求。一些驅動如上一篇文章中提到的DRBD和RBD設備,只使用bio層提供的一些接口,對bio請求進行處理。其它的驅動則能夠從IO請求plugging機制,各類請求排序和請求合併中受益。爲了給驅動層提供服務,塊層作了很多事情,我且稱之爲"request層"。ios

如今,"request層"並存着兩種模型:單隊列(single-queue) 和 多隊列(multi-queue)。多隊列的出現也就是近幾年的事情,也許總有一天會徹底取代單隊列的,可是目前來看二者在內核的使用都至關活躍。有了這兩種不一樣的排隊方法(queuing approach)作參考,咱們就能夠兩相對比來學習學習,因此咱們將花點時間都看看,分析他們如何把請求呈現給驅動層的。首先,咱們來看看二者的共同之處,分析這兩個關鍵的結構體是個不錯的入手點: struct request_queue 和 struct request。算法

請求隊列和請求

struct request_queue之於struct request的關係,很是像struct gendisk之於struct bio的關係:一個表明具體的設備,一個表明IO請求。須要注意的是,每一個gendisk都有一個關聯的request_queue結構體,可是隻有那些使用了"request層"的設備纔會分配request結構體。按理說,適用於全部塊設備的域,好比struct queue_limits,都應該放到gendisk結構體裏面,而對於一些僅適用於"request層"隊列管理的域,其實只應當分配給那些使用了"request層"的設備。如今來看,這些結構體裏有些域的安排可能就是歷史巧合,也不值得去糾正了。各類隊列相關的域,均可以在/sys/block/*/queue/目錄下查看。後端

request結構體表明單個IO請求,最終要傳遞到底層設備。一個request包含一個或多個表明連續IO請求的bio,一些跟蹤整體狀態的信息(好比時間戳,哪一個CPU發來的請求),和一些用來鏈入更大數據結構的「錨點」,好比用struct list_head queuelist鏈入一個簡單的隊列結構,用struct hlist_node hash鏈入一個哈希表(用來查找與新bio相鄰的請求),用struct rb_node rb_node來把請求放在一棵紅黑樹上。在分配一個request結構體的時候,有時候要分配一些額外的空間來給底層驅動保存一些額外的信息。有時候這些空間用來保存命令頭部,以便發送給底層設備,好比 SCSI command descriptor block,驅動能夠自由地決定如何使用這塊空間。緩存

相應的make_request_fn()函數 (單隊列是blk_queue_bio,多隊列是blk_mq_make_request())爲IO請求建立一個request結構體,而後把交給IO調度器, 也叫電梯算法"elevator", 這個名字來自電梯算法( elevator algorithm),電梯算法曾是磁盤IO調度一個里程碑式的成就。咱們將快速看一下單隊列的實現,而後再對比地去看多隊列。數據結構

單隊列上的請求調度

過去,大多存儲設備都是機械硬盤,要經過磁頭尋道,盤片旋轉來定位數據。機械盤一次只能處理一個請求,從盤片上一個位置挪動到下一個位置代價很大。單隊列的實現就是爲這種類型的設備而生的,而後漸漸地也能支持其它快速設備,然而單隊列總體結構依然反映了旋轉類型存儲設備的需求。app

單隊列調度器主要有三個任務:異步

  • 積聚表明連續IO操做的多個bio,合併成更大的IO請求,充分發揮硬件性能,可是請求不能太大超高硬件設備的限制;
  • 把請求進行排序來減小尋道時間,可是又要保證重要的請求獲得及時處理。不斷尋求較優方案,來解決這個問題,是這一部分代碼複雜度的根源。通常很難知道這兩點:一個請求有多重要,和一個請求須要多少尋道時間,咱們只能依賴啓發式方法來判斷怎樣排序比較好,然而啓發式方法毫不是完美的;
  • 把通過整理的請求列表交給底層驅動,讓驅動從隊列取下請求去處理,同時提供一個通知機制來告訴上層請求處理的結果。

最後一個任務很直接了當。驅動會經過blk_init_queue_node()來註冊一個request_fn()策略例程,只要隊列上有新請求已經準備好,策略例程被調用去處理這個請求。驅動負責用blk_peek_request()來從隊列上取下請求,而後進行處理。當請求處理完畢 [有些設備能夠並行處理多個請求],驅動會從隊列上摘下另外一個請求來處理,而不用再去調用request_fn() [由於request_fn()一次拿到了整個列表上的全部request]。請求一旦處理完畢,就可調用blk_finish_request()來通知上層。函數

第一個任務有部分是用elv_attempt_insert_merge()完成的,它會快速地檢查隊列,看是否能夠找到一個已經存在的request,把新的bio合併進入。若是成功,調度器就會容許合併,若是失敗,調度器還有一次機會嘗試在調度器內部進行一次合併。若是沒有機會合並,就分配一個新的請求,而後交給調度器。稍後一會,若是某個請求因合併而長大,使得它與該請求變成了連續的,調度器就能夠再次嘗試合併操做。oop

第二個任務多是最複雜的。如何把請求按照"適當"順序排隊很是依賴咱們如何來解釋"適當"的含義。這三種不一樣的單隊列調度器: noop, deadline和cfq,對「適當」的解釋就很是不同。

  • "noop"會對請求作一些簡單的排序,不容許把讀請求挪到寫請求以前,反之亦然;依據電梯算法,一個同類型的請求能夠插入到另外一個以前。除了elv_dispatch_sort()所作簡單排序,"noop"就是一個FIFO隊列。

  • 「deadline」會把提交時間相近的請求放在一批。在同一批中,請求會被排序。當一批請求達到了大小上限或着定時器超時,這批請求就會提交到設備隊列上去。這個算法嘗試給每個請求都設置一個延遲時間上限,同時儘可能彙集比較大的一批請求。

  • "cfq"即"Complete Fairness Queuing",相比其它幾個調度器要複雜不少,目的是在不一樣進程或進程組間保證IO資源使用的公平性。cfq調度器內部維護了多個隊列,每個進程都有一個隊列來保存來自該進程的同步請求(一般是讀),而對於異步請求(一般是寫),每個優先級都有一個隊列,全部請求不論來自哪一個進程都按照優先級放到相應的隊列上。在提交請求時,按照優先級每一個隊列都有機會獲得調度。每一個隊列都有必定的時間片,在時間片內才能提交必定數量的請求。當一個同步隊列中的請求不足必定數量時,這個設備能夠空閒一會,即便其它隊列裏可能有請求等待處理。一般,同步請求之間在磁盤上的物理位置是連續的,因此讓磁盤稍等一會來接收更多連續的請求,這樣作能夠提升吞吐量。以上對CFQ的描述僅僅是點皮毛。內核文檔(Documentation/block/cfq-iosched.txt)講的更詳細點,還列出了全部參數,經過調整這些參數可以適應各類不一樣的場景。

以前提到過,一些高端點的設備能夠一次處理多個請求,即在一個請求尚未處理完成以前,就可以處理新的請求。一般,這個要用到"tagging"功能,給每個請求加一個標籤,這樣請求完成通知就能和原來的請求正確的對應起來。單隊列的"request層"能夠對任意深度的設備提供"tagging"功能。

一個設備內部能夠經過真正地並行處理請求,來支持被標記的命令,好比經過訪問一個內存緩存,經過設計多模塊而每一個模塊都能處理一個請求,或者經過其內部隊列,這樣的隊列比"request層"更加了解設備。

多隊列和多CPU

多隊列的另外一個動機就是減小鎖的開銷,由於咱們的系統處理器愈來愈多,而請求從多個處理器放到一個隊列中時須要加鎖,鎖的開銷變得愈來愈大。"plugging"機制能幫得上一些忙,可是不夠理想。若是咱們可以分配更多隊列:每一個NUMA節點一個隊列,或者一個CPU一個隊列,那麼把請求放到隊列的鎖開銷就會減小不少。若是硬件支持並行處理多個請求,那麼這樣作的優點就更大了。若是硬件只支持一次提交一個請求,那麼多個per-CPU隊列仍然須要合併。若是他們比"plugging"機制批處理的效果更好,那麼這樣作也是有益處的。假如不能提升批處理的效果,寫程序的時候當心點應該也可以保證,至少不會有什麼損失。

以前說過,cfq調度器內部已經有多個隊列,可是它們跟multi-queue的目的徹底不同,它們把請求與進程和優先級關聯起來,而multi-queue的隊列是跟硬件密切相關的。multi-queue "request層"維護着兩組硬件相關的隊列:軟件的"staging"隊列和硬件的"dispatch"隊列。

軟件staging隊列(struct blk_mq_ctx)是依CPU硬件狀況而分配的,每一個CPU分配一個,或每一個NUMA節點分配一個。當塊層的"plugging"機制拔開"塞子"時(blk_mq_flush_plug_list()),request請求在一個spinlock的保護下被添加到隊列上,鎖競爭應該不多。multi-queue的隊列能夠選擇由某一個multi-queue調度器來管理, 如今有三種multi-queue調度器:bfq, kyber和mq-deadline.

硬件dispatch隊列是基於目標硬件塊設備進行分配的,因此有可能只有一個,也有可能多達2048個隊列 (或與硬件支持的中斷源個數同樣)。"request層"爲每個硬件隊列(或"硬件上下文")分配一個數據結構struct blk_mq_hw_ctx,維護着一個CPU和隊列之間的映射表,而隊列自己就是爲底層驅動而服務的。「request 層」時不時地把硬件隊列中的請求傳遞給底層驅動。接下來,請求就全由驅動處理了,一般狀況下,又會很快按照接收的順序交給硬件。

與single-queue相比有另外一個重要區別,multi-queue使用的request結構體都是預分配的。每一個request結構體都關聯着一個不一樣tag number,這個tag number會隨着請求傳遞到硬件,再隨着請求完成通知傳遞回來。早點爲一個請求分配tag number,在時機到來的時候,request 層可隨時向底層發送請求。

single-queue只須要一個request_fn()就能夠了,可是multi-queue須要底層驅動提供一個struct blk_mq_ops結構體,包含了多達11個函數。其中,最核心的一個函數是queue_rq(),其它的函數實現了超時,請求完成輪詢,請求初始化,等相似操做。

一旦請求就緒,而且調度器不想再把請求保持在隊列上來排序或擴展,調度器就會調用queue_rq()函數。single-queue把收集請求的責任交給了驅動層,與之不一樣,multi-queue卻把這個責任交給了"request層"。queue_rq()有個參數表明的是硬件上下文,一般會把請求放在內部FIFO隊列上,也有可能會直接處理。queue_rq()能夠拒絕一個請求,而後返回BLK_STS_RESOURCE,讓請求繼續在staging隊列上等待。除了BLK_STS_RESOURCE和BLK_STS_OK,其它返回值都被視爲IO錯誤。

多隊列調度

multi-queue不是必需要配置一個調度器,若是不指定的話,那麼就會用相似單隊列中的「noop」調度器。連續的bio會被合併到一個請求,而不連續的bio各成爲一個獨立的請求。這些請求以FIFO的順序被放到staging隊列中,儘管有多個submission隊列,默認的調度器會嘗試直接提交新請求,僅僅在收到BLK_STS_RESOURCE返回值時纔會使用staging隊列。當塊設備上的plugging機制拔開「塞子」時,調度器會調用blk_mq_run_hw_queue()或blk_mq_delay_run_hw_queue()把軟件隊列傳遞給驅動層處理。

可插拔的multi-queue調度器有22個入口點,其中有兩個函數,一個是insert_requests()把一組請求添加到軟件staging隊列中,另外一個是dispatch_request()會選擇一個請求而後傳遞給硬件設備。若是沒有實現insert_request()函數,請求就會被簡單的插入到列表末尾。若是沒有提供dispatch_request()函數,就會從staging隊列裏取下請求,而後以任意順序傳遞到相應的硬件隊列。This "collect everything" step is the main point of cross-CPU locking and can hurt performance, so it is best if a device with a single hardware queue accepts all the requests it is given.[最後一句我理解不到那個點,大體理解是說staging隊列有多個,硬件隊列只有一個的狀況,那麼多個軟件隊列上的請求最終要收集到這個硬件隊列上來,那麼必然要加鎖,會比較影響性能]

mq-deadline調度器跟單隊列的deadline調度器發揮的功能很類似。它有個insert_request()函數,不會使用多個staging隊列,而是把請求放到兩個全局的基於時間的隊列中 - 一個放讀請求,一個放寫請求,先嚐試把該新請求與已經存在的請求合併,若是合併不了,則把這個新請求放到隊列尾部。dispatch_request()函數會從這些隊列中返回第一個請求:基於時間的隊列,基於請求批大小,以及避免寫飢餓的隊列。

bfq調度器,即Budget Fair Queueing, 必定程度上是借鑑cfq實現的。內核裏有介紹bfq的文檔(Documentation/block/bfq-iosched.txt),幾年前lwn上有篇講bfq的文章(https://lwn.net/Articles/601799/),後來又出了一篇文章(https://lwn.net/Articles/709202/), 跟進了bfq被吸納進multi-queue的狀況。bfq有一點比較像mq-deadline,沒有使用多個per-CPU staging隊列。bfq有多個隊列,每個隊列由一把自旋鎖保護。

與mq-deadline和bfq不同,kyber IO調度器,在這篇文章中(https://lwn.net/Articles/720675/)有簡單介紹,它使用了per-CPU的staging隊列,也沒有實現本身的insert_request()函數,而用的是默認行爲。dispatch_request()函數爲每個硬件上下文都維護着各類內部隊列,若是這些隊列是空的,它就會從給定的硬件上下文所對應的全部staging隊列來收集請求,而後在內部將請求作個分佈,若是硬件隊列不是空的,它就直接從對應的內部隊列裏下發請求。關於kyber調度器的這幾個方面內核沒有相應的註釋和文檔:策略解釋,請求分佈,以及處理順序。

單隊列要壽終正寢了?

在塊層中,並存兩套不一樣的隊列系統,兩套調度器和兩套不一樣的驅動接口很明顯是不理想的。咱們能夠期待single-queue的代碼很快被移除掉嗎?multi-queue到底又有多好呢?

不幸的是,這些問題的回答須要在不一樣的硬件上作不少測試,而筆者只是個作軟件的。從軟件的角度來講,很清楚,在支持多隊列並行提交請求的硬件上,multi-queue應該能帶來不少好處。當用在單隊列硬件上時,multi-queue至少應該和單隊列旗鼓至關,可是不要指望一晚上之間就能達到旗鼓至關的水平,由於任何新事物都不是完美的。

說到不完美,舉個例子,最近一個補丁集進了Linux 4.15。這些補丁對mq-deadline調度器作了一些修改來解決redhat在內部存儲系統測試中發現的性能問題。假如切換到multi-queue, 存儲領域的各家廠商頗有可能在測試中發現相似的性能回退。在將來幾個月裏,這些被發現的問題頗有但願獲得修復。2017可能不會成爲multi-queue-ony Linux的一年,可是這樣的一天不會太遠。

相關文章
相關標籤/搜索