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

劉正元:Linux通用塊層之IO合併

作者簡介: 劉正元,來自天津麒麟(kylinos.cn), linux內核愛好者,對內核IO子系統和內核除錯工具這塊比較感興趣,向內核上游內核貢獻過一些,目前在公司負責檔案IO協議棧的除錯調優。

相關閱讀:

宋寶華: 檔案讀寫(BIO)波瀾壯闊的一生

劉正元: Linux 通用塊層之DeadLine IO調度器

所謂請求合併就是將行程內或者行程間產生的在物理地址上連續的多個IO請求合併成單個IO請求一併處理,從而提升IO請求的處理效率。在前面有關通用塊層介紹的系列文章當中我們或多或少地提及了IO請求合併的概念,本篇我們從頭集中梳理IO請求在block layer的來龍去脈,以此來增強對IO請求合併的理解。 
首先來看一張圖,下麵的圖展示了IO請求資料由用戶行程產生,到最終持久化儲存到物理儲存介質,其間在內核空間所經歷的資料流以及IO請求合併可能的觸發點。


從內核的角度而言,行程產生的IO路徑主要有圖中①②③所示的三條:

① 快取IO, 對應圖中的路徑,系統中絕大部分IO走的這種形式,充分利用filesystem 層的page cache所帶來的優勢, 應用程式產生的IO經系統呼叫落入page cache之後便可以直接傳回,page cache中的快取資料由內核回寫執行緒在適當時機負責同步到底層的儲存介質之上,當然應用程式也可以主動發起回寫過程(如fsync系統呼叫)來確保資料儘快同步到儲存介質上,從而避免系統崩潰或者掉電帶來的資料不一致性。快取IO可以帶來很多好處,首先應用程式將IO丟給page cache之後就直接傳回了,避免了每次IO都將整個IO協議棧走一遍,從而減少了IO的延遲。其次,page cache中的快取最後以頁或塊為單位進行回寫,並非應用程式向page cache中提交了幾次IO, 回寫的時候就需要往通用塊層提交幾次IO, 這樣在提交時間上不連續但在空間上連續的小塊IO請求就可以合併到同一個快取頁中一併處理。再次,如果應用程式之前產生的IO已經在page cache中,後續又產生了相同的IO,那麼只需要將後到的IO改寫page cache中的舊IO,這樣一來如果應用程式頻繁的操作檔案的同一個位置,我們只需要向底層儲存設備提交最後一次IO就可以了。最後,應用程式寫入到page cache中的快取資料可以為後續的讀操作服務,讀取資料的時候先搜索page cache,如果命中了則直接傳回,如果沒命中則從底層讀取並儲存到page cache中,下次再讀的時候便可以從page cache中命中。

② 非快取IO(帶蓄流),對應圖中的路徑,這種IO繞過檔案系統層的cache。用戶在打開要讀寫的檔案的時候需要加上“O_DIRECT”標誌,意為直接IO,不讓檔案系統的page cache介入。從用戶角度而言,應用程式能直接控制的IO形式除了上面提到的快取IO”,剩下的IO都走的這種形式,就算檔案打開時加上了 ”O_SYNC” 標誌,最終產生的IO也會進入蓄流鏈表(圖中的Plug List)。如果應用程式在用戶空間自己做了快取,那麼就可以使用這種IO方式,常見的如資料庫應用。

③ 非快取IO(不帶蓄流),對應圖中的路徑,內核通用塊層的蓄流機制只給內核空間提供了接口來控制IO請求是否蓄流,用戶空間行程沒有辦法控制提交的IO請求進入通用塊層的時候是否蓄流。嚴格的說用戶空間直接產生的IO都會走蓄流路徑,哪怕是IO的時候附上了“O_DIRECT”  ”O_SYNC”標誌(可以參考《Linux通用塊層介紹(part1: bio層)》中的蓄流章節),用戶間接產生的IO,如檔案系統日誌資料、元資料,有的不會走蓄流路徑而是直接進入調度佇列儘快得到調度。註意一點,通用塊層的蓄流只提供機制和接口而不提供策略,至於需不需要蓄流、何時蓄流完全由內核中的IO派發者決定。

應用程式不管使用圖中哪條IO路徑,內核都會想方設法對IO進行合併。內核為促進這種合併,在IO協議棧上設置了三個最佳狙擊點:

Cache (頁高速快取)

Plug List (蓄流鏈表)

Elevator Queue (調度佇列)

cache 合併

IO處在檔案系統層的page cache中時只有IO資料,還沒有IO請求(bio  request,只有page cache在讀寫的時候才會產生IO請求。本文主要介紹IO請求在通用塊層的合併,因此對於IO cache 層的合併只做現象分析,不深入到內部邏輯和代碼細節。 
如果是快取IO,用戶行程提交的寫資料會積聚在page cache 中。cache 儲存IO資料的基本單位為page,大小一般為4K, 因此cache 又叫頁高速快取, 用戶行程提交的小塊資料可以快取到cache中的同一個page中,最後回寫執行緒將一個page中的資料一次性提交給通用塊層處理。以dd程式寫一個裸設備為例,每次寫1K資料,連續寫16次:

dd if=/dev/zero of=/dev/sdb bs=1k count=16

通過blktrace觀測的結果為: 

blktrace -d /dev/sdb -o – | blkparse -i –

bio請求在通用塊層的處理情況主要是通過第六列反映出來的如果對blkparse的輸出不太瞭解,可以 man 一下blktrace。對照每一行的輸出來看看應用程式產生的寫IO經由page cache之後是如何派發到通用塊層的:

現階段只關註IO是如何從page cache中派發到通用塊層的,所以後面的瀉流、派發過程沒有貼出來。回寫執行緒–kworker8個扇區(扇區大小為512B, 8個扇區為4K對應一個page大小)為單位將dd程式讀寫的1K資料塊派發給通用塊層處理。dd程式寫了16次,回寫執行緒只寫了4次(對應四次Q),page cache的快取功能有效的合併了應用程式直接產生的IO資料。
檔案系統層的page cache對讀IO也有一定的作用,帶快取的讀IO會觸發檔案系統層的預讀機制,所謂預讀有專門的預讀演算法,通過判斷用戶行程IO趨勢,提前將儲存介質上的資料塊讀入page cache中,下次讀操作來時可以直接從page cache中命中,而不需要每次都發起對塊設備的讀請求。還是以dd程式讀一個裸設備為例,每次讀1K資料,連續讀16次:

dd if=/dev/sdb of=/dev/zero bs=1K count=16

通過blktrace觀測的結果為:

blktrace -d /dev/sdb -o – | blkparse -i –

同樣只關註IO是如何從上層派發到通用塊層的,不關註IO在通用塊層的具體情況,先不考慮PIUDC等操作,那麼上面的輸出可以簡單解析為:

讀操作是同步的,所以觸發讀請求的是dd行程本身。dd行程發起了16次讀操作,總共讀取16K資料,但是預讀機制只向底層發送了兩次讀請求,分別為0+32(16K), 32+64(32K),總共預讀了16 + 32 = 48K資料,並儲存到cache中,多預讀的資料可以為後續的讀操作服務。

plug 合併

在閱讀本節之前可以先回顧下linuxer公眾號中介紹biorequest的系列文章,熟悉IO請求在通用塊層的處理,以及蓄流(plug)機制的原理和接口。特別推薦宋寶華老師寫的《檔案讀寫(BIO)波瀾壯闊的一生》,通俗易懂地介紹了一個檔案io的生命周期。

每個行程都有一個私有的蓄流鏈表,行程在往通用塊層派發IO之前如果開啟了蓄流功能,那麼IO請求在被髮送給IO調度器之前都儲存在蓄流鏈表中,直到泄流(unplug)的時候才批量交給調度器。蓄流的主要目的就是為了增加請求合併的機會,bio在進入蓄流鏈表之前會嘗試與蓄流鏈表中儲存的request進行合併,使用的接口為blk_attempt_plug_merge(). 本文是基於內核4.17分析的,原始碼來源於4.17-rc1

代碼遍歷蓄流鏈表中的request,使用blk_try_merge找到一個能與bio合併的request並判斷合併型別,蓄流鏈表中的合併型別有三種:ELEVATOR_BACK_MERGEELEVATOR_FRONT_MERGEELEVATOR_DISCARD_MERGE。普通檔案IO操作只會進行前兩種合併,第三種是丟棄操作的合併,不是普通的IO的合併,故不討論。

bio後向合併 (ELEVATOR_BACK_MERGE)

為了驗證IO請求在通用塊層的各種合併形式,準備了以下測試程式,該測試程式使用內核原生支持的異步IO引擎,可異步地向內核一次提交多個IO請求。為了減少page cache和檔案系統的干擾,使用O_DIRECT的方式直接向裸設備派發IO

iotc.c

/* dispatch 3 4k-size ios using the io_type specified by user */

#define NUM_EVENTS  3

#define ALIGN_SIZE  4096

#define WR_SIZE  4096

enum io_type {

SEQUENCE_IO,/* dispatch 3 ios: 0-4k(0+8), 4-8k(8+8), 8-12k(16+8) */

REVERSE_IO,/* dispatch 3 ios: 8-12k(16+8), 4-8k(8+8),0-4k(0+8) */

INTERLEAVE_IO, /* dispatch 3 ios: 8-12k(16+8), 0-4k(0+8),4-8k(8+8) */ ,

IO_TYPE_END

};


int io_units[IO_TYPE_END][NUM_EVENTS] = {

{0, 1, 2}, /* corresponding to SEQUENCE_IO */

{2, 1, 0}, /* corresponding to REVERSE_IO */

{2, 0, 1} /* corresponding to INTERLEAVE_IO */

};


char *io_opt = “srid:”; /* acceptable options */


int main(int argc, char *argv[])

{

int fd;

io_context_t ctx;

struct timespec tms;

struct io_event events[NUM_EVENTS];

struct iocb iocbs[NUM_EVENTS],

                *iocbp[NUM_EVENTS];

int i, io_flag = -1;;

void *buf;

bool hit = false;

char *dev = NULL, opt;


/* io_flag and dev got set according the options passed by user , don’t paste the code of parsing here to shrink space */

fd = open(dev, O_RDWR | __O_DIRECT);


/* we can dispatch 32 IOs at 1 systemcall */

ctx = 0;


io_setup(32, &ctx;);

posix_memalign(&buf;,ALIGN_SIZE,WR_SIZE);


/* prepare IO request according to io_type */

for (i = 0; i < NUM_EVENTS; iocbp[i] = iocbs + i, ++i)

io_prep_pwrite(&iocbs;[i], fd, buf, WR_SIZE, io_units[io_flag][i] * WR_SIZE);


/* submit IOs using io_submit systemcall */

io_submit(ctx, NUM_EVENTS, iocbp);


/* get the IO result with a timeout of 1S*/

tms.tv_sec = 1;

tms.tv_nsec = 0;

io_getevents(ctx, 1, NUM_EVENTS, events, &tms;);


return 0;

}

測試程式接收兩個引數,第一個為作用的設備,第二個為IO型別,定義了三種IO型別:SEQUENCE_IO(順序),REVERSE_IO(逆序),INTERLEAVE_IO(交替)分別用來驗證蓄流階段的bio後向合併、前向合併和泄流階段的request合併。為了減少篇幅,此處貼出的原始碼刪除了選項解析和容錯處理,只保留主幹,原版位於:https://github.com/liuzhengyuan/iotc

為驗證bio在蓄流階段的後向合併,用上面的測試程式iotc順序派發三個寫io:

# ./iotc  -d  /dev/sdb  -s

-d 指定作用的設備sdb -s 指定IO方式為SEQUENCE_IO(順序),表示順序發起三個寫請求: bio0(0 + 8), bio1(8 + 8), bio2(16 + 8)。通過blktrace來觀察iotc派發的bio請求在通用塊層蓄流鏈表中的合併情況:

blktrace -d /dev/sdb -o – | blkparse -i –

上面的輸出可以簡單解析為:

第一個bio(bio0)進入通用塊層時,此時蓄流鏈表為空,於是申請一個request並用bio0初始化,再將request添加進蓄流鏈表,同時告訴blktrace蓄流已正式工作。第二個bio(bio1)到來的時候會走blk_attempt_plug_merge的邏輯,嘗試呼叫bio_attempt_back_merge與蓄流鏈表中的request合併,發現正好能合併到第一個bio所在的request尾部,於是直接傳回。第三個bio(bio2)的處理與第二個同理。通過蓄流合併之後,三個IO請求最終合併成了一個request(0 + 24)。用一副圖來展示整個合併過程:

bio前向合併 (ELEVATOR_FRONT_MERGE)

為驗證bio在蓄流階段的前向合併,使用iotc逆序派發三個寫io:

# ./iotc  -d  /dev/sdb  –r

-r 指定IO方式為REVERSE_IO(逆序),表示逆序發起三個寫請求: bio0(16 + 8),bio1(8 + 8), bio2(0 + 8)blktrace的觀察結果為:

blktrace -d /dev/sdb -o – | blkparse -i –

上面的輸出可以簡單解析為:

與前面的後向合併相比,唯一的區別是合併方式由之前的”M”變成了現在的”F”,即在blk_attempt_plug_merge中走的是bio_attempt_front_merge分支。通過下麵的圖來展示前向合併過程:

“plug 合併不會做requestrequest的進階合併,蓄流鏈表中的request之間的合併會在泄流的時候做,即在下麵介紹的“elevator 合併中做。

elevator 合併

上面講到的蓄流鏈表合併是為行程內的IO請求服務的,每個行程只往自己的蓄流鏈表中提交IO請求,行程間的蓄流鏈表相互獨立,互不干涉。但是,多個行程可以同時對一個設備發起IO請求,那麼通用塊層還需要提供一個節點,讓行程間的IO請求有機會進行合併。一個塊設備有且僅有一個請求佇列(調度佇列),所有對塊設備的IO請求都需要經過這個公共節點,因此調度佇列(Elevator Queue)是IO請求合併的另一個節點。 
先回顧一下通用塊層處理IO請求的核心函式:blk_queue_bio(), 上層派發的bio請求都會流經該函式,或將bio蓄流到Plug List,或將bio合併到Elevator Queue, 或將bio生成request直接插入到Elevator Queueblk_queue_bio()的主要處理流程為:

其中”A”標識的合併到蓄流鏈表的request就是上一章介紹的“plug 合併bio如果不能合併到蓄流鏈表中接下來會嘗試合併到“B”標識的合併到調度佇列的request合併到調度佇列的request只是“elevator 合併的第一個點。你可能已經發現了blk_queue_bio()bio合併到蓄流鏈表或者將request添加進蓄流鏈表之後就沒管了,從路徑可以發現蓄流鏈表中的request最終都是要交給電梯調度佇列的,這正是”elevator 合併的第二個點,關於泄流的時機請參考我之前寫的《Linux通用塊層介紹(part1: bio層)》。下麵分別介紹這兩個合併點:

bio合併到elevator

先看B表示的代碼段:

blk_queue_bio:

        switch (elv_merge(q, &req;, bio)) {

        case ELEVATOR_BACK_MERGE:

                if (!bio_attempt_back_merge(q, req, bio))

                        break;

                elv_bio_merged(q, req, bio);

                free = attempt_back_merge(q, req);

                if (free)

                        __blk_put_request(q, free);

                else

                        elv_merged_request(q, req, ELEVATOR_BACK_MERGE);

                goto out_unlock;

        case ELEVATOR_FRONT_MERGE:

                if (!bio_attempt_front_merge(q, req, bio))

                        break;

                elv_bio_merged(q, req, bio);

                free = attempt_front_merge(q, req);

                if (free)

                        __blk_put_request(q, free);

                else

                        elv_merged_request(q, req, ELEVATOR_FRONT_MERGE);

                goto out_unlock;

        default:

                break;

        }


合併邏輯基本與”plug 合併相似,先呼叫elv_merge接口判斷合併型別,然後根據是後向合併或是前向合併分別呼叫bio_attempt_back_mergebio_attempt_front_merge進行合併操作,由於操作物件從蓄流鏈表變成了電梯調度佇列,bio合併完了之後還需額外乾幾件事:

1. 呼叫elv_bio_merged, 該函式會呼叫電梯調度器註冊的elevator_bio_merged_fn接口來通知調度器做相應的處理,對於deadline調度器而言該接口為NULL

2.尋找進階合併,參考我之前寫的《Linux通用塊層介紹(part2: request層)》中對進階合併的描述,如果bio產生了後向合併,則呼叫attempt_back_merge試圖進行後向進階合併,如果bio產生了前向合併,則呼叫attempt_front_merge企圖進行前向進階合併。deadline的進階合併接口為deadline_merged_requests, 被合併的request會從調度佇列中刪除。通過下麵的圖示來展示後向進階合併過程,前向進階合併同理。

    3. 如果產生了進階合併,則被合併的request可以釋放了,參考上圖,可呼叫blk_put_request進行回收。如果只產生了bio合併,合併後的request的長度和扇區地址都會發生變化,需要呼叫elv_merged_request->elevator_merged_fn來更新合併後的請求在調度佇列的位置。deadline對應的接口為deadline_merged_request,其相應的操作為將合併的request先從調度佇列移出再重新插進去。

“bio合併到elevator”的合併形式只會發生在行程間,即只有一個行程在IO的時候不會產生這種合併形式,原因在於行程在向調度佇列派發IO請求或者試圖與將bio與調度佇列中的請求合併的時候是持有設備的佇列鎖得,其他行程是不能往調度佇列派發請求,這也是通用塊層單佇列通道窄需要發展多佇列的主要原因之一,只有行程在將調度佇列中的request逐個派發給驅動層的時候才會將設備佇列鎖重新打開,即只有當一個行程在將調度佇列中request派發給驅動的時候其他行程才有機會將bio合併到還未派發完的request中。所以想通過簡單的IO測試程式來捕捉這種形式的合併比較困難,這對兩個IO行程的IO產生時序有非常高的要求,故不演示。有興趣的可以參考上面的github倉庫,裡面有patch對內核特定的請求派發位置加上延時來改變IO請求本來的時序,從而讓測試程式人為的達到這種碰撞效果。

request在泄流的時候合併到elevator

通用塊層的泄流接口為:blk_flush_plug_list(), 該接口主要的處理邏輯如下圖所示

其中請求合併發生的點在__elv_add_request()blk_flush_plug_list會遍歷蓄流鏈表中的每個request,然後將每個request通過 _elv_add_request接口添加到調度佇列中,添加的過程中會嘗試與調度佇列中已有的request進行合併。

__elv_add_request:

        case ELEVATOR_INSERT_SORT_MERGE:

                /*

                 * If we succeed in merging this request with one in the

                 * queue already, we are done – rq has now been freed,

                 * so no need to do anything further.

                 */

                if (elv_attempt_insert_merge(q, rq))

                        break;

                /* fall through */

        case ELEVATOR_INSERT_SORT:

                BUG_ON(blk_rq_is_passthrough(rq));

                rq->rq_flags |= RQF_SORTED;

                q->nr_sorted++;

                if (rq_mergeable(rq)) {

                        elv_rqhash_add(q, rq);

                        if (!q->last_merge)

                                q->last_merge = rq;

                }

                q->elevator->type->ops.sq.elevator_add_req_fn(q, rq);

                break;


泄流時走的是ELEVATOR_INSERT_SORT_MERGE分支,正如註釋所說的先讓蓄流的request呼叫elv_attempt_insert_merge嘗試與調度佇列中的request合併,如果不能合併則落入到ELEVATOR_INSERT_SORT分支,該分支直接呼叫電梯調度器註冊的elevator_add_req_fn接口將新來的request插入到調度佇列合適的位置。其中elv_rqhash_add是將新加入到調度佇列的requesthash索引,這樣做的好處是加快從調度佇列尋找可合併的request的索引速度。 
在泄流的時候調度佇列中既有其他行程產生的request,也有當前行程從蓄流鏈表中派發的requestblk_flush_plug_list是先將所有request派發到調度佇列再一次性queue_unplugged,而不是派發一個requestqueue_unplugged)。所以“request在泄流的時候合併到elevator”既是行程內的,也可以是行程間的。 
elv_attempt_insert_merge的實現只做request間的後向合併,即只會將一個request合併到調度佇列中的request的尾部。這對於單行程IO而言足夠了,因為blk_flush_plug_list在泄流的時候已經將蓄流鏈表中的request進行了list_sort(按扇區排序)。筆者曾經提交過促進行程間request的前向合併的patch(見github),但沒被接收,maintainer–Jens的解析是這種IO場景很難發生,如果產生這種IO場景基本是應用程式設計不合理。通過增加時間和空間來優化一個並不常見的場景並不可取。 
最後通過一個例子來驗證行程內“request在泄流的時候合併到elevator”,行程間的合併同樣對請求派發時序有很強的要求,在此不演示,github中有相應的測試patch和測試方法。iotc使用下麵的方式派發三個寫io:

# ./iotc  -d  /dev/sdb  –i

-i指定IO方式為INTERLEAVE_IO(交替),表示按扇區交替的方式發起三個寫請求: bio0(16 + 8),bio1(8 + 8), bio2(0 + 8)blktrace的觀察結果為:

blktrace -d /dev/sdb -o – | blkparse -i –

上面的輸出可以簡單解析為:

bio0(16 + 8)先到達plug listbio1(0+8)到達時發現不能與plug list中的request合併,於是申請一個request添加到plug listbio2(8+8)到達時首先與bio1進行後向合併。之後行程觸發泄流,泄流接口函式會將plug list中的request排序,因此request(0+16)先派發到調度佇列,此時調度佇列為空不能進行合併。然後派發request(16+8),派發時呼叫elv_attempt_insert_merge接口嘗試與調度佇列中的其他request進行合併,發現可以與request(0+16)進行後向合併,於是兩個request合併成一個,最後向設備驅動派發的只有一個request(0+24)。整個過程可以用下麵的圖來展示:

小結

通過cache plugelevator自上而下的三層狙擊,應用程式產生的IO能最大限度的進行合併,從而提升IO帶寬,降低IO延遲,延長設備壽命。page cache打頭陣,既做資料快取又做IO合併,主要是針對小塊IO進行合併,因為使用記憶體頁做快取,所以合併後的最大IO單元為頁大小,當然對於大塊IO,page cache也會將它拆分成以頁為單位下發,這不影響最終的效果,因為後面還有plug  elevator補刀。plug list竭盡全力合併行程內產生的IO, 從設備的角度而言行程內產生的IO相關性更強,合併的可能性更大,plug list設計位於elevator queue之上而且又是每個行程私有的,因此plug list既有利於IO合併,又減輕了elevator queue的負擔。elevator queue更多的是承擔行程間IO的合併,用來彌補plug list對行程間合併的不足,如果是帶快取的IO,這種IO合併基本上不會出現。從實際應用角度出發,IO合併更多的是發生在page cacheplug list中。

(完)

赞(0)

分享創造快樂