1. 程式人生 > >《Linux核心設計與實現》讀書筆記(十四)- 塊I/O層

《Linux核心設計與實現》讀書筆記(十四)- 塊I/O層

最近太忙,居然過了2個月才更新第十四章。。。。

主要內容:

  • 塊裝置簡介
  • 核心訪問塊裝置的方法
  • 核心I/O排程程式

1. 塊裝置簡介

I/O裝置主要有2類:

  • 字元裝置:只能順序讀寫裝置中的內容,比如 串列埠裝置,鍵盤
  • 塊裝置:能夠隨機讀寫裝置中的內容,比如 硬碟,U盤

字元裝置由於只能順序訪問,所以應用場景也不多,這篇文章主要討論塊裝置。

塊裝置是隨機訪問的,所以塊裝置在不同的應用場景中存在很大的優化空間。

塊裝置中最重要的一個概念就是塊裝置的最小定址單元。

塊裝置的最小定址單元就是扇區,扇區的大小是2的整數倍,一般是 512位元組。

扇區是物理上的最小定址單元,而邏輯上的最小定址單元是塊。

為了便於檔案系統管理,塊的大小一般是扇區的整數倍,並且小於等於頁的大小。

檢視扇區和I/O塊的方法:

[[email protected]]$ sudo fdisk -l

WARNING: GPT (GUID Partition Table) detected on '/dev/sda'! The util fdisk doesn't support GPT. Use GNU Parted.


Disk /dev/sda: 500.1 GB, 500107862016 bytes, 976773168 sectors
Units = sectors of 1 * 512 = 512
bytes Sector size (logical/physical): 512 bytes / 4096 bytes I/O size (minimum/optimal): 4096 bytes / 4096 bytes Disk identifier: 0x00000000

上面的 Sector size 就是扇區的值,I/O size就是 塊的值

從上面顯示的結果,我們發現有個奇怪的地方,扇區的大小有2個值,邏輯大小是 512位元組,而物理大小卻是 4096位元組。

其實邏輯大小 512位元組是為了相容以前的軟體應用,而實際物理大小 4096位元組是由於硬碟空間越來越大導致的。

2. 核心訪問塊裝置的方法

核心通過檔案系統訪問塊裝置時,需要先把塊讀入到記憶體中。所以檔案系統為了管理塊裝置,必須管理[塊]和記憶體頁之間的對映。

核心中有2種方法來管理 [] 和記憶體頁之間的對映。

  • 緩衝區和緩衝區頭
  • bio

2.1 緩衝區和緩衝區頭

每個 [] 都是一個緩衝區,同時對每個 [] 都定義一個緩衝區頭來描述它。

由於 [] 的大小是小於記憶體頁的大小的,所以每個記憶體頁會包含一個或者多個 []

緩衝區頭定義在 <linux/buffer_head.h>: include/linux/buffer_head.h

struct buffer_head {
    unsigned long b_state;            /* 表示緩衝區狀態 */
    struct buffer_head *b_this_page;/* 當前頁中緩衝區 */
    struct page *b_page;            /* 當前緩衝區所在記憶體頁 */

    sector_t b_blocknr;        /* 起始塊號 */
    size_t b_size;            /* buffer在記憶體中的大小 */
    char *b_data;            /* 塊對映在記憶體頁中的資料 */

    struct block_device *b_bdev; /* 關聯的塊裝置 */
    bh_end_io_t *b_end_io;        /* I/O完成方法 */
     void *b_private;             /* 保留的 I/O 完成方法 */
    struct list_head b_assoc_buffers;   /* 關聯的其他緩衝區 */
    struct address_space *b_assoc_map;    /* 相關的地址空間 */
    atomic_t b_count;                    /* 引用計數 */
};

整個 buffer_head 結構體中的欄位是減少過的,以前的核心中欄位更多。

各個欄位的含義通過註釋都很明瞭,只有 b_state 欄位比較複雜,它涵蓋了緩衝區可能的各種狀態。

enum bh_state_bits {
    BH_Uptodate,    /* 包含可用資料 */
    BH_Dirty,    /* 該緩衝區是髒的(說明緩衝的內容比磁碟中的內容新,需要回寫磁碟) */
    BH_Lock,    /* 該緩衝區正在被I/O使用,鎖住以防止併發訪問 */
    BH_Req,        /* 該緩衝區有I/O請求操作 */
    BH_Uptodate_Lock,/* 由記憶體頁中的第一個緩衝區使用,使得該頁中的其他緩衝區 */

    BH_Mapped,    /* 該緩衝區是對映到磁碟塊的可用緩衝區 */
    BH_New,        /* 緩衝區是通過 get_block() 剛剛對映的,尚且不能訪問 */
    BH_Async_Read,    /* 該緩衝區正通過 end_buffer_async_read() 被非同步I/O讀操作使用 */
    BH_Async_Write,    /* 該緩衝區正通過 end_buffer_async_read() 被非同步I/O寫操作使用 */
    BH_Delay,    /* 緩衝區還未和磁碟關聯 */
    BH_Boundary,    /* 該緩衝區處於連續塊區的邊界,下一個塊不在連續 */
    BH_Write_EIO,    /* 該緩衝區在寫的時候遇到 I/O 錯誤 */
    BH_Ordered,    /* 順序寫 */
    BH_Eopnotsupp,    /* 該緩衝區發生 “不被支援” 錯誤 */
    BH_Unwritten,    /* 該緩衝區在磁碟上的位置已經被申請,但還有實際寫入資料 */
    BH_Quiet,    /* 該緩衝區禁止錯誤 */

    BH_PrivateStart,/* 不是表示狀態,分配給其他實體的私有資料區的第一個bit */
};

在2.6之前的核心中,主要就是通過緩衝區頭來管理 [塊] 和記憶體之間的對映的。

用緩衝區頭來管理核心的 I/O 操作主要存在以下2個弊端,所以在2.6開始的核心中,緩衝區頭的作用大大降低了。

- 弊端 1

對核心而言,操作記憶體頁是最為簡便和高效的,所以如果通過緩衝區頭來操作的話(緩衝區 即[塊]在記憶體中對映,可能比頁面要小),效率低下。

而且每個 [塊] 對應一個緩衝區頭的話,導致記憶體的利用率降低(緩衝區頭包含的欄位非常多)

- 弊端 2

每個緩衝區頭只能表示一個 [塊],所以核心在處理大資料時,會分解為對一個個小的 [塊] 的操作,造成不必要的負擔和空間浪費。

2.2 bio

bio結構體的出現就是為了改善上面緩衝區頭的2個弊端,它表示了一次 I/O 操作所涉及到的所有記憶體頁。

/*
 * I/O 操作的主要單元,針對 I/O塊和更低階的層 (ie drivers and
 * stacking drivers)
 */
struct bio {
    sector_t        bi_sector;    /* 磁碟上相關扇區 */
    struct bio        *bi_next;    /* 請求列表 */
    struct block_device    *bi_bdev; /* 相關的塊裝置 */
    unsigned long        bi_flags;    /* 狀態和命令標誌 */
    unsigned long        bi_rw;        /* 讀還是寫 */

    unsigned short        bi_vcnt;    /* bio_vecs的數目 */
    unsigned short        bi_idx;        /* bio_io_vect的當前索引 */

    /* Number of segments in this BIO after
     * physical address coalescing is performed.
     * 結合後的片段數目
     */
    unsigned int        bi_phys_segments;

    unsigned int        bi_size;    /* 剩餘 I/O 計數 */

    /*
     * To keep track of the max segment size, we account for the
     * sizes of the first and last mergeable segments in this bio.
     * 第一個和最後一個可合併的段的大小
     */
    unsigned int        bi_seg_front_size;
    unsigned int        bi_seg_back_size;

    unsigned int        bi_max_vecs;    /* bio_vecs數目上限 */
    unsigned int        bi_comp_cpu;    /* 結束CPU */

    atomic_t        bi_cnt;        /* 使用計數 */
    struct bio_vec        *bi_io_vec;    /* bio_vec 連結串列 */
    bio_end_io_t        *bi_end_io; /* I/O 完成方法 */
    void            *bi_private;    /* bio結構體建立者的私有方法 */
#if defined(CONFIG_BLK_DEV_INTEGRITY)
    struct bio_integrity_payload *bi_integrity;  /* data integrity */
#endif
    bio_destructor_t    *bi_destructor;    /* bio撤銷方法 */
    /*
     * We can inline a number of vecs at the end of the bio, to avoid
     * double allocations for a small number of bio_vecs. This member
     * MUST obviously be kept at the very end of the bio.
     * 內嵌在結構體末尾的 bio 向量,主要為了防止出現二次申請少量的 bio_vecs
     */
    struct bio_vec        bi_inline_vecs[0];
};

幾個重要欄位說明:

  • bio 結構體表示正在執行的 I/O 操作相關的資訊。
  • bio_io_vec 連結串列表示當前 I/O 操作涉及到的記憶體頁
  • bio_vec 結構體表示 I/O 操作使用的片段
  • bi_vcnt bi_io_vec連結串列中bi_vec的個數
  • bi_idx 當前的 bi_vec片段,通過 bi_vcnt(總數)和 bi_idx(當前數),就可以跟蹤當前 I/O 操作的進度

bio_vec 結構體很簡單,定義如下:

struct bio_vec {
    struct page    *bv_page;       /* 對應的物理頁 */
    unsigned int    bv_len;     /* 緩衝區大小 */
    unsigned int    bv_offset;  /* 緩衝區開始的位置 */
};

每個 bio_vec 都是對應一個頁面,從而保證核心能夠方便高效的完成 I/O 操作

bio, bio_vec和page之間的關係

2.3 2種方法的對比

緩衝區頭和bio並不是相互矛盾的,bio只是緩衝區頭的一種改善,將以前緩衝區頭完成的一部分工作移到bio中來完成。

bio中對應的是記憶體中的一個個頁,而緩衝區頭對應的是磁碟中的一個塊。

對核心來說,配合使用bio和緩衝區頭 比 只使用緩衝區頭更加的方便高效。

bio相當於在緩衝區上又封裝了一層,使得核心在 I/O操作時只要針對一個或多個記憶體頁即可,不用再去管理磁碟塊的部分。

使用bio結構體還有以下好處:

  • bio結構體很容易處理高階記憶體,因為它處理的是記憶體頁而不是直接指標
  • bio結構體既可以代表普通頁I/O,也可以代表直接I/O
  • bio結構體便於執行分散-集中(向量化的)塊I/O操作,操作中的資料可以取自多個物理頁面

3. 核心I/O排程程式

緩衝區頭和bio都是核心處理一個具體I/O操作時涉及的概念。

但是核心除了要完成I/O操作以外,還要排程好所有I/O操作請求,儘量確保每個請求能有個合理的響應時間。

下面就是目前核心中已有的一些 I/O 排程演算法。

3.1 linus電梯

為了保證磁碟定址的效率,一般會盡量讓磁頭向一個方向移動,等到頭了再反過來移動,這樣可以縮短所有請求的磁碟定址總時間。

磁頭的移動有點類似於電梯,所有這個 I/O 排程演算法也叫電梯排程。

linux中的第一個電梯排程演算法就是 linus本人所寫的,所有也叫做 linus 電梯。

linus電梯排程主要是對I/O請求進行合併和排序。

當一個新請求加入I/O請求佇列時,可能會發生以下4種操作:

  1. 如果佇列中已存在一個對相鄰磁碟扇區操作的請求,那麼新請求將和這個已存在的請求合併成一個請求
  2. 如果佇列中存在一個駐留時間過長的請求,那麼新請求之間查到佇列尾部,防止舊的請求發生飢餓
  3. 如果佇列中已扇區方向為序存在合適的插入位置,那麼新請求將被插入該位置,保證佇列中的請求是以被訪問磁碟物理位置為序進行排列的
  4. 如果佇列中不存在合適的請求插入位置,請求將被插入到佇列尾部

linus電梯排程程式在2.6版的核心中被其他排程程式所取代了。

3.2 最終期限I/O排程

linus電梯排程主要考慮了系統的全域性吞吐量,對於個別的I/O請求,還是有可能造成飢餓現象。

而且讀寫請求的響應時間要求也是不一樣的,一般來說,寫請求的響應時間要求不高,寫請求可以和提交它的應用程式非同步執行,

但是讀請求一般和提交它的應用程式時同步執行,應用程式等獲取到讀的資料後才會接著往下執行。

因此在 linus 電梯排程程式中,還可能造成 寫-飢餓-讀(wirtes-starving-reads)這種特殊問題。

為了儘量公平的對待所有請求,同時儘量保證讀請求的響應時間,提出了最終期限I/O排程演算法。

最終期限I/O排程 演算法給每個請求設定了超時時間,預設情況下,讀請求的超時時間500ms,寫請求的超時時間是5s

但一個新請求加入到I/O請求佇列時,最終期限I/O排程和linus電梯排程相比,多出了以下操作:

  1. 新請求加入到 排序佇列(order-FIFO),加入的方法類似 linus電梯新請求加入的方法
  2. 根據新請求的型別,將其加入 讀佇列(read-FIFO) 或者寫佇列(wirte-FIFO) 的尾部(讀寫佇列是按加入時間排序的,所以新請求都是加到尾部)
  3. 排程程式首先判斷 讀,寫佇列頭的請求是否超時,如果超時,從讀,寫佇列頭取出請求,加入到派發佇列(dispatch-FIFO)
  4. 如果沒有超時請求,從 排序佇列(order-FIFO)頭取出一個請求加入到 派發佇列(dispatch-FIFO)
  5. 派發佇列(dispatch-FIFO)按順序將請求提交到磁碟驅動,完成I/O操作

最終期限I/O排程 演算法也不能嚴格保證響應時間,但是它可以保證不會發生請求在明顯超時的情況下仍得不到執行。

最終期限I/O排程 的實現參見: block/deadline-iosched.c

3.3 預測I/O排程

最終期限I/O排程演算法優先考慮讀請求的響應時間,但系統處於寫操作繁重的狀態時,會大大降低系統的吞吐量。

因為讀請求的超時時間比較短,所以每次有讀請求時,都會打斷寫請求,讓磁碟定址到讀的位置,完成讀操作後再回來繼續寫。

這種做法保證讀請求的響應速度,卻損害了系統的全域性吞吐量(磁頭先去讀再回來寫,發生了2次定址操作)

預測I/O排程演算法是為了解決上述問題而提出的,它是基於最終期限I/O排程演算法的。

但有一個新請求加入到I/O請求佇列時,預測I/O排程與最終期限I/O排程相比,多了以下操作:

  1. 新的讀請求提交後,並不立即進行請求處理,而是有意等待片刻(預設是6ms)
  2. 等待期間如果有其他對磁碟相鄰位置進行讀操作的讀請求加入,會立刻處理這些讀請求
  3. 等待期間如果沒有其他讀請求加入,那麼等待時間相當於浪費掉
  4. 等待時間結束後,繼續執行以前剩下的請求

預測I/O排程演算法中最重要的是保證等待期間不要浪費,也就是提高預測的準確性,

目前這種預測是依靠一系列的啟發和統計工作,預測I/O排程程式會跟蹤並統計每個應用程式的I/O操作習慣,以便正確預測應用程式的讀寫行為。

如果預測的準確率足夠高,那麼預測I/O排程和最終期限I/O排程相比,既能提高讀請求的響應時間,又能提高系統吞吐量。

預測I/O排程的實現參見: block/as-iosched.c

:預測I/O排程是linux核心中預設的排程程式。

3.4 完全公正的排隊I/O排程

完全公正的排隊(Complete Fair Queuing, CFQ)I/O排程 是為專有工作負荷設計的,它和之前提到的I/O排程有根本的不同。

CFQ I/O排程 演算法中,每個程序都有自己的I/O佇列,

CFQ I/O排程程式以時間片輪轉排程佇列,從每個佇列中選取一定的請求數(預設4個),然後進行下一輪排程。

CFQ I/O排程在程序級提供了公平,它的實現位於: block/cfq-iosched.c

3.5 空操作的I/O排程

空操作(noop)I/O排程幾乎不做什麼事情,這也是它這樣命名的原因。

空操作I/O排程只做一件事情,當有新的請求到來時,把它與任一相鄰的請求合併。

空操作I/O排程主要用於快閃記憶體卡之類的塊裝置,這類裝置沒有磁頭,沒有定址的負擔。

空操作I/O排程的實現位於: block/noop-iosched.c

3.6 I/O排程程式的選擇

2.6核心中內建了上面4種I/O排程,可以在啟動時通過命令列選項 elevator=xxx 來啟用任何一種。

elevator選項引數如下:

引數

I/O排程程式

as 預測
cfq 完全公正排隊
deadline 最終期限
noop 空操作

如果啟動預測I/O排程,啟動的命令列引數中加上 elevator=as