Multi-queue 架構分析


Linux上傳統的塊設備層(Block Layer)和IO調度器(如cfq)主要是針對HDD(hard disk drivers)設計的。我們知道,HDD設備的隨機IO性能很差,吞吐量大約是幾百IOPS(IOs per second),延遲在毫秒級,所以當時IO性能的瓶頸在硬件,而不是內核。但是,隨着高速SSD(Solid State Disk)的出現並展現出越來越高的性能,百萬級甚至千萬級IOPS的數據訪問已成為一大趨勢,傳統的塊設備層已無法滿足這么高的IOPS需求,逐漸成為系統IO性能的瓶頸。

為了適配現代存設備(高速SSD等)高IOPS、低延遲的IO特征,新的塊設備層框架Block multi-queue(blk-mq)應運而生。本文就帶大家來了解下Linux 塊設備層的blk-mq框架和代碼實現。

一、單隊列框架和存在的問題

Linux上傳統塊設備層使用單隊列(Single-queue/SQ)架構,如圖1所示。簡單來說,塊設備層負責管理從用戶進程到存儲設備的IO請求,一方面為上層提供訪問不同存儲設備的統一接口,隱藏存儲設備的復雜性和多樣性;另一方面,為存儲設備驅動程序提供通用服務,讓這些驅動程序以最適合的方式接收來自上層的IO請求。Linux Block Layer主要提供以下幾個方面的功能:

  • bio的提交和完成處理,上層通過bio來統一描述發往塊設備的IO請求

  • IO請求暫存,如合並、排序等

  • IO調度,如noop、cfq、deadline等

  • IO記賬,如統計提交到塊設備的IO總量,IO延遲等信息

圖1. 單隊列的Linux block layer設計

圖片引用自《Linux Block IO: Introducing Multi-queue SSD Access on Multi-core Systems》

由於采用單隊列(每個塊設備1個請求隊列--Requst Queue)的設計,傳統的Block Layer對多核體系的可擴展性(scalability)不佳。當系統配備現代高速存儲器件時,單隊列引入的軟件開銷變得突出(在多socket體系中尤為嚴重),成為IO性能的瓶頸。多核體系中blk-sq的軟件開銷主要來自三個方面:

  • 請求隊列鎖競爭:blk-sq使用spinlock(q->queue_lock)來同步IO請求隊列的訪問,每次往請求隊列中插入或刪除IO請求,必須先獲取此鎖;IO提交時如果操作請求隊列,必須先獲取此鎖;IO排序和調度操作時,也必須先獲取此鎖。這一系列操作繼續之前,必須先獲得請求隊列鎖,在高IOPS場景(多個線程同時提交IO請求)下,勢必引起劇烈的鎖競爭,帶來不可忽視的軟件開銷。從圖2中可以看到,Linux- 2.6.32 中scsi+blk-sq,高IOPS場景下,約80%的cpu時間耗費在鎖獲取上。

圖2. 高IOPS場景下cpu熱點數據

圖片引用自《High Performance Storage with blk-mq and scsi-mq》

  • 硬件中斷:高的IOPS意味着高的中斷數量。在多數情況下,完成一次IO需要兩次中斷,一次是存儲器件觸發的硬件中斷,另一次是IPI核間中斷用於觸發其他cpu上的軟中斷。

  • 遠端內存訪問:如果提交IO請求的cpu不是接收硬件中斷的cpu且這兩個cpu沒有共享緩存,那么獲取請求隊列鎖的過程中還存在遠端內存訪問問題。

圖3. blk-sq IOPS吞吐量隨cpu數量的變化曲線,blk-sq支持的最高吞吐量大概在1MIOPS

圖片引用自《Linux Block IO: Introducing Multi-queue SSD Access on Multi-core Systems》

二、多隊列框架和解決的問題

針對blk-sq存在的問題,Jens Axboe (Linux內核Block Layer Maintainer)提出了多隊列(multi-queue/MQ)的塊設備層架構(blk-mq),如圖4所示:

圖4. 兩層隊列的Block Layer設計

圖片引用自《Linux Block IO: Introducing Multi-queue SSD Access on Multi-core Systems》

blk-mq中使用了兩層隊列,將單個請求隊列鎖的競爭分散多個隊列中,極大的提高了Block Layer並發處理IO的能力。兩層隊列的設計分工明確:

  • 軟件暫存隊列(Software Staging Queue):blk-mq中為每個cpu分配一個軟件隊列,bio的提交/完成處理、IO請求暫存(合並、排序等)、IO請求標記、IO調度、IO記賬都在這個隊列上進行。由於每個cpu有單獨的隊列,所以每個cpu上的這些IO操作可以同時進行,而不存在鎖競爭問題

  • 硬件派發隊列(Hardware Dispatch Queue):blk-mq為存儲器件的每個硬件隊列(目前多數存儲器件只有1個)分配一個硬件派發隊列,負責存放軟件隊列往這個硬件隊列派發的IO請求。在存儲設備驅動初始化時,blk-mq會通過固定的映射關系將一個或多個軟件隊列映射(map)到一個硬件派發隊列(同時保證映射到每個硬件隊列的軟件隊列數量基本一致),之后這些軟件隊列上的IO請求會往對應的硬件隊列上派發。

MQ架構解決了SQ架構中請求隊列鎖競爭和遠端內存訪問問題,極大的提高了Block Layer的IOPS吞吐量。從圖5中,我們可以看到Linux 3.17-rc3 中scsi-mq+blk-mq,與圖2相同的高IOPS場景下僅3%的cpu時間耗費在鎖獲取上。

圖5. scsi-mq_+blk-mq高IOPS場景下cpu熱點數據

圖片引用自《High Performance Storage with blk-mq and scsi-mq》

圖6. IOPS吞吐量隨cpu數量的變化曲線,blk-mq更加接近raw設備的性能

圖片引用自《Linux Block IO: Introducing Multi-queue SSD Access on Multi-core Systems》

三、多隊列框架代碼分析

blk-mq代碼在Linux-3.13(2014)內核中合入主線,在Linux-3.16中成為內核的一個完整特性,在Linux-5.0內核中,blk-sq代碼(包括基於blk-sq的IO調度器,如cfq、noop)已被完全移除,MQ成為Linux Block layer的默認選項。下面基於Linux-5.6.0內核介紹blk-mq代碼和關鍵數據結構。

request和tag分配

blk-mq中,request和tag是綁定的。首先,我們來看下兩個與tag分配有關的重要數據結構--blk_mq_tags和blk_mq_tag_set。

  • blk_mq_tags,用於描述tag和request的集合,它的主要成員如下:

  • blk_mq_tag_set,用於描述與存儲器件相關的tag集合,抽象了存儲器件的IO特征,它的主要成員如下:

與SQ框架一樣,MQ框架中使用request結構體來描述IO請求;不同的是,SQ使用內存池來分配request結構體(參見__get_request),在request往驅動派發時分配tag(參見blk_queue_start_tag),MQ中request和tag分配是綁定在一起的(參見blk_mq_get_request), 具體表現為:

  • request內存分配在塊設備驅動初始化時完成(通過調用blk_mq_alloc_tag_set),避免IO發生時request內存分配帶來的開銷

  • tag 作為request(static_rqs/rqs數組)的索引

blk_mq_alloc_tag_set: 為一個或者多個請求隊列分配tag和request集合(tag set可以是多個request queue共享的,例如UFS設備,一個host controller只有一個tag set,但器件可能划分成多個LU--Logical Unit,每個LU有單獨的request queue, 這些不同的request queue共享一個tag set),主要流程如下:

  • 設置硬件隊列數量(nr_hw_queues)和映射表數量(nr_maps)

  • 調用blk_mq_realloc_tag_set_tag 根據硬件隊列數量擴展tags數組

  • 調用blk_mq_update_queue_map更新映射表(map: cpu id->hw queue id)

  • 調用blk_mq_alloc_rq_maps分配request和tag(隊列深度可能會根據內存狀態下調)

圖7. scsi-mq驅動初始化時tag set分配流程

blk_mq_get_request: 為bio分配request。MQ中request占用的內存在塊設備驅動初始化時分配完成(tags->static_rqs), tag作為數組的索引獲取對應的request,因此MQ中分配request即分配tag(使用sbitmap標記對應tag的是否已被使用)。該函數的主要流程如下:

  • 調用blk_mq_get_ctx 獲取當前cpu的軟件隊列(ctx)

  • 調用blk_mq_map_queue 找到軟件隊列(ctx)對應的硬件派發隊列(hctx)

  • 對於配置了調度器的隊列,調用limit_depth限制隊列深度(影響tag獲取);對於無調度器的隊列,更新tag set的當前活躍隊列數量(用於均分tag到不同request_queue)

  • 調用blk_mq_get_tag獲取tag, 可能因當前無可用tag進入iowait狀態

  • 調用blk_mq_rq_ctx_init 初始化tag對應的request(tags->static_rqs[tag])

圖8. blk-mq bio提交時request分配流程

request_queue初始化

基於blk-mq的塊設備驅動初始化時,通過調用blk_mq_init_queue初始化IO請求隊列(request_queue)。例如,scsi-mq驅動中,每次添加scsi設備(scsi_device)時都會調用blk_mq_init_queue接口來初始化scsi設備的請求隊列。
blk_mq_init_queue:初始化IO請求隊列--request_queue。函數的主要流程如下:

  • 調用blk_alloc_queue_node分配請求隊列的內存,分配的內存節點與設備連接的NUMA節點一致,避免遠端內存訪問問題。

  • 調用blk_mq_init_allocated_queue初始化分配的請求隊列(request_queue),blk-mq的request_queue中包含兩層隊列,即percpu的軟件隊列(ctx)和與塊設備硬件隊列一一對應的硬件派發隊列(hctx)。這個初始化過程主要包含下面幾步:

1.設置隊列的mq_ops(q->mq_ops)為set->ops (例如scsi對應的實現是scsi_mq_ops)

2.設置request超時時間,初始化timeout_work(處理函數是blk_mq_timeout_work)

3.設置隊列的make_request回調為blk_mq_make_request (bio的提交時會用到)

4.分配和初始化percpu軟件隊列(ctx)

5.關聯request_queue和塊設備的tag set

6.更新軟件隊列(ctx)到硬件派發隊列(hctx)的映射關系(map: ctx->hctx)

圖9. scsi-mq驅動創建scsi device時初始化requst_queue流程

IO的提交(submit)

blk-mq中,通過調用blk_mq_make_request將上層提交的bio封裝成request並提交到塊設備層,它的主要流程如下:

  • 嘗試與當前線程plug list(如果當前線程正在做IO plug)中的IO request合並

  • 嘗試與當前cpu軟件隊列中的IO request合並(如果使能調度器,且調度器實現bio_merge接口,則調用這個接口嘗試與調度器隊列中的IO request合並)

  • 嘗試IO請求的QoS(Quality of Service)限流(目前實現的QoS策略有wbt, io-latency cgroup, io-cost cgroup三種)

圖10.  blk-mq中IO提交流程

  • 獲取request,並將bio添加到request

  • 生成request后,將request插入請求隊列中,分下面幾種情況

1.如果是fua/flush請求,則將request插入到flush隊列,並調用blk_mq_run_hw_queue 啟動請求派發

2.如果當前線程正在做IO plug且塊設備是硬件單隊列的(nr_hw_queues=1),則將request插入到當前線程的plug list

3.如果配置了調度器,則調用blk_mq_sched_insert_request將請求插入調度器隊列(如果沒有實現insert_requests接口,則插入到當前cpu的軟件隊列中)

4.如果是硬件多隊列塊設備上的同步IO請求,則調用blk_mq_try_issue_directly嘗試將request直接派發到塊設備驅動

5.其他情況,則調用blk_mq_sched_insert_request插入request(同case 3)

IO的派發(dispatch)

blk-mq中通過調用blk_mq_run_hw_queue派發IO請求到塊設備驅動,MQ框架中存在很多的點會觸發IO請求往塊設備驅動派發,主要如下:

blk_mq_run_hw_queue: 啟動硬件隊列派發IO請求,可以是同步/異步的執行的。如果隊列不在靜默狀態(quiesced)且有IO請求pending,則啟動派發:

  • 如果是同步派發,且當前cpu的軟件隊列映射到此硬件隊列,則調用__blk_mq_run_hw_queue在當前線程上下文中執行IO請求派發

  • 如果是異步派發,則啟動延遲任務(hctx->run_work)執行IO請求派發

圖11. blk-mq啟動硬件隊列派發IO請求的流程

無論是同步還是異步的派發模式,最終都會調用__blk_mq_run_hw_queue派發IO請求,這個函數先檢查執行的上下文,然后調用blk_mq_sched_dispatch_requests派發IO請求到塊設備驅動,這個函數的主要流程如下:

圖12. blk-mq派發IO請求的流程

  • 如果硬件派發隊列(hctx->dispatch)非空, 則先調用blk_mq_dispatch_rq_list派發這個隊列中的IO請求

  • 如果配置了調度器,則調用blk_mq_do_dispatch_sched 從調度隊列中派發IO請求

  • 如果隊列繁忙(dispatch_busy記錄繁忙狀態),則調用blk_mq_do_dispatch_ctx 從軟件隊列(軟件隊列選取采用Round-Robin策略)中取1個IO請求派發

  • 否則,取映射到這個硬件隊列的所有軟件隊列上的IO請求,調用blk_mq_dispatch_rq_list派發

上述4種情況都會調用blk_mq_dispatch_rq_list 將IO請求派發到塊設備驅動,這個函數使用塊設備驅動實現的幾個接口完成派發邏輯:

IO的完成(complete)

下面以UFS+scsi-mq驅動為例,講解IO完成處理的過程,主要流程如圖13所示:

圖13. ufs+scsi-mq驅動中一個IO的完成流程

  • UFS設備完成一個IO請求之后,觸發中斷, ufshc(UFS host controller)驅動負責處理這個中斷(服務例程是ufshc_intr),通過中斷狀態寄存器(REG_INTERRUPT_STATUS)判斷是否有IO請求完成(UTP_TRANSFER_REQ_COMPL位),通過對比門鈴寄存器(REG_UTP_TRANSFER_REQ_DOOR_BELL)和outstanding_reqs的值(異或操作)取得完成的IO請求。對於每個完成的IO請求,調用scsi_done進入scsi 命令的完成處理流程

  • scsi-mq驅動中scsi_done的實現是scsi_mq_done,這個函數會調用blk-mq中的接口blk_mq_complete_request 進入塊設備層的request完成處理流程

  • Block Layer對於request完成處理有4種方式,具體如下:

1.對於硬件單隊列的塊設備,調用__blk_complete_request處理。如果發起IO請求的cpu就是當前cpu或者和當前cpu共享緩存,則發起當前cpu上的block軟中斷,在block軟中斷中繼續request完成處理流程

2.對於硬件單隊列的塊設備,如果不滿足case 1的條件,則通過IPI(inter-processor interrupt)發起其他cpu上的block軟中斷,在block軟中斷繼續request完成處理流程

3.對於硬件多隊列設備,如果當前cpu與發起IO請求的cpu不共享緩存(且不是高優先級的IO請求),則調用__blk_mq_complete_request_remote發起遠端cpu上的request完成處理流程(IPI)

4.對於硬件多隊列設備,非上述情況,直接在當前cpu上(硬件中斷上下文)繼續request完成處理流程

  • 上述4種情況,最后都會調用mq_ops->complete 繼續處理request的完成,對於scsi-mq驅動,這個接口的實現是scsi_softirq_done,經過一系列過程調用后(參見圖13),由req_bio_endio 完成bio

四、多隊列IO調度器

事實上,在blk-mq框架一開始是不支持IO調度器的(Linux-3.13)。由於高速存儲器件IO特征是高IOPS,低延遲,我們希望IO的軟件開銷盡可能低,而IO調度會增加軟件開銷,所以專門針對這類器件設計的blk-mq在一開始並沒有加入IO調度的能力。

當時的Linux塊設備層是SQ和MQ兩套框架共存的(SQ用於HDD這樣的慢速塊設備驅動,MQ用於nvme這樣的高速塊設備驅動)。Linux Block Layer的發展趨勢是希望能夠使用一套框架同時滿足慢速器件和高速器件的需求,所以blk-mq引入后,Linux上的塊設備驅動程序開始往MQ框架遷移,在Linux-5.0上,所有基於SQ的塊設備驅動都完成了向MQ框架的轉化,Block Layer的SQ框架和相關IO調度器被完全移除。

我們知道對於慢速器件(特別是旋轉磁盤,隨機IO性能很差)來講,IO調度是十分重要的,同時一些高速器件(特別是硬件單隊列的器件,如emmc,ufs)也有IO調度的需求。

因此在Linux-4.11上,Jens Axboe在MQ框架上增加了IO調度的能力,同SQ框架一樣,MQ中的IO調度器也是插件化的,框架提供一系列的接口,由具體的IO調度算法(如mq-deadline)實現這些接口:

目前基於blk-mq實現的IO調度器主要有下面幾個:

  • mq-deadline: 根據IO請求的類型(read/write)分配不同的到期時間(deadline),並將IO請求按照deadline排序,同時支持IO合並,派發時優先選擇deadline最小的IO請求,以此來控制IO的延遲。它在Linux-4.11上合入主線,是mq調度器的默認選項。對這個調度器感興趣的同學可以閱讀參考資料8和源碼(mq-deadline.c)進一步了解相關細節

  • bfq: budget fair queuing, 它在Linux-4.12上合入主線,主要針對的是慢速器件,如機械硬盤。它提供了IO排序、IO優先級、按權重均勻分配IO帶寬和組調度的能力,它保證每個IO的最大延遲可控的同時充分利用器件IO帶寬。它的缺點是調度的軟件開銷比較高,例如Arm CortexTM-A53 8核系統中,最高只能支持80KIOPS,因此不適用於高速器件(如百萬級IOPS的SSD)。對這個調度器感興趣的同學可以閱讀參考資料9和源碼(bfq-iosched.c)進一步了解相關細節

  • kyber: 它在Linux-4.12上合入主線,是一個真正意義上的mq調度器,適用於高速器件。它對不同類型的IO(read/write/discard/others)設置不同的延遲要求,分開調度,同時監控每個IO調度域的延遲情況,如果某個IO調度域的延遲過高,則動態增加這個IO調度域的隊列深度,並減小延遲OK的IO調度域的隊列深度,以達到控制IO延遲的目的。本質上這種調度算法傾向於優先調度延遲要求高的IO(如read)。對這個調度器感興趣的同學可以閱讀參考資料10和源碼(kyber-iosched.c)進一步了解相關細節

五、結語

本文主要介紹了blk-mq框架,基於這個框架,我們能夠實現下面幾件事情:

  • 實現基於blk-mq的塊設備驅動程序(具體實現可以參考空設備驅動--null_blk)

  • 實現基於blk-mq的IO調度器,例如提供關鍵IO的優先處理能力、IO限流等(具體實現可以參考kyber-iosched.c)

  • 改進或者擴展blk-mq框架,來達到業務的需求(事實上,在blk-mq演進的過程中,許多想法都來自底層塊設備驅動,特別是nvme驅動和scsi-mq驅動)

參考資料

1.《Linux Block IO: Introducing Multi-queue SSD Access on Multi-core Systems》

2.《High Performance Storage with blk-mq and scsi-mq》

3.https://www.thomas-krenn.com/en/wiki/Linux_Multi-Queue_Block_IO_Queueing_Mechanism_(blk-mq)#cite_note-blkmq-2

4.https://lwn.net/Articles/552904/

5.https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/block

6.https://lwn.net/Articles/736534/

7. https://lwn.net/Articles/738449/

8.https://www.kernel.org/doc/html/latest/block/deadline-iosched.html

9.https://www.kernel.org/doc/html/latest/block/bfq-iosched.html

10.https://www.kernel.org/doc/html/latest/block/kyber-iosched.html

掃碼關注
“內核工匠”微信公眾號
Linux 內核黑科技 | 技術文章 | 精選教程


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM