作者簡介: 劉正元,來自天津麒麟(kylinos.cn), linux核心愛好者,對核心IO子系統和核心除錯工具這塊比較感興趣,向內核上游核心貢獻過一些,目前在公司負責檔案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協議棧上設定了三個最佳狙擊點:
l Cache (頁高速快取)
l Plug List (蓄流連結串列)
l 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中派發到通用塊層的,所以後面的瀉流、派發過程沒有貼出來。回寫執行緒–kworker以8個扇區(扇區大小為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在通用塊層的具體情況,先不考慮P,I,U,D,C等操作,那麼上面的輸出可以簡單解析為:
讀操作是同步的,所以觸發讀請求的是dd行程本身。dd行程發起了16次讀操作,總共讀取16K資料,但是預讀機制只向底層發送了兩次讀請求,分別為0+32(16K), 32+64(32K),總共預讀了16 + 32 = 48K資料,並儲存到cache中,多預讀的資料可以為後續的讀操作服務。
plug 合併
在閱讀本節之前可以先回顧下linuxer公眾號中介紹bio和request的系列文章,熟悉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_MERGE,ELEVATOR_FRONT_MERGE,ELEVATOR_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 合併”不會做request與request的進階合併,蓄流連結串列中的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 Queue。blk_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_merge和bio_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是將新加入到排程佇列的request做hash索引,這樣做的好處是加快從排程佇列尋找可合併的request的索引速度。
在洩流的時候排程佇列中既有其他行程產生的request,也有當前行程從蓄流連結串列中派發的request(blk_flush_plug_list是先將所有request派發到排程佇列再一次性queue_unplugged,而不是派發一個request就queue_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 list,bio1(0+8)到達時發現不能與plug list中的request合併,於是申請一個request新增到plug list。bio2(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 、plug和elevator自上而下的三層狙擊,應用程式產生的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 cache和plug list中。
(完)