linux文件讀寫過程--各種層級結構

在《linux內核虛擬文件系統淺析》這篇文章中,我們看到文件是如何被打開、文件的讀寫是如何被觸發的。
對一個已打開的文件fd進行read/write系統調用時,內核中該文件所對應的file結構的f_op->read/f_op->write被調用。
本文將順着這條路走下去,大致看看普通磁盤文件的讀寫是怎樣實現的。

linux內核響應一個塊設備文件讀寫的層次結構如圖(摘自ULK3):


VFST圖片

1、VFS,虛擬文件系統。
之前我們已經看到f_op->read/f_op->write如何被調用,這就是VFS乾的事(參見:《linux內核虛擬文件系統淺析》);

2、Disk Caches,磁盤高速緩存。
將磁盤上的數據緩存在內存中,加速文件的讀寫。實際上,在一般情況下,read/write是隻跟緩存打交道的。(當然,存在特殊情況。下面會說到。)
read就直接從緩存讀數據。如果要讀的數據還不在緩存中,則觸發一次讀盤操作,然後等待磁盤上的數據被更新到磁盤高速緩存中;write也是直接寫到緩存裏去,然後就不用管了。後續內核會負責將數據寫回磁盤。

爲了實現這樣的緩存,每個文件的inode內嵌了一個address_space結構,通過inode->i_mapping來訪問。address_space結構中維護了一棵radix樹,用於磁盤高速緩存的內存頁面就掛在這棵樹上。而既然磁盤高速緩存是跟文件的inode關聯上的,則打開這個文件的每個進程都共用同一份緩存。
radix樹的具體實現細節這裏可以不用關心,可以把它理解成一個數組。數組中的每個元素就是一個頁面,文件的內容就順序存放在這些頁面中。

於是,通過要讀寫的文件pos,可以換算得到要讀寫的是第幾頁(pos是以字節爲單位,只需要除以每個頁的字節數即可)。
inode被載入內存的時候,對應的磁盤高速緩存是空的(radix樹上沒有頁面)。隨着文件的讀寫,磁盤上的數據被載入內存,相應的內存頁被掛到radix樹的相應位置上。
如果文件被寫,則僅僅是對應inode的radix樹上的對應頁上的內容被更新,並不會直接寫回磁盤。這樣被寫過,但還沒有更新到磁盤的頁稱爲髒頁。
內核線程pdflush定期將每個inode上的髒頁更新到磁盤,也會適時地將radix上的頁面回收,這些內容都不在這裏深入探討了。

當需要讀寫的文件內容尚未載入到對應的radix樹時,read/write的執行過程會向底層的“通用塊層”發起讀請求,以便將數據讀入。
而如果文件打開時指定了O_DIRECT選項,則表示繞開磁盤高速緩存,直接與“通用塊層”打交道。
既然磁盤高速緩存提供了有利於提高讀寫效率的緩存機制,爲什麼又要使用O_DIRECT選項來繞開它呢?一般情況下,這樣做的應用程序會自己在用戶態維護一套更利於應用程序使用的專用的緩存機制,用以取代內核提供的磁盤高速緩存這種通用的緩存機制。(數據庫程序通常就會這麼幹。)
既然使用O_DIRECT選項後,文件的緩存從內核提供的磁盤高速緩存變成了用戶態的緩存,那麼打開同一文件的不同進程將無法共享這些緩存(除非這些進程再創建一個共享內存什麼的)。而如果對於同一個文件,某些進程使用了O_DIRECT選項,而某些又沒有呢?沒有使用O_DIRECT選項的進程讀寫這個文件時,會在磁盤高速緩存中留下相應的內容;而使用了O_DIRECT選項的進程讀寫這個文件時,需要先將磁盤高速緩存裏面對應本次讀寫的髒數據寫回磁盤,然後再對磁盤進行直接讀寫。
關於O_DIRECT選項帶來的direct_IO的具體實現細節,說來話長,在這裏就不做介紹了。可以參考《linux異步IO淺析》。

3、Generic Block Layer,通用塊層。
linux內核爲塊設備抽象了統一的模型,把塊設備看作是由若干個扇區組成的數組空間。扇區是磁盤設備讀寫的最小單位,通過扇區號可以指定要訪問的磁盤扇區。
上層的讀寫請求在通用塊層被構造成一個或多個bio結構,這個結構裏面描述了一次請求--訪問的起始扇區號?訪問多少個扇區?是讀還是寫?相應的內存頁有哪些、頁偏移和數據長度是多少?等等……

這裏面主要有兩個問題:要訪問的扇區號從哪裏來?內存是怎麼組織的?
前面說過,上層的讀寫請求通過文件pos可以定位到要訪問的是相應的磁盤高速緩存的第幾個頁,而通過這個頁index就可以知道要訪問的是文件的第幾個扇區,得到扇區的index。
但是,文件的第幾個扇區並不等同於磁盤上的第幾個扇區,得到的扇區index還需要由特定文件系統提供的函數來轉換成磁盤的扇區號。文件系統會記載當前磁盤上的扇區使用情況,並且對於每一個inode,它依次使用了哪些扇區。(參見《linux文件系統實現淺析》)
於是,通過文件系統提供的特定函數,上層請求的文件pos最終被對應到了磁盤上的扇區號。
可見,上層的一次請求可能跨多個扇區,可能形成多個非連續的扇區段。對應於每個扇區段,一個bio結構被構造出來。而由於塊設備一般都支持一次性訪問若干個連續的扇區,所以一個扇區段(不止一個扇區)可以包含在代表一次塊設備IO請求的一個bio結構中。

接下來談談內存的組織。既然上層的一次讀寫請求可能跨多個扇區,它也可能跨越磁盤高速緩存上的多個頁。於是,一個bio裏面包含的扇區請求可能會對應一組內存頁。而這些頁是單獨分配的,內存地址很可能不連續。
那麼,既然bio描述的是一次塊設備請求,塊設備能夠一次性訪問一組連續的扇區,但是能夠一次性對一組非連續的內存地址進行存取嗎?
塊設備一般是通過DMA,將塊設備上一組連續的扇區上的數據拷貝到一組連續的內存頁面上(或將一組連續的內存頁面上的數據拷貝到塊設備上一組連續的扇區),DMA本身一般是不支持一次性訪問非連續的內存頁面的。
但是某些體系結構包含了io-mmu。就像通過mmu可以將一組非連續的物理頁面映射成連續的虛擬地址一樣,對io-mmu進行編程,可以讓DMA將一組非連續的物理內存看作連續的。所以,即使一個bio包含了非連續的多段內存,它也是有可能可以在一次DMA中完成的。當然,不是所有的體系結構都支持io-mmu,所以一個bio也可能在後面的設備驅動程序中被拆分成多個設備請求。

每個被構造的bio結構都會分別被提交,提交到底層的IO調度器中。

4、I/O SchedulerLayer,IO調度器。
我們知道,磁盤是通過磁頭來讀寫數據的,磁頭在定位扇區的過程中需要做機械的移動。相比於電和磁的傳遞,機械運動是非常慢速的,這也就是磁盤爲什麼那麼慢的主要原因。
IO調度器要做的事情就是在完成現有請求的前提下,讓磁頭儘可能少移動,從而提高磁盤的讀寫效率。最有名的就是“電梯算法”。
在IO調度器中,上層提交的bio被構造成request結構,一個request結構包含了一組順序的bio。而每個物理設備會對應一個request_queue,裏面順序存放着相關的request。
新的bio可能被合併到request_queue中已有的request結構中(甚至合併到已有的bio中),也可能生成新的request結構並插入到request_queue的適當位置上。具體怎麼合併、怎麼插入,取決於設備驅動程序選擇的IO調度算法。大體上可以把IO調度算法就想象成“電梯算法”,儘管實際的IO調度算法有所改進。
除了類似“電梯算法”的IO調度算法,還有“none”算法,這實際上是沒有算法,也可以說是“先來先服務算法”。因爲現在很多塊設備已經能夠很好地支持隨機訪問了(比如固態磁盤、flash閃存),使用“電梯算法”對於它們沒有什麼意義。

IO調度器除了改變請求的順序,還可能延遲觸發對請求的處理。因爲只有當請求隊列有一定數目的請求時,“電梯算法”才能發揮其功效,否則極端情況下它將退化成“先來先服務算法”。
這是通過對request_queue的plug/unplug來實現的,plug相當於停用,unplug相當於恢復。請求少時將request_queue停用,當請求達到一定數目,或者request_queue裏最“老”的請求已經等待很長一段時間了,這時候纔將request_queue恢復。
在request_queue恢復的時候,驅動程序提供的回調函數將被調用,於是驅動程序開始處理request_queue。
一般來說,read/write系統調用到這裏就返回了。返回之後可能等待(同步)或是繼續幹其他事(異步)。而返回之前會在任務隊列裏面添加一個任務,而處理該任務隊列的內核線程將來會執行request_queue的unplug操作,以觸發驅動程序處理請求。

5、Device Driver,設備驅動程序。
到了這裏,設備驅動程序要做的事情就是從request_queue裏面取出請求,然後操作硬件設備,逐個去執行這些請求。

除了處理請求,設備驅動程序還要選擇IO調度算法,因爲設備驅動程序最知道設備的屬性,知道用什麼樣的IO調度算法最合適。甚至於,設備驅動程序可以將IO調度器屏蔽掉,而直接對上層的bio進行處理。(當然,設備驅動程序也可實現自己的IO調度算法。)
可以說,IO調度器是內核提供給設備驅動程序的一組方法。用與不用、使用怎樣的方法,選擇權在於設備驅動程序。

於是,對於支持隨機訪問的塊設備,驅動程序除了選擇“none”算法,還有一種更直接的做法,就是註冊自己的bio提交函數。這樣,bio生成後,並不會使用通用的提交函數,被提交到IO調度器,而是直接被驅動程序處理。
但是,如果設備比較慢的話,bio的提交可能會阻塞較長時間。所以這種做法一般被基於內存的“塊設備”驅動使用(當然,這樣的塊設備是由驅動程序虛擬的)。


下面大致介紹一下read/write的執行流程:
sys_read。通過fd得到對應的file結構,然後調用vfs_read;
vfs_read。各種權限及文件鎖的檢查,然後調用file->f_op->read(若不存在則調用do_sync_read)。file->f_op是從對應的inode->i_fop而來,而inode->i_fop是由對應的文件系統類型在生成這個inode時賦予的。file->f_op->read很可能就等同於do_sync_read;
do_sync_read。f_op->read是完成一次同步讀,而f_op->aio_read完成一次異步讀。do_sync_read則是利用f_op->aio_read這個異步讀操作來完成同步讀,也就是在發起一次異步讀之後,如果返回值是-EIOCBQUEUED,則進程睡眠,直到讀完成即可。但實際上對於磁盤文件的讀,f_op->aio_read一般不會返回-EIOCBQUEUED,除非是設置了O_DIRECT標誌aio_read,或者是對於一些特殊的文件系統(如nfs這樣的網絡文件系統);
f_op->aio_read。這個函數通常是由generic_file_aio_read或者其封裝來實現的;
generic_file_aio_read。一次異步讀可能包含多個讀操作(對應於readv系統調用),對於其中的每一個,調用do_generic_file_read;
do_generic_file_read。主要流程是在radix樹裏面查找是否存在對應的page,且該頁可用。是則從page裏面讀出所需的數據,然後返回,否則通過file->f_mapping->a_ops->readpage去讀這個頁。(file->f_mapping->a_ops->readpage返回後,說明讀請求已經提交了。但是磁盤上的數據還不一定就已經讀上來了,需要等待數據讀完。等待的方法就是lock_page:在調用file->f_mapping->a_ops->readpage之前會給page置PG_locked標記。而數據讀完後,會將該標記清除,這個後面會看到。而這裏的lock_page就是要等待PG_locked標記被清除。);
file->f_mapping是從對應inode->i_mapping而來,inode->i_mapping->a_ops是由對應的文件系統類型在生成這個inode時賦予的。而各個文件系統類型提供的a_ops->readpage函數一般是mpage_readpage函數的封裝;
mpage_readpage。調用do_mpage_readpage構造一個bio,再調用mpage_bio_submit將其提交;
do_mpage_readpage。根據page->index確定需要讀的磁盤扇區號,然後構造一組bio。其中需要使用文件系統類型提供的get_block函數來對應需要讀取的磁盤扇區號;
mpage_bio_submit。設置bio的結束回調bio->bi_end_io爲mpage_end_io_read,然後調用submit_bio提交這組bio;
submit_bio。調用generic_make_request將bio提交到磁盤驅動維護的請求隊列中;
generic_make_request。一個包裝函數,對於每一個bio,調用__generic_make_request;
__generic_make_request。獲取bio對應的塊設備文件對應的磁盤對象的請求隊列bio->bi_bdev->bd_disk->queue,調用q->make_request_fn將bio添加到隊列;
q->make_request_fn。設備驅動程序在其初始化時會初始化這個request_queue結構,並且設置q->make_request_fn和q->request_fn(這個下面就會用到)。前者用於將一個bio組裝成request添加到request_queue,後者用於處理request_queue中的請求。一般情況下,設備驅動通過調用blk_init_queue來初始化request_queue,q->request_fn需要給定,而q->make_request_fn使用了默認的__make_request;
__make_request。會根據不同的調度算法來決定如何添加bio,生成對應的request結構加入request_queue結構中,並且決定是否調用q->request_fn,或是在kblockd_workqueue任務隊列裏面添加一個任務,等kblockd內核線程來調用q->request_fn;
q->request_fn。由驅動程序定義的函數,負責從request_queue裏面取出request進行處理。從添加bio到request被取出,若干的請求已經被IO調度算法整理過了。驅動程序負責根據request結構裏面的描述,將實際物理設備裏面的數據讀到內存中。當驅動程序完成一個request時,會調用end_request(或類似)函數,以結束這個request;
end_request。完成request的收尾工作,並且會調用對應的bio的的結束方法bio->bi_end_io,即前面設置的mpage_end_io_read;
mpage_end_io_read。如果page已更新則設置其up-to-date標記,併爲page解鎖,喚醒等待page解鎖的進程。最後釋放bio對象;

sys_write。跟sys_read一樣,對應的vfs_write、do_sync_write、f_op->aio_write、generic_file_aio_write被順序調用;
generic_file_aio_write。調用__generic_file_aio_write_nolock來進行寫的處理,將數據寫到磁盤高速緩存中。寫完成之後,判斷如果文件打開時使用了O_SYNC標記,則再調用sync_page_range將寫入到磁盤高速緩存中的數據同步到磁盤(只同步文件頭信息);
__generic_file_aio_write_nolock。進行一些檢查之後,調用generic_file_buffered_write;
generic_file_buffered_write。調用generic_perform_write執行寫,寫完成之後,判斷如果文件打開時使用了O_SYNC標記,則再調用generic_osync_inode將寫入到磁盤高速緩存中的數據同步到磁盤(同步文件頭信息和文件內容);
generic_perform_write。一次異步寫可能包含多個寫操作(對應於writev系統調用),對於其中牽涉的每一個page,調用file->f_mapping->a_ops->write_begin準備好需要寫的磁盤高速緩存頁面,然後將需要寫的數據拷入其中,最後調用file->f_mapping->a_ops->write_end完成寫;
file->f_mapping是從對應inode->i_mapping而來,inode->i_mapping->a_ops是由對應的文件系統類型在生成這個inode時賦予的。而各個文件系統類型提供的file->f_mapping->a_ops->write_begin函數一般是block_write_begin函數的封裝、file->f_mapping->a_ops->write_end函數一般是generic_write_end函數的封裝;
block_write_begin。調用grab_cache_page_write_begin在radix樹裏面查找要被寫的page,如果不存在則創建一個。調用__block_prepare_write爲這個page準備一組buffer_head結構,用於描述組成這個page的數據塊(利用其中的信息,可以生成對應的bio結構);
generic_write_end。調用block_write_end提交寫請求,然後設置page的dirty標記;
block_write_end。調用__block_commit_write爲page中的每一個buffer_head結構設置dirty標記;
至此,write調用就要返回了。如果文件打開時使用了O_SYNC標記,sync_page_range或generic_osync_inode將被調用。否則write就結束了,等待pdflush內核線程發現radix樹上的髒頁,並最終調用到do_writepages寫回這些髒頁;
sync_page_range也是調用generic_osync_inode來實現的,而generic_osync_inode最終也會調用到do_writepages;
do_writepages。調用inode->i_mapping->a_ops->writepages,而後者一般是mpage_writepages函數的包裝;
mpage_writepages。檢查radix樹中需要寫回的page,對每一個page調用__mpage_writepage;
__mpage_writepage。這裏也是構造bio,然後調用mpage_bio_submit來進行提交;
後面的流程跟read幾乎就一樣了……

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