如果您記性好的話,應該記得我在linux設備驅(qū)動實例帖中說的最多的就是字符設備驅(qū)動程序,那么今天的塊I/O層是一個和字符設備驅(qū)動相對應的設備。兩者最根本的區(qū)別就是看它們能否被隨機訪問,換句話說就是看它們能否在訪問設備時從一個位置隨意地調(diào)到另外一個位置,如果可以就是塊設備,否則就字符設備。
塊設備中最小的可尋址單元是扇區(qū)。扇區(qū)的大小一般是2的整數(shù)倍,最常見的大小是512個字節(jié)。扇區(qū)的大小是設備的物理屬性,扇區(qū)是所有塊設備的基本單元,塊設備無法對比它還小的單元進行尋址和操作,不過許多塊設備能夠一次就傳輸多個扇區(qū)。從軟件角度來講,最小的邏輯可尋址單元卻是塊,塊是文件系統(tǒng)的一種抽象-----只能基于塊來訪問文件系統(tǒng)。雖然物理磁盤尋址是按照扇區(qū)級進行的,但是內(nèi)核執(zhí)行的所有磁盤操作都是按照塊進行的。前邊已經(jīng)說過,扇區(qū)是設備的最小可尋址單元,所以塊不能比扇區(qū)還小,只能數(shù)倍于扇區(qū)大小。另外內(nèi)核還要求塊大小是2的整數(shù)倍,=而且不能超過一個頁的長度,所以大小的最終要求是,必須是扇區(qū)大小的2的整數(shù)倍,并且要小于頁面大小。所以通常塊大小是512字節(jié),1k或4k。
當一個塊被調(diào)入內(nèi)存時,它要存儲在一個緩沖區(qū)中,每個緩沖區(qū)與一個塊對應,它相當于是磁盤塊在內(nèi)存中的表示。另外,由于內(nèi)核在處理數(shù)據(jù)時需要一些相關的控制信息,所以每個緩沖區(qū)都有一個叫做buffer_head的描述符來表示,被稱為緩沖區(qū)頭,在linux/buffer_head.h中定義,它包含了內(nèi)核操作緩沖區(qū)所需要的全部信息,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
struct buffer_head {
unsigned long b_state; /* buffer state flags */
atomic_t b_count; /* buffer usage counter */
struct buffer_head *b_this_page; /* buffers using this page */
struct page *b_page; /* page storing this buffer */
sector_t b_blocknr; /* logical block number */
u32 b_size; /* block size (in bytes) */
char *b_data; /* buffer in the page */
struct block_device *b_bdev; /* device where block resides */
bh_end_io_t *b_end_io; /* I/O completion method */
void *b_private; /* data for completion method */
struct list_head b_assoc_buffers; /* list of associated mappings */
};
其中的b_state域表示緩沖區(qū)的狀態(tài),下表給出一種標志或多種標志的組合,在linux/buffer_head.h中定義了所有合法標志的bh_state_bite列表,如下所示:
bh_state_bits列表包含了一個特殊標志----BH_PrivateStart,該標志不是可用狀態(tài)標志,使用它是為了指明可能其它代碼使用的起始位。塊I/O層不會使用BH_PrivateStart或更高的位,那么某個驅(qū)動程序希望通過b_state域存儲信息時就可以安全地使用這些位。驅(qū)動程序可以在這些位中定義自己的狀態(tài)標志,只要保證自定義的狀態(tài)標志不會與塊IO層的專用位發(fā)生沖突就可以了。b_count域表示緩沖區(qū)的使用計數(shù),可通過兩個定義在文件linux/buffer_head.h中的內(nèi)聯(lián)函數(shù)對此域進行增減:
1
2
3
4
5
6
7
8
static inline void get_bh(struct buffer_head *bh)
{
atomic_inc(&bh->b_count);
}
static inline void put_bh(struct buffer_head *bh)
{
atomic_dec(&bh->b_count);
}
在操作緩沖區(qū)頭之前,應該先使用get_bh()函數(shù)增加緩沖區(qū)頭的引用計數(shù),確保緩沖區(qū)頭不會再被分配出去,當完成對緩沖區(qū)頭的操作之后,還必須使用put_bh()函數(shù)減少引用計數(shù)。與緩沖區(qū)對應的磁盤物理塊由b_blocknr域索引,該值是b_bdev域指明的塊設備中的邏輯塊號。與緩沖區(qū)對應的內(nèi)存物理頁由b_page域表示,另外,b_data域直接指向相應的塊(它位于b_page域所指明的頁面的某個位置上),塊的大小由b_size域表示,所以塊在內(nèi)存中的起始位置在b_data處,結(jié)束位置在(b_data+b_size)處。緩沖區(qū)頭的目的在于描述磁盤塊和物理內(nèi)存緩沖區(qū)(在特定頁面上的字節(jié)序列)之間的映射關系。這個結(jié)構(gòu)體在內(nèi)核中扮演一個描述符的角色,說明從緩沖區(qū)到塊的映射關系。使用緩沖區(qū)頭作為I/O操作有它的弊端,這里不細說,你明白就好。我們只需知道現(xiàn)在的內(nèi)核采用了一種新型,靈活而且輕量級的容器---bio結(jié)構(gòu)體。
bio結(jié)構(gòu)體定義在linux/bio.h中,該結(jié)構(gòu)體代表了正在現(xiàn)場的(活動)以片斷(segment)鏈表形式組織的塊I/O操作。一個片斷是一小塊連續(xù)的內(nèi)存緩沖區(qū)。這樣的話,就不需要保證單個緩沖區(qū)一定要連續(xù),所有通過片斷來描述緩沖區(qū),即使一個緩沖區(qū)分散在內(nèi)存的多個位置上,bio結(jié)構(gòu)體也能保證I/O操作的執(zhí)行。下面給出bio結(jié)構(gòu)體和各個域的描述,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct bio {
sector_t bi_sector; /* associated sector on disk */
struct bio *bi_next; /* list of requests */
struct block_device *bi_bdev; /* associated block device */
unsigned long bi_flags; /* status and command flags */
unsigned long bi_rw; /* read or write? */
unsigned short bi_vcnt; /* number of bio_vecs off */
unsigned short bi_idx; /* current index in bi_io_vec */
unsigned short bi_phys_segments; /* number of segments after coalescing */
unsigned short bi_hw_segments; /* number of segments after remapping */
unsigned int bi_size; /* I/O count */
unsigned int bi_hw_front_size; /* size of the first mergeable segment */
unsigned int bi_hw_back_size; /* size of the last mergeable segment */
unsigned int bi_max_vecs; /* maximum bio_vecs possible */
struct bio_vec *bi_io_vec; /* bio_vec list */
bio_end_io_t *bi_end_io; /* I/O completion method */
atomic_t bi_cnt; /* usage counter */
void *bi_private; /* owner-private method */
bio_destructor_t *bi_destructor; /* destructor method */
};
使用bio結(jié)構(gòu)體的目的主要是代表正在現(xiàn)場執(zhí)行的I/O操作,所有該結(jié)構(gòu)體中的主要域都是用來管理相關信息的。其中最重要的幾個域是bi_io_vecs,bi_vcnt和bi_idx.它們之間的關系如下圖所示:
我在前邊已經(jīng)給出了struct bio的結(jié)構(gòu)體,下面給出struct bio_vec的描述:
1
2
3
4
5
struct bio_vec {
struct page *bv_page; /* pointer to the physical page on which this buffer resides */
unsigned int bv_len; /* the length in bytes of this buffer */
unsigned int bv_offset; /* the byte offset within the page where the buffer resides */
};
下面來分析以上上面的那個圖,我們說:每一個塊I/O請求都通過一個bio結(jié)構(gòu)體表示。每個請求包含一個或多個塊,這些塊存儲在bio_vec結(jié)構(gòu)體數(shù)組中,這些結(jié)構(gòu)體描述了每個片斷在物理頁中的實際位置,并且像向量一樣地組織在一起,IO操作的第一個片斷由b_io_vec結(jié)構(gòu)體所指向,其他的片斷在其后依次放置,共有bi_vcnt個片斷。當塊IO開始執(zhí)行請求,需要使用各個片段時,bi_idx域會不斷更新,從而總指向當前片斷。bi_idx域指向數(shù)組中的當前bio_vec片段,塊IO層通過它跟蹤塊IO操作的完成進度。但該域更重要的作用是分割bio結(jié)構(gòu)體。bi_cnt域記錄bio結(jié)構(gòu)體的使用計數(shù),如果為0,則應該銷毀該bio結(jié)構(gòu)體,并釋放它占用的內(nèi)存。通過下面兩個函數(shù)管理使用計數(shù):
1
2
void bio_get(struct bio *bio);
void bio_put(struct bio *bio);
最后一個域是bi_private域,這是一個屬于擁有者的私有域,誰創(chuàng)建了bio結(jié)構(gòu),誰就可以讀寫該域。
塊設備將它們掛起的塊IO請求保存在請求隊列中,該隊列有request_queue結(jié)構(gòu)體體表示,定義在文件linux/blkdev.h中,包含一個雙向請求鏈表以及相關控制信息。通過內(nèi)核中想文件系統(tǒng)這樣高層的代碼將請求加入到隊列中。請求隊列只要不為空,隊列對應的塊設備驅(qū)動程序就會從隊列頭獲取請求,然后將其送入對應的塊設備上去請求隊列表中的每一項都是一個單獨的請求,有reques結(jié)構(gòu)體體表示。隊列中的請求由結(jié)構(gòu)體request表示,定義在文件linux/blkdev.h表示。因為一個請求可能要操作多個連續(xù)的磁盤塊,所有每個請求可有由多個bio結(jié)構(gòu)體組成,注意,雖然磁盤上的塊必須連續(xù),但是在內(nèi)存中的這些塊并不一定要連續(xù)----每個bio結(jié)構(gòu)都可以描述多個片段,而每個請求也可以包含多個bio結(jié)構(gòu)體。
好了,我們明白了塊IO請求,下面的就是IO調(diào)度了。每次尋址的操作就是定位磁盤磁頭到特定塊上的某個位置,為了優(yōu)化尋址操作,內(nèi)核既不會簡單地按請求接收次序,也不會立即將其提交給磁盤,相反,它會在提交前,先執(zhí)行名為合并與排序的預操作,這種預操作可以極大地提高系統(tǒng)的整體性能。
IO調(diào)度程序通過兩種方法減少磁盤尋址時間:合并與排序。合并指將兩個或多個請求結(jié)合成一個一個新請求。關于排序的,最有名的當然就是大名鼎鼎的電梯調(diào)度。排序就是整個請求隊列將按扇區(qū)增長方向有序排列,使所有請求按磁盤上扇區(qū)的排列順序有序排列的目的不僅是為了縮短單獨一次請求的尋址時間,更重要的優(yōu)化在于,通過保持磁盤頭以直線方向移動,縮短了所有請求的磁盤尋址的時間。關于linux中的電梯調(diào)度程序,很多操作系統(tǒng)的書上都已經(jīng)說的很明白,我這里給出一個大致流程:
1.首先,如果隊列中已存在一個對相鄰磁盤扇區(qū)操作的請求,那么新請求將和這個已經(jīng)存在的請求合并為一個請求。
2.如果隊列中存在一個駐留時間過長的請求,那么新請求將被插入到隊列尾部,以防止其他舊的請求發(fā)生饑餓。
3.如果隊列中以扇區(qū)方向為序存在合適的插入位置,那么新的請求將被插入到該位置,保證隊列中的請求是以被訪問磁盤物理位置為序進行排序的。
4.如果隊列中不存在合適的請求插入位置,請求將被插入到隊列尾部。
我前邊提到過電梯調(diào)度,但是有一個問題一直沒提,那就是電梯調(diào)度程序的缺點:饑餓。出于減少磁盤尋址時間的考慮,對某個磁盤區(qū)域上的繁重操作,無疑會使得磁盤其他位置上的操作得不到運行機會,實際上,一個對磁盤同一位置操作的請求流可以造成較遠位置的其他請求永遠得不到運行機會,這是一種很不公平的饑餓現(xiàn)象。更糟糕的是,普通的請求饑餓還會帶來寫--饑餓--讀這種特殊問題。我們知道寫操作通常發(fā)生在內(nèi)核有空時,而讀操作卻必須阻塞知道讀請求被滿足,這對系統(tǒng)性能影響是非常大的。而且我們知道讀請求往往相互依靠,比如要讀大量的文件,每次都是針對一塊很小的緩沖區(qū)進行讀操作,而應用程序只有將上一個數(shù)據(jù)區(qū)域從磁盤中讀取并返回之后,才能繼續(xù)讀取下一個數(shù)據(jù)區(qū),所以如果每一次請求都發(fā)生饑餓現(xiàn)象,那么對讀取文件的應用程序來說,全部延遲加起來會造成過長的等待時間。減少饑餓請求必須以降低全局吞吐量為代價。為了避免這種問題,提出了最后期限IO調(diào)度程序,既要盡量提高全局吞吐量,又要使請求得到公平處理。在最后期限IO調(diào)度程序中,每個請求都有一個超時時間。默認情況下,讀請求的超時時間是500ms,寫請求的超時時間是5s。最后期限IO調(diào)度請求類似與linux電梯,也以磁盤物理位置為次序維護請求隊列,這個隊列被稱為排序隊列。當一個新請求遞交給排序隊列時,最后期限IO調(diào)度程序類似于linux電梯,合并和插入請求,但是最后期限IO調(diào)度程序同時也會以請求類型為依據(jù)將它們插入到額外隊列中。讀請求按次序被插入到特定的讀FIFO隊列中,寫請求被插入到特定的寫FIFO隊列中。雖然普通隊列以磁盤扇區(qū)為序進行排序,但是這些隊列是以FIFO形式組織的,結(jié)果新隊列總是被加入到隊列尾部。對于普通操作來說,最后期限IO調(diào)度將請求從排序隊列的頭部去下,再推入到派發(fā)隊列中,派發(fā)隊列然后將請求提交給磁盤驅(qū)動,從而保證了最小化的請求尋址。如果在寫FIFO隊列頭,或是在讀FIFO隊列頭的請求超時,那么最后期限IO調(diào)度程序便從FIFO隊列中提取請求進行服務。依靠這種方法,最后期限IO調(diào)度程序試圖保證不會發(fā)生有請求在明顯超期的情況下仍不能得到服務的現(xiàn)象,如下圖所示:

最后期限IO調(diào)度程序的實現(xiàn)在文件driver/block/deadline-iosched.c中。
雖然最后期限IO調(diào)度程序為降低讀操作響應時間做了許多工作,但同時也降低了系統(tǒng)吞吐量??紤]這樣的情況,假設一個系統(tǒng)正處于很繁重的寫操作期間,每次提交新請求,IO調(diào)度程序都會迅速處理讀請求,這樣磁盤會首先為讀操作尋址,執(zhí)行讀操作,然后返回再尋址進行寫操作,并且對每個讀操作都重復這個過程。這種做法明顯損害了系統(tǒng)全局吞吐量。這事就有了預測IO調(diào)度程序。它的基礎就是最后期限IO調(diào)度程序。最主要的改進是它增加了預測啟發(fā)能力。它的不同之處在于讀操作提交后并不直接返回處理其他請求,而是會有意空閑片刻。這空閑的幾秒鐘,對應用程序來說是個提交其他讀請求的好機會-----任何對相鄰磁盤位置操作的請求都會立刻得到處理。在等待時間結(jié)束后,預測IO調(diào)度程序重新返回原來的位置,繼續(xù)執(zhí)行以前剩下的請求。要注意,如果等待可以減少讀請求所帶來的向后再向前(back-and-forth)尋址操作,那么完全值得花一些時間來等待更多的請求(這里的時間花在對更多請求的預測上),如果一個相鄰的IO請求在等待期帶來,那么IO調(diào)度程序可以節(jié)省兩次尋址操作。如果存在愈來愈多的訪問同樣區(qū)域的讀請求到來,那么片刻等待無疑會避免大量的尋址操作。當然,不得不說,如果沒有IO請求在等待期到來,那么預測IO調(diào)度程序會給系統(tǒng)性能帶來輕微的損失,浪費掉幾毫秒。預測調(diào)度程序所帶來的優(yōu)勢在于能否正確預測應用程序和文件系統(tǒng)的行為。這種預測依靠一系列的啟發(fā)和統(tǒng)計工作。預測IO調(diào)度程序的實現(xiàn)在文件driver/block/as-iosched.c中。塊設備使用哪個IO調(diào)度程序是可以選擇的。默認的IO調(diào)度程序就是預測IO調(diào)度程序。