宋寶華:Linux文件讀寫(BIO)波瀾壯闊的一生

點擊上方“公衆號” 可以訂閱哦!

前言


網上關於BIO和塊設備讀寫流程的文章何止千萬,但是能夠讓你徹底讀懂讀明白的文章實在難找,可以說是越讀越糊塗!

我曾經跨過山和大海 也穿過人山人海

我曾經問遍整個世界 從來沒得到答案

本文用一個最簡單的read(fd, buf, 4096)的代碼,分析它從開始讀到讀結束,在整個Linux系統裏面波瀾壯闊的一生。本文涉及到的代碼如下:

#include <unistd.h>

#include <fcntl.h>

 

main()

{

        int fd;

        char buf[4096];

    

        sleep(30); //run ./funtion.sh to trace vfs_read of this process

        fd = open("file", O_RDONLY);

        read(fd, buf, 4096);

        read(fd, buf, 4096);

}

本文的寫作宗旨是:絕不裝逼,一定要簡單,簡單,再簡單!

本文適合:已經讀了很多亂七八糟的block資料,但是沒打通脈絡的讀者;

本文不適合:完全不知道block子系統是什麼的讀者,和完全知道block子系統是什麼的讀者

Page cache與預讀


在Linux中,內存充當硬盤的page cache,所以,每次讀的時候,會先check你讀的那一部分硬盤文件數據是否在內存命中,如果沒有命中,纔會去硬盤;如果已經命中了,就直接從內存裏面讀出來。如果是寫的話,應用如果是以非SYNC方式寫的話,寫的數據也只是進內存,然後由內核幫忙在適當的時機writeback進硬盤。

代碼中有2行read(fd, buf, 4096),第1行read(fd, buf, 4096)發生的時候,顯然”file”文件中的數據都不在內存,這個時候,要執行真正的硬盤讀,app只想讀4096個字節(一頁),但是內核不會只是讀一頁,而是要多讀,提前讀,把用戶現在不讀的也先讀,因爲內核懷疑你讀了一頁,接着要連續讀,懷疑你想讀後面的。與其等你發指令,不如提前先斬後奏(存儲介質執行大塊讀比多個小塊讀要快),這個時候,它會執行預讀,直接比如讀4頁,這樣當你後面接着讀第2-4頁的硬盤數據的時候,其實是直接命中了。

所以這個代碼路徑現在是 :

當你執行完第一個read(fd, buf, 4096)後,”file”文件的0~16KB都進入了pagecache,同時內核會給第2頁標識一個PageReadahead標記,意思就是如果app接着讀第2頁,就可以預判app在做順序讀,這樣我們在app讀第2頁的時候,內核可以進一步異步預讀。

第一個read(fd,buf, 4096)之前,page cache命中情況(都不命中):

第一個read(fd,buf, 4096)之後,page cache命中情況:

我們緊接着又碰到第二個read(fd, buf, 4096),它要讀硬盤文件的第2頁內容,這個時候,第2頁是page cache命中的,這一次的讀,由於第2頁有PageReadahead標記,讓內核覺得app就是在順序讀文件,內核會執行更加激進的異步預讀,比如讀文件的第16KB~48KB。

所以第二個read(fd,buf, 4096)的代碼路徑現在是 :

第二個read(fd,buf, 4096)之前,page cache命中情況:

第二個read(fd,buf, 4096)之後,page cache命中情況:

內存到硬盤的轉換


剛纔我們提到,第一次的read(fd, buf, 4096),變成了讀硬盤裏面的16KB數據,到內存的4個頁面(對應硬盤裏面文件數據的第0~16KB)。但是我們還是不知道,硬盤裏面文件數據的第0~16KB在硬盤的哪些位置?我們必須把內存的頁,轉化爲硬盤裏面真實要讀的位置。

在Linux裏面,用於描述硬盤裏面要真實操作的位置與page cache的頁映射關係的數據結構是bio。相信大家已經見到bio一萬次了,但是就是和真實的案例對不上。

bio的定義如下(include/linux/blk_types.h):

struct bio_vec {

       struct page    *bv_page;

       unsigned int  bv_len;

       unsigned int  bv_offset;

};

struct bio {

       struct bio              *bi_next; /* request queue link */

       struct block_device      *bi_bdev;

       …

       struct bvec_iter     bi_iter;

 

       /* Number of segments in this BIO after

        * physical address coalescing is performed.

        */

       unsigned int         bi_phys_segments; 

       …

       bio_end_io_t         *bi_end_io;

 

       void               *bi_private;

 

       unsigned short            bi_vcnt;  /* how many bio_vec's */

       atomic_t        bi_cnt;           /* pin count */

       struct bio_vec       *bi_io_vec;     /* the actual vec list */

       …

};

它是一個描述硬盤裏面的位置與page cache的頁對應關係的數據結構,每個bio對應的硬盤裏面一塊連續的位置,每一塊硬盤裏面連續的位置,可能對應着page cache的多頁,或者一頁,所以它裏面會有一個bio_vec *bi_io_vec的表。

我們現在假設2種情況

第1種情況是page_cache_sync_readahead()要讀的0~16KB數據,在硬盤裏面正好是順序排列的(是否順序排列,要查文件系統,如ext3、ext4),Linux會爲這一次4頁的讀,分配1個bio就足夠了,並且讓這個bio裏面分配4個bi_io_vec,指向4個不同的內存頁:

第2種情況是page_cache_sync_readahead()要讀的0~16KB數據,在硬盤裏面正好是完全不連續的4塊 (是否順序排列,要查文件系統,如ext3、ext4),Linux會爲這一次4頁的讀,分配4個bio,並且讓這4個bio裏面,每個分配1個bi_io_vec,指向4個不同的內存頁面:

當然你還可以有第3種情況,比如0~8KB在硬盤裏面連續,8~16KB不連續,那可以是這樣的:

其他的情況請類似推理…完成這項工作的史詩級的代碼就是mpage_readpages()

mpage_readpages()會間接調用ext4_get_block(),真的搞清楚0~16KB的數據,在硬盤裏面的擺列位置,並依據這個信息,轉化出來一個個的bio。

bio和request的三進三出


人生,說到最後,簡單得只有生死兩個字。但由於有了命運的浮沉,由於有了人世的冷暖,簡單的過程才變得跌宕起伏,紛繁複雜。小平三落三起,最終建立了不朽的功勳。曼德拉受非人待遇在監獄服刑數十年,終成世界公認的領袖。走向自由之路不會平坦,鬥爭就是生活。與天鬥,其樂無窮;與地鬥,其樂無窮;與Linux鬥,痛苦無窮!

bio產生後,到最終的完成,同樣經歷了三進三出的隊列,這個過程的艱辛和痛苦,讓人欲罷不能,欲說還休,求生不得求死不能。

這三步是:

1.原地蓄勢

把bio轉化爲request,把request放入進程本地的plug隊列;蓄勢多個request後,再進行泄洪。

2.電梯排序

進程本地的plug隊列的request進入到電梯,進行再次的合併、排序,執行QoS的排隊,之後按照QoS的結果,分發給塊設備驅動。電梯內部的實現,可以有各種各樣的隊列。

3.分發執行

電梯分發的request,被設備驅動的request_fn()挨個取出來,派發真正的硬件讀寫命令到硬盤。這個分發的隊列,一般就是我們在塊設備驅動裏面見到的request_queue了。

下面我們再一一呈現,這三進三出。

原地蓄勢

在Linux中,每個task_struct(對應一個進程,或輕量級進程——線程),會有一個plug的list。什麼叫plug呢?類似於葛洲壩和三峽,先蓄水,當app需要發多個bio請求的時候,比較好的辦法是先蓄勢,而不是一個個單獨發給最終的硬盤。

這個類似你現在有10個老師,這10個老師開學的時候都接受學生報名。然後有一個大的學生隊列,如果每個老師有一個學生報名的時候,都訪問這個唯一的學生隊列,那麼這個隊列的操作會變成一個重要的鎖瓶頸:

如果我們換一個方法,讓每個老師有學生報名的時候,每天的報名的學生掛在老師自己的隊列上面,老師的隊列上面掛了很多學生後,一天之後再泄洪,掛到最終的學生隊列,則可以避免這個問題,最終小隊列融合進大隊列的時候控制住時序就好。

你會發現,代碼路徑是這樣的:

read_pages()函數先把閘門拉上,然後發起一系列bio後,再通過blk_finish_plug()的調用來泄洪。

在這個蓄勢的過程中,還要完成一項重要的工作,就是make request(造請求)。這個完成“造請求”的史詩級的函數,一般是void blk_queue_bio(struct request_queue *q, struct bio *bio),位於block/blk-core.c。

它會嘗試把bio合併進入一個進程本地plug list裏面的一個request,如果無法合併,則造一個新的request。request裏面包含一個bio的list,這個list的bio對應的硬盤位置,最終在硬盤上是連續存放的。

下面我們假設"file"的第0~16KB在硬盤的存放位置爲:

根據我們前面"內存到硬盤的轉換"一節舉的例子,這屬於在硬盤裏面完全不連續的"情況2",於是這4塊數據,會被史詩級的mpage_readpages()轉化爲4個bio。

當他們進入進程本地的plug list的時候,由於最開始plug list爲空,100顯然無法與誰合併,這樣形成一個新的request0。

Bio1也無法合併進request0,於是得到新的request1。

Bio2正好可以合併進request1,於是Bio1合併進request1。

Bio3對應硬盤的200塊,無法合併,於是得到新的request2。

現在進程本地plug list上的request排列如下:

泄洪的時候,進程本地的plug list的request,會通過調用elevator調度算法的elevator_add_req_fn() callback函數,被加入電梯的隊列。

電梯排序

當各個進程本地的plug list裏面的request被泄洪,以排山倒海之勢進入的,不是最終的設備驅動(不會直接被拍死在沙灘上的),而是一個電梯排隊算法,進行再一次的排隊。這個電梯調度,其實目的3個:

  1. 進一步的合併request

  2. 把request對硬盤的訪問變得順序化

  3. 執行QoS

電梯的內部實現可以非常靈活,但是入口是elevator_add_req_fn(),出口是elevator_dispatch_fn()。

合併和排序都好理解,下面我們重點解釋QoS(服務質量)。想象你家裏的寬帶,有迅雷,有在線電影,有機頂盒看電視。

當你只用迅雷下電影的時候,你當然可以全速的下電影,但是當你還看電視,在線看電影,這個時候,你可能會對迅雷限流,以保證相關電視盒電影的服務質量。

電梯調度裏面也執行同樣的邏輯,比如CFQ調度算法,可以根據進程的ionice,調整不同進程訪問硬盤的時候的優先級。比如,如下2個優先級不同的dd

# ionice-c 2 -n 0 cat /dev/sda > /dev/null&

# ionice -c 2 -n 7 cat /dev/sda >/dev/null&

最終訪問硬盤的速度是不一樣的,一個371M,一個只有72M。

所以當泄洪開始,漫江碧透,百舸爭流,誰能到中流擊水,浪遏飛舟?QoS是一個關於一將功成萬骨枯的故事。

目前常用的IO電梯調度算法有:cfq, noop, deadline。詳細的區別不是本文的重點,建議閱讀《劉正元:Linux 通用塊層之DeadLine IO調度器》從瞭解deadline的實現開始。

分發執行

到了最後要交差的時刻了,設備驅動的request_fn()通過調用電梯調度算法的elevator_dispatch_fn()取出經過QoS排序後的request併發命令給最終的存儲設備執行I/O動作。

static void xxx_request_fn(struct request_queue *q)

{

        struct request *req;

        struct bio *bio;

 

        while ((req = blk_peek_request(q)) != NULL) {

                struct xxx_disk_dev *dev = req->rq_disk->private_data;

                if (req->cmd_type != REQ_TYPE_FS) {

                        printk (KERN_NOTICE "Skip non-fs request\n");

                        blk_start_request(req);

                        __blk_end_request_all(req, -EIO);

                        continue;

                }

 

                blk_start_request(req);

                __rq_for_each_bio(bio, req)

                        xxx_xfer_bio(dev, bio);

        }

}

request_fn()只是派發讀寫事件和命令,最終的完成一般是在另外一個上下文,而不是發起IO的進程。request處理完成後,探知到IO完成的上下文會以blk_end_request()的形式,通知等待IO請求完成的本進程。主動發起IO的進程的代碼序列一般是:

  • submit_bio()

  • io_schedule(),放棄CPU。

blk_end_request()一般把io_schedule()後放棄CPU的進程喚醒。io_schedule()的這段等待時間,會計算到進程的iowait時間上,詳見:《朱輝(茶水):Linux Kernel iowait 時間的代碼原理》

用Ftrace抓所有流程


本文所涉及到的所有流程,都可以用ftrace跟蹤到。這樣可以瞭解更多更深刻的細節。

        char buf[4096];

    

        sleep(30); //run ./funtion.sh to trace vfs_read of this process

        fd = open("file", O_RDONLY);

        read(fd, buf, 4096);

在上述代碼的中間,我特意留下了30秒的延時,在這個延時的空擋,你可以啓動如下的腳本,來對整個過程進行function graph的trace,抓取進程對vfs_read()開始後的調用棧:

#!/bin/bash

 

debugfs=/sys/kernel/debug

echo nop > $debugfs/tracing/current_tracer

echo 0 > $debugfs/tracing/tracing_on

echo `pidof read` > $debugfs/tracing/set_ftrace_pid

echo function_graph > $debugfs/tracing/current_tracer

echo vfs_read > $debugfs/tracing/set_graph_function

echo 1 > $debugfs/tracing/tracing_on

筆者也是通過ftrace的結果,用vim打開,逐句分析的。關於ftrace使用的詳細方法,可以閱讀《宋寶華:關於Ftrace的一個完整案例》

最後的話


本文描述的是主幹,許多的細節和代碼分支沒有涉及,因爲在本文描述太多的分支,會讓讀者抓不住主幹。很多分支都沒有介紹,比如unplug的泄洪,除了可以人爲的blk_finish_plug()泄洪外,也會發生plug隊列較滿的時候,以及進程睡眠schedule()的時候的自動泄洪。另外,關於寫,後面的三進三出的過程,基本與讀類似,但是寫有個page cache堆積和writeback的啓動機制,是read所沒有的。

(完)

Linux閱碼場原創精華文章彙總

更多精彩,盡在"Linux閱碼場",掃描下方二維碼關注

如果您覺得文章不錯,請點一點右下角“在看”吧~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章