漫談linux文件IO


    在Linux 開發中,有幾個關系到性能的東西,技術人員非常關注:進程,CPUMEM,網絡IO,磁盤IO。本篇文件打算詳細全面,深入淺出。剖析文件IO的細節。從多個角度探索如何提高IO性能。本文盡量用通俗易懂的視角去闡述。不copy內核代碼。

     闡述之前,要先有個大視角,讓我們站在萬米高空,鳥瞰我們的文件IO,它們設計是分層的,分層有2個好處,一是架構清晰,二是解耦。讓我們看一下下面這張圖。

 clip_image002

圖一

1.       穿越各層寫文件方式

程序的最終目的是要把數據寫到磁盤上, 但是系統從通用性和性能角度,盡量提供一個折中的方案來保證這些。讓我們來看一個最常用的寫文件典型example,也是路徑最長的IO

{

    char *buf = malloc(MAX_BUF_SIZE);

    strncpy(buf, src, , MAX_BUF_SIZE);

    fwrite(buf, MAX_BUF_SIZE, 1, fp);

    fclose(fp);

}

這里mallocbuf對於圖層中的application buffer,即應用程序的buffer;調用fwrite后,把數據從application buffer 拷貝到了 CLib buffer,即C庫標准IObufferfwrite返回后,數據還在CLib buffer,如果這時候進程core掉。這些數據會丟失。沒有寫到磁盤介質上。當調用fclose的時候,fclose調用會把這些數據刷新到磁盤介質上。除了fclose方法外,還有一個主動刷新操作fflush 函數,不過fflush函數只是把數據從CLib buffer 拷貝到page  cache 中,並沒有刷新到磁盤上,從page cache刷新到磁盤上可以通過調用fsync函數完成。

從上面類子看到,一個常用的fwrite函數過程,基本上歷經千辛萬苦,數據經過多次copy,才到達目的地。有人心生疑問,這樣會提高性能嗎,反而會降低性能吧。這個問題先放一放。

有人說,我不想通過fwrite+fflush這樣組合,我想直接寫到page cache。這就是我們常見的文件IO調用read/write函數。這些函數基本上是一個函數對應着一個系統調用,如sys_read/sys_write. 調用write函數,是直接通過系統調用把數據從應用層拷貝到內核層,從application buffer 拷貝到 page cache 中。

系統調用,write會觸發用戶態/內核態切換?是的。那有沒有辦法避免這些消耗。這時候該mmap出場了,mmappage cache 地址空間映射到用戶空間,應用程序像操作應用層內存一樣,寫文件。省去了系統調用開銷。

那如果繼續刨根問底,如果想繞過page cache,直接把數據送到磁盤設備上怎么辦。通過open文件帶上O_DIRECT參數,這是write該文件。就是直接寫到設備上。

如果繼續較勁,直接寫扇區有沒有辦法。這就是所謂的RAW設備寫,繞開了文件系統,直接寫扇區,想fdsikddcpio之類的工具就是這一類操作。

 

2.       IO調用鏈

列舉了上述各種穿透各種cache 層寫操作,可以看到系統提供的接口相當豐富,滿足你各種寫要求。下面通過講解圖一,了解一下文件IO的調用鏈。

fwrite是系統提供的最上層接口,也是最常用的接口。它在用戶進程空間開辟一個buffer,將多次小數據量相鄰寫操作先緩存起來,合並,最終調用write函數一次性寫入(或者將大塊數據分解多次write調用)。

Write函數通過調用系統調用接口,將數據從應用層copy到內核層,所以write會觸發內核態/用戶態切換。當數據到達page cache后,內核並不會立即把數據往下傳遞。而是返回用戶空間。數據什么時候寫入硬盤,有內核IO調度決定,所以write是一個異步調用。這一點和read不同,read調用是先檢查page cache里面是否有數據,如果有,就取出來返回用戶,如果沒有,就同步傳遞下去並等待有數據,再返回用戶,所以read是一個同步過程。當然你也可以把write的異步過程改成同步過程,就是在open文件的時候帶上O_SYNC標記。

數據到了page cache后,內核有pdflush線程在不停的檢測臟頁,判斷是否要寫回到磁盤中。把需要寫回的頁提交到IO隊列——即IO調度隊列。又IO調度隊列調度策略調度何時寫回。

提到IO調度隊列,不得不提一下磁盤結構。這里要講一下,磁頭和電梯一樣,盡量走到頭再回來,避免來回搶占是跑,磁盤也是單向旋轉,不會反復逆時針順時針轉的。從網上copy一個圖下來,具體這里就不介紹。

clip_image003

IO隊列有2個主要任務。一是合並相鄰扇區的,而是排序。合並相信很容易理解,排序就是盡量按照磁盤選擇方向和磁頭前進方向排序。因為磁頭尋道時間是和昂貴的。

這里IO隊列和我們常用的分析工具IOStat關系密切。IOStatrrqm/s wrqm/s表示讀寫合並個數。avgqu-sz表示平均隊列長度。

內核中有多種IO調度算法。當硬盤是SSD時候,沒有什么磁道磁頭,人家是隨機讀寫的,加上這些調度算法反而畫蛇添足。OK,剛好有個調度算法叫noop調度算法,就是什么都不錯(合並是做了)。剛好可以用來配置SSD硬盤的系統。

IO隊列出來后,就到了驅動層(當然內核中有更多的細分層,這里忽略掉),驅動層通過DMA,將數據寫入磁盤cache

至於磁盤cache時候寫入磁盤介質,那是磁盤控制器自己的事情。如果想要睡個安慰覺,確認要寫到磁盤介質上。就調用fsync函數吧。可以確定寫到磁盤上了。

 

3.       一致性和安全性

談完調用細節,再將一下一致性問題和安全問題。既然數據沒有到到磁盤介質前,可能處在不同的物理內存cache中,那么如果出現進程死機,內核死,掉電這樣事件發生。數據會丟失嗎。

當進程死機后:只有數據還處在application cacheCLib cache時候,數據會丟失。數據到了page cache。進程core掉,即使數據還沒有到硬盤。數據也不會丟失。

當內核core掉后,只要數據沒有到達disk cache,數據都會丟失。

掉電情況呢,哈哈,這時候神也救不了你,哭吧。

那么一致性呢,如果兩個進程或線程同時寫,會寫亂嗎?或A進程寫,B進程讀,會寫臟嗎?

文章寫到這里,寫得太長了,就舉出各種各樣的例子。講一下大概判斷原則吧。fwrite操作的buffer是在進程私有空間,兩個線程讀寫,肯定需要鎖保護的。如果進程,各有各的地址空間。是否要加鎖,看應用場景。

write操作如果寫大小小於PIPE_BUF(一般是4096),是原子操作,能保證兩個進程“AAA”,“BBB”寫操作,不會出現“ABAABB”這樣的數據交錯。O_APPEND 標志能保證每次重新計算pos,寫到文件尾的原子性。

數據到了內核層后,內核會加鎖,會保證一致性的。

 

4.       性能問題

 性能從系統層面和設備層面去分析;磁盤的物理特性從根本上決定了性能。IO的調度策略,系統調用也是致命殺手。

磁盤的尋道時間是相當的慢,平均尋道時間大概是在10ms,也就是是每秒只能100-200次尋道。

磁盤轉速也是影響性能的關鍵,目前最快15000rpm,大概就每秒500轉,滿打滿算,就讓磁頭不尋道,設想所有的數據連續存放在一個柱面上。大家可以算一下每秒最多可以讀多少數據。當然這個是理論值。一般情況下,盤片轉太快,磁頭感應跟不上,所以需要轉幾圈才能完全讀出磁道內容。

另外設備接口總線傳輸率是實際速率的上限。

另外有些等密度磁盤,磁盤外圍磁道扇區多,線速度快,如果頻繁操作的數據放在外圍扇區,也能提高性能。

利用多磁盤並發操作,也不失為提高性能的手段。

這里給個業界經驗值:機械硬盤順序寫~30MB,順序讀取速率一般~50MB好的可以達到100M, SSD讀達到~400MBSSD寫性能和機械硬盤差不多。

 

Ps

O_DIRECT RAW設備最根本的區別是O_DIRECT是基於文件系統的,也就是在應用層來看,其操作對象是文件句柄,內核和文件層來看,其操作是基於inode和數據塊,這些概念都是和ext2/3的文件系統相關,寫到磁盤上最終是ext3文件。

RAW設備寫是沒有文件系統概念,操作的是扇區號,操作對象是扇區,寫出來的東西不一定是ext3文件(如果按照ext3規則寫就是ext3文件)。

一般基於O_DIRECT來設計優化自己的文件模塊,是不滿系統的cache和調度策略,自己在應用層實現這些,來制定自己特有的業務特色文件讀寫。但是寫出來的東西是ext3文件,該磁盤卸下來,mount到其他任何linux系統上,都可以查看。

而基於RAW設備的設計系統,一般是不滿現有ext3的諸多缺陷,設計自己的文件系統。自己設計文件布局和索引方式。舉個極端例子:把整個磁盤做一個文件來寫,不要索引。這樣沒有inode限制,沒有文件大小限制,磁盤有多大,文件就能多大。這樣的磁盤卸下來,mount到其他linux系統上,是無法識別其數據的。

兩者都要通過驅動層讀寫;在系統引導啟動,還處於實模式的時候,可以通過bios接口讀寫raw設備。

 

《直接io的優缺點》https://www.ibm.com/developerworks/cn/linux/l-cn-directio/

《aio和direct io關系以及DMA》http://blog.csdn.net/brucexu1978/article/details/7085924

《io模型矩陣》http://www.ibm.com/developerworks/cn/linux/l-async/

 

《為什么nginx引入多線程:減少阻塞IO影響》http://www.aikaiyuan.com/10228.html

《aio機制在nginx之中的使用:output chain 》http://www.aikaiyuan.com/8867.html

《nginx對Linux native AIO機制的封裝》http://www.aikaiyuan.com/8869.html

 

《nginx output chain 分析》https://my.oschina.net/astute/blog/316954

《sendfile和read/write的內核切換次數》http://www.cnblogs.com/zfyouxi/p/4196170.html

 

 

 

Linux 中直接 I/O 機制的介紹

直接 I/O 的動機

在介紹直接 I/O 之前,這一小節先介紹一下為什么會出現直接 I/O 這種機制,即傳統的 I/O 操作存在哪些缺點。

什么是緩存 I/O (Buffered I/O)

緩存 I/O 又被稱作標准 I/O,大多數文件系統的默認 I/O 操作都是緩存 I/O。在 Linux 的緩存 I/O 機制中,操作系統會將 I/O 的數據緩存在文件系統的頁緩存( page cache )中,也就是說,數據會先被拷貝到操作系統內核的緩沖區中,然后才會從操作系統內核的緩沖區拷貝到應用程序的地址空間。緩存 I/O 有以下這些優點:

  • 緩存 I/O 使用了操作系統內核緩沖區,在一定程度上分離了應用程序空間和實際的物理設備。
  • 緩存 I/O 可以減少讀盤的次數,從而提高性能。

當應用程序嘗試讀取某塊數據的時候,如果這塊數據已經存放在了頁緩存中,那么這塊數據就可以立即返回給應用程序,而不需要經過實際的物理讀盤操作。當然,如果數據在應用程序讀取之前並未被存放在頁緩存中,那么就需要先將數據從磁盤讀到頁緩存中去。對於寫操作來說,應用程序也會將數據先寫到頁緩存中去,數據是否被立即寫到磁盤上去取決於應用程序所采用的寫操作機制:如果用戶采用的是同步寫機制( synchronous writes ), 那么數據會立即被寫回到磁盤上,應用程序會一直等到數據被寫完為止;如果用戶采用的是延遲寫機制( deferred writes ),那么應用程序就完全不需要等到數據全部被寫回到磁盤,數據只要被寫到頁緩存中去就可以了。在延遲寫機制的情況下,操作系統會定期地將放在頁緩存中的數據刷到磁盤上。與異步寫機制( asynchronous writes )不同的是,延遲寫機制在數據完全寫到磁盤上的時候不會通知應用程序,而異步寫機制在數據完全寫到磁盤上的時候是會返回給應用程序的。所以延遲寫機制本身是存在數據丟失的風險的,而異步寫機制則不會有這方面的擔心。

緩存 I/O 的缺點

在緩存 I/O 機制中,DMA 方式可以將數據直接從磁盤讀到頁緩存中,或者將數據從頁緩存直接寫回到磁盤上,而不能直接在應用程序地址空間和磁盤之間進行數據傳輸,這樣的話,數據在傳輸過程中需要在應用程序地址空間和頁緩存之間進行多次數據拷貝操作,這些數據拷貝操作所帶來的 CPU 以及內存開銷是非常大的。

對於某些特殊的應用程序來說,避開操作系統內核緩沖區而直接在應用程序地址空間和磁盤之間傳輸數據會比使用操作系統內核緩沖區獲取更好的性能,下邊這一小節中提到的自緩存應用程序就是其中的一種。

自緩存應用程序( self-caching applications)

對於某些應用程序來說,它會有它自己的數據緩存機制,比如,它會將數據緩存在應用程序地址空間,這類應用程序完全不需要使用操作系統內核中的高速緩沖存儲器,這類應用程序就被稱作是自緩存應用程序( self-caching applications )。數據庫管理系統是這類應用程序的一個代表。自緩存應用程序傾向於使用數據的邏輯表達方式,而非物理表達方式;當系統內存較低的時候,自緩存應用程序會讓這種數據的邏輯緩存被換出,而並非是磁盤上實際的數據被換出。自緩存應用程序對要操作的數據的語義了如指掌,所以它可以采用更加高效的緩存替換算法。自緩存應用程序有可能會在多台主機之間共享一塊內存,那么自緩存應用程序就需要提供一種能夠有效地將用戶地址空間的緩存數據置為無效的機制,從而確保應用程序地址空間緩存數據的一致性。

對於自緩存應用程序來說,緩存 I/O 明顯不是一個好的選擇。由此引出我們這篇文章着重要介紹的 Linux 中的直接 I/O 技術。Linux 中的直接 I/O 技術非常適用於自緩存這類應用程序,該技術省略掉緩存 I/O 技術中操作系統內核緩沖區的使用,數據直接在應用程序地址空間和磁盤之間進行傳輸,從而使得自緩存應用程序可以省略掉復雜的系統級別的緩存結構,而執行程序自己定義的數據讀寫管理,從而降低系統級別的管理對應用程序訪問數據的影響。在下面一節中,我們會着重介紹 Linux 中提供的直接 I/O 機制的設計與實現,該機制為自緩存應用程序提供了很好的支持。

Linux 2.6 中的直接 I/O 技術

Linux 2.6 中提供的幾種文件訪問方式

所有的 I/O 操作都是通過讀文件或者寫文件來完成的。在這里,我們把所有的外圍設備,包括鍵盤和顯示器,都看成是文件系統中的文件。訪問文件的方法多種多樣,這里列出下邊這幾種 Linux 2.6 中支持的文件訪問方式。

標准訪問文件的方式

在 Linux 中,這種訪問文件的方式是通過兩個系統調用實現的:read() 和 write()。當應用程序調用 read() 系統調用讀取一塊數據的時候,如果該塊數據已經在內存中了,那么就直接從內存中讀出該數據並返回給應用程序;如果該塊數據不在內存中,那么數據會被從磁盤上讀到頁高緩存中去,然后再從頁緩存中拷貝到用戶地址空間中去。如果一個進程讀取某個文件,那么其他進程就都不可以讀取或者更改該文件;對於寫數據操作來說,當一個進程調用了 write() 系統調用往某個文件中寫數據的時候,數據會先從用戶地址空間拷貝到操作系統內核地址空間的頁緩存中去,然后才被寫到磁盤上。但是對於這種標准的訪問文件的方式來說,在數據被寫到頁緩存中的時候,write() 系統調用就算執行完成,並不會等數據完全寫入到磁盤上。Linux 在這里采用的是我們前邊提到的延遲寫機制( deferred writes )。

圖 1. 以標准的方式對文件進行讀寫

圖 1. 以標准的方式對文件進行讀寫

同步訪問文件的方式

同步訪問文件的方式與上邊這種標准的訪問文件的方式比較類似,這兩種方法一個很關鍵的區別就是:同步訪問文件的時候,寫數據的操作是在數據完全被寫回磁盤上才算完成的;而標准訪問文件方式的寫數據操作是在數據被寫到頁高速緩沖存儲器中的時候就算執行完成了。

圖 2. 數據同步寫回磁盤

圖 2. 數據同步寫回磁盤

內存映射方式

在很多操作系統包括 Linux 中,內存區域( memory region )是可以跟一個普通的文件或者塊設備文件的某一個部分關聯起來的,若進程要訪問內存頁中某個字節的數據,操作系統就會將訪問該內存區域的操作轉換為相應的訪問文件的某個字節的操作。Linux 中提供了系統調用 mmap() 來實現這種文件訪問方式。與標准的訪問文件的方式相比,內存映射方式可以減少標准訪問文件方式中 read() 系統調用所帶來的數據拷貝操作,即減少數據在用戶地址空間和操作系統內核地址空間之間的拷貝操作。映射通常適用於較大范圍,對於相同長度的數據來講,映射所帶來的開銷遠遠低於 CPU 拷貝所帶來的開銷。當大量數據需要傳輸的時候,采用內存映射方式去訪問文件會獲得比較好的效率。

圖 3. 利用 mmap 代替 read

圖 3. 利用 mmap 代替 read

直接 I/O 方式

凡是通過直接 I/O 方式進行數據傳輸,數據均直接在用戶地址空間的緩沖區和磁盤之間直接進行傳輸,完全不需要頁緩存的支持。操作系統層提供的緩存往往會使應用程序在讀寫數據的時候獲得更好的性能,但是對於某些特殊的應用程序,比如說數據庫管理系統這類應用,他們更傾向於選擇他們自己的緩存機制,因為數據庫管理系統往往比操作系統更了解數據庫中存放的數據,數據庫管理系統可以提供一種更加有效的緩存機制來提高數據庫中數據的存取性能。

圖 4. 數據傳輸不經過操作系統內核緩沖區

圖 4. 數據傳輸不經過操作系統內核緩沖區

異步訪問文件的方式

Linux 異步 I/O 是 Linux 2.6 中的一個標准特性,其本質思想就是進程發出數據傳輸請求之后,進程不會被阻塞,也不用等待任何操作完成,進程可以在數據傳輸的時候繼續執行其他的操作。相對於同步訪問文件的方式來說,異步訪問文件的方式可以提高應用程序的效率,並且提高系統資源利用率。直接 I/O 經常會和異步訪問文件的方式結合在一起使用。

圖 5.CPU 處理其他任務和 I/O 操作可以重疊執行

圖 5.CPU 處理其他任務和 I/O 操作可以重疊執行

在下邊這一小節中,我們會重點介紹 Linux 2.6 內核中直接 I/O 的設計與實現。

Linux 2.6 中直接 I/O 的設計與實現

在塊設備或者網絡設備中執行直接 I/O 完全不用擔心實現直接 I/O 的問題,Linux 2.6 操作系統內核中高層代碼已經設置和使用了直接 I/O,驅動程序級別的代碼甚至不需要知道已經執行了直接 I/O;但是對於字符設備來說,執行直接 I/O 是不可行的,Linux 2.6 提供了函數 get_user_pages() 用於實現直接 I/O。本小節會分別對這兩種情況進行介紹。 

內核為塊設備執行直接 I/O 提供的支持

要在塊設備中執行直接 I/O,進程必須在打開文件的時候設置對文件的訪問模式為 O_DIRECT,這樣就等於告訴操作系統進程在接下來使用 read() 或者 write() 系統調用去讀寫文件的時候使用的是直接 I/O 方式,所傳輸的數據均不經過操作系統內核緩存空間。使用直接 I/O 讀寫數據必須要注意緩沖區對齊( buffer alignment )以及緩沖區的大小的問題,即對應 read() 以及 write() 系統調用的第二個和第三個參數。這里邊說的對齊指的是文件系統塊大小的對齊,緩沖區的大小也必須是該塊大小的整數倍。

這一節主要介紹三個函數:open(),read() 以及 write()。Linux 中訪問文件具有多樣性,所以這三個函數對於處理不同的文件訪問方式定義了不同的處理方法,本文主要介紹其與直接 I/O 方式相關的函數與功能.首先,先來看 open() 系統調用,其函數原型如下所示:

1
int open(const char *pathname, int oflag, … /*, mode_t mode * / ) ;

以下列出了 Linux 2.6 內核定義的系統調用 open() 所使用的標識符宏定義:

表 1. open() 系統調用提供的標識符

當應用程序需要直接訪問文件而不經過操作系統頁高速緩沖存儲器的時候,它打開文件的時候需要指定 O_DIRECT 標識符。

操作系統內核中處理 open() 系統調用的內核函數是 sys_open(),sys_open() 會調用 do_sys_open() 去處理主要的打開操作。它主要做了三件事情:首先, 它調用 getname() 從進程地址空間中讀取文件的路徑名;接着,do_sys_open() 調用 get_unused_fd() 從進程的文件表中找到一個空閑的文件表指針,相應的新文件描述符就存放在本地變量 fd 中;之后,函數 do_filp_open() 會根據傳入的參數去執行相應的打開操作。清單 1 列出了操作系統內核中處理 open() 系統調用的一個主要函數關系圖。

清單 1. 主要調用函數關系圖
1
2
3
4
5
6
7
sys_open()
   |-----do_sys_open()
          |---------getname()
          |---------get_unused_fd()
          |---------do_filp_open()
                     |--------nameidata_to_filp()
                               |----------__dentry_open()

函數 do_flip_open() 在執行的過程中會調用函數 nameidata_to_filp(),而 nameidata_to_filp() 最終會調用 __dentry_open() 函數,若進程指定了 O_DIRECT 標識符,則該函數會檢查直接 I./O 操作是否可以作用於該文件。清單 2 列出了 __dentry_open() 函數中與直接 I/O 操作相關的代碼。

清單 2. 函數 dentry_open() 中與直接 I/O 相關的代碼
1
2
3
4
5
6
7
8
if (f->f_flags & O_DIRECT) {
     if (!f->f_mapping->a_ops ||
        ((!f->f_mapping->a_ops->direct_IO) &&
        (!f->f_mapping->a_ops->get_xip_page))) {
         fput(f);
         f = ERR_PTR(-EINVAL);
     }
}

當文件打開時指定了 O_DIRECT 標識符,那么操作系統就會知道接下來對文件的讀或者寫操作都是要使用直接 I/O 方式的。

下邊我們來看一下當進程通過 read() 系統調用讀取一個已經設置了 O_DIRECT 標識符的文件的時候,系統都做了哪些處理。 函數 read() 的原型如下所示:

1
ssize_t read(int feledes, void *buff, size_t nbytes) ;

操作系統中處理 read() 函數的入口函數是 sys_read(),其主要的調用函數關系圖如下清單 3 所示:

清單 3. 主調用函數關系圖
1
2
3
4
5
sys_read()
   |-----vfs_read()
        |----generic_file_read()
              |----generic_file_aio_read()
                   |--------- generic_file_direct_IO()

函數 sys_read() 從進程中獲取文件描述符以及文件當前的操作位置后會調用 vfs_read() 函數去執行具體的操作過程,而 vfs_read() 函數最終是調用了 file 結構中的相關操作去完成文件的讀操作,即調用了 generic_file_read() 函數,其代碼如下所示:

清單 4. 函數 generic_file_read()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
ssize_t
generic_file_read(struct file *filp,
char __user *buf, size_t count, loff_t *ppos)
{
     struct iovec local_iov = { .iov_base = buf, .iov_len = count };
     struct kiocb kiocb;
     ssize_t ret;
 
     init_sync_kiocb(&kiocb, filp);
     ret = __generic_file_aio_read(&kiocb, &local_iov, 1, ppos);
     if (-EIOCBQUEUED == ret)
         ret = wait_on_sync_kiocb(&kiocb);
     return ret;
}

函數 generic_file_read() 初始化了 iovec 以及 kiocb 描述符。描述符 iovec 主要是用於存放兩個內容:用來接收所讀取數據的用戶地址空間緩沖區的地址和緩沖區的大小;描述符 kiocb 用來跟蹤 I/O 操作的完成狀態。之后,函數 generic_file_read() 凋用函數 __generic_file_aio_read()。該函數檢查 iovec 中描述的用戶地址空間緩沖區是否可用,接着檢查訪問模式,若訪問模式描述符設置了 O_DIRECT,則執行與直接 I/O 相關的代碼。函數 __generic_file_aio_read() 中與直接 I/O 有關的代碼如下所示:

清單 5. 函數 __generic_file_aio_read() 中與直接 I/O 有關的代碼
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if (filp->f_flags & O_DIRECT) {
     loff_t pos = *ppos, size;
     struct address_space *mapping;
     struct inode *inode;
 
     mapping = filp->f_mapping;
     inode = mapping->host;
     retval = 0;
     if (!count)
         goto out;
     size = i_size_read(inode);
     if (pos < size ) {
         retval = generic_file_direct_IO (READ, iocb,
                     iov, pos, nr_segs);
         if (retval > 0 && !is_sync_kiocb(iocb))
             retval = -EIOCBQUEUED;
         if (retval > 0)
             *ppos = pos + retval;
     }
     file_accessed(filp);
     goto out;
}

上邊的代碼段主要是檢查了文件指針的值,文件的大小以及所請求讀取的字節數目等,之后,該函數調用 generic_file_direct_io(),並將操作類型 READ,描述符 iocb,描述符 iovec,當前文件指針的值以及在描述符 io_vec  中指定的用戶地址空間緩沖區的個數等值作為參數傳給它。當 generic_file_direct_io() 函數執行完成,函數 __generic_file_aio_read()會繼續執行去完成后續操作:更新文件指針,設置訪問文件 i 節點的時間戳;這些操作全部執行完成以后,函數返回。 函數 generic_file_direct_IO() 會用到五個參數,各參數的含義如下所示:

  • rw:操作類型,可以是 READ 或者 WRITE
  • iocb:指針,指向 kiocb 描述符 
  • iov:指針,指向 iovec 描述符數組
  • offset:file 結構偏移量
  • nr_segs:iov 數組中 iovec 的個數
1
函數 generic_file_direct_IO() 代碼如下所示:
清單 6. 函數 generic_file_direct_IO()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static ssize_t
generic_file_direct_IO(int rw, struct kiocb *iocb, const struct iovec *iov,
     loff_t offset, unsigned long nr_segs)
{
     struct file *file = iocb->ki_filp;
     struct address_space *mapping = file->f_mapping;
     ssize_t retval;
     size_t write_len = 0;
 
     if (rw == WRITE) {
         write_len = iov_length(iov, nr_segs);
             if (mapping_mapped(mapping))
             unmap_mapping_range(mapping, offset, write_len, 0);
     }
 
     retval = filemap_write_and_wait(mapping);
     if (retval == 0) {
         retval = mapping->a_ops->direct_IO(rw, iocb, iov,
                         offset, nr_segs);
         if (rw == WRITE && mapping->nrpages) {
             pgoff_t end = (offset + write_len - 1)
                         >> PAGE_CACHE_SHIFT;
             int err = invalidate_inode_pages2_range(mapping,
                     offset >> PAGE_CACHE_SHIFT, end);
             if (err)
                 retval = err;
         }
     }
     return retval;
}

函數 generic_file_direct_IO() 對 WRITE 操作類型進行了一些特殊處理,這在下邊介紹 write() 系統調用的時候再做說明。除此之外,它主要是調用了 direct_IO 方法去執行直接 I/O 的讀或者寫操作。在進行直接  I/O  讀操作之前,先將頁緩存中的相關臟數據刷回到磁盤上去,這樣做可以確保從磁盤上讀到的是最新的數據。這里的 direct_IO 方法最終會對應到 __blockdev_direct_IO() 函數上去。__blockdev_direct_IO() 函數的代碼如下所示:

清單 7. 函數 __blockdev_direct_IO()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
ssize_t
__blockdev_direct_IO(int rw, struct kiocb *iocb, struct inode *inode,
     struct block_device *bdev, const struct iovec *iov, loff_t offset,
     unsigned long nr_segs, get_block_t get_block, dio_iodone_t end_io,
     int dio_lock_type)
{
     int seg;
     size_t size;
     unsigned long addr;
     unsigned blkbits = inode->i_blkbits;
     unsigned bdev_blkbits = 0;
     unsigned blocksize_mask = (1 << blkbits ) - 1;
     ssize_t retval = -EINVAL;
     loff_t end = offset ;
     struct dio *dio;
     int release_i_mutex = 0 ;
     int acquire_i_mutex = 0 ;
 
     if (rw & WRITE)
         rw = WRITE_SYNC ;
 
     if (bdev)
         bdev_blkbits = blksize_bits (bdev_hardsect_size(bdev));
 
     if (offset & blocksize_mask) {
         if (bdev)
             blkbits = bdev_blkbits ;
         blocksize_mask = (1 << blkbits) - 1;
         if (offset & blocksize_mask)
             goto out;
     }
 
     for ( seg = 0 ; seg < nr_segs; seg++) {
         addr = (unsigned long)iov[seg].iov_base;
         size = iov [seg].iov_len;
         end += size;
         if ((addr & blocksize_mask) || (size & blocksize_mask))  {
             if (bdev)
                 blkbits = bdev_blkbits ;
             blocksize_mask = (1 << blkbits) - 1;
             if ((addr & blocksize_mask) || (size & blocksize_mask)) 
                 goto out;
         }
     }
 
     dio = kmalloc (sizeof(*dio), GFP_KERNEL);
     retval = -ENOMEM;
     if (!dio)
         goto out;
     dio->lock_type = dio_lock_type;
     if (dio_lock_type != DIO_NO_LOCKING) {
         if (rw == READ && end > offset) {
             struct address_space *mapping;
 
             mapping = iocb->ki_filp->f_mapping;
             if (dio_lock_type != DIO_OWN_LOCKING) {
                 mutex_lock(&inode->i_mutex);
                 release_i_mutex = 1;
             }
 
             retval = filemap_write_and_wait_range(mapping, offset,
                                  end - 1);
             if (retval) {
                 kfree(dio);
                 goto out;
             }
 
             if (dio_lock_type == DIO_OWN_LOCKING) {
                 mutex_unlock(&inode->i_mutex);
                 acquire_i_mutex = 1;
             }
         }
 
         if (dio_lock_type == DIO_LOCKING)
             down_read_non_owner(&inode->i_alloc_sem);
     }
 
     dio->is_async = !is_sync_kiocb(iocb) && !((rw & WRITE) &&
         (end > i_size_read(inode)));
 
     retval = direct_io_worker(rw, iocb, inode, iov, offset,
                 nr_segs, blkbits, get_block, end_io, dio);
 
     if (rw == READ && dio_lock_type == DIO_LOCKING)
         release_i_mutex = 0;
 
out:
     if (release_i_mutex)
         mutex_unlock(&inode->i_mutex);
     else if (acquire_i_mutex)
         mutex_lock(&inode->i_mutex);
     return retval;
}

該函數將要讀或者要寫的數據進行拆分,並檢查緩沖區對齊的情況。本文在前邊介紹 open() 函數的時候指出,使用直接 I/O 讀寫數據的時候必須要注意緩沖區對齊的問題,從上邊的代碼可以看出,緩沖區對齊的檢查是在 __blockdev_direct_IO() 函數里邊進行的。用戶地址空間的緩沖區可以通過 iov 數組中的 iovec 描述符確定。直接 I/O 的讀操作或者寫操作都是同步進行的,也就是說,函數 __blockdev_direct_IO() 會一直等到所有的 I/O 操作都結束才會返回,因此,一旦應用程序 read() 系統調用返回,應用程序就可以訪問用戶地址空間中含有相應數據的緩沖區。但是,這種方法在應用程序讀操作完成之前不能關閉應用程序,這將會導致關閉應用程序緩慢。

1
2
3
4
5
6
 
接下來我們看一下 write() 系統調用中與直接 I/O 相關的處理實現過程。函數 write() 的原型如下所示:
 
  ssize_t write(int filedes, const void * buff, size_t nbytes) ;
 
操作系統中處理 write() 系統調用的入口函數是 sys_write()。其主要的調用函數關系如下所示:
清單 8. 主調用函數關系圖
1
2
3
4
5
6
7
8
sys_write()
       |-----vfs_write()
          |----generic_file_write()
                |----generic_file_aio_read()
                      |---- __generic_file_write_nolock()
                            |-- __generic_file_aio_write_nolock
                                |-- generic_file_direct_write()
                                    |-- generic_file_direct_IO()

函數 sys_write() 幾乎與 sys_read() 執行相同的步驟,它從進程中獲取文件描述符以及文件當前的操作位置后即調用 vfs_write() 函數去執行具體的操作過程,而 vfs_write() 函數最終是調用了 file 結構中的相關操作完成文件的寫操作,即調用了 generic_file_write() 函數。在函數 generic_file_write() 中, 函數 generic_file_write_nolock() 最終調用 generic_file_aio_write_nolock() 函數去檢查 O_DIRECT 的設置,並且調用  generic_file_direct_write() 函數去執行直接 I/O 寫操作。

1
函數 generic_file_aio_write_nolock() 中與直接 I/O 相關的代碼如下所示:
清單 9. 函數 generic_file_aio_write_nolock() 中與直接 I/O 相關的代碼
1
2
3
4
5
6
7
8
9
if (unlikely(file->f_flags & O_DIRECT)) {
     written = generic_file_direct_write(iocb, iov,
             &nr_segs, pos, ppos, count, ocount);
     if (written < 0 || written == count)
         goto out;
    
     pos += written;
     count -= written;
}

從上邊代碼可以看出, generic_file_aio_write_nolock() 調用了 generic_file_direct_write() 函數去執行直接 I/O 操作;而在 generic_file_direct_write() 函數中,跟讀操作過程類似,它最終也是調用了 generic_file_direct_IO() 函數去執行直接 I/O 寫操作。與直接 I/O 讀操作不同的是,這次需要將操作類型 WRITE 作為參數傳給函數 generic_file_direct_IO()。

前邊介紹了 generic_file_direct_IO() 的主體 direct_IO 方法:__blockdev_direct_IO()。函數 generic_file_direct_IO() 對 WRITE 操作類型進行了一些額外的處理。當操作類型是 WRITE 的時候,若發現該使用直接 I/O 的文件已經與其他一個或者多個進程存在關聯的內存映射,那么就調用 unmap_mapping_range() 函數去取消建立在該文件上的所有的內存映射,並將頁緩存中相關的所有 dirty 位被置位的臟頁面刷回到磁盤上去。對於直接  I/O  寫操作來說,這樣做可以保證寫到磁盤上的數據是最新的,否則,即將用直接  I/O  方式寫入到磁盤上的數據很可能會因為頁緩存中已經存在的臟數據而失效。在直接  I/O  寫操作完成之后,在頁緩存中相關的臟數據就都已經失效了,磁盤與頁緩存中的數據內容必須保持同步。

如何在字符設備中執行直接 I/O

在字符設備中執行直接 I/O 可能是有害的,只有在確定了設置緩沖 I/O 的開銷非常巨大的時候才建議使用直接 I/O。在 Linux 2.6 的內核中,實現直接 I/O 的關鍵是函數 get_user_pages() 函數。其函數原型如下所示:

1
2
3
4
5
6
7
8
int get_user_pages(struct task_struct *tsk,
struct mm_struct *mm,
unsigned long start,
     int len,
int write,
int force,
struct page **pages,
struct vm_area_struct **vmas);

該函數的參數含義如下所示:

  • tsk:指向執行映射的進程的指針;該參數的主要用途是用來告訴操作系統內核,映射頁面所產生的頁錯誤由誰來負責,該參數幾乎總是 current。
  • mm:指向被映射的用戶地址空間的內存管理結構的指針,該參數通常是 current->mm 。
  • start: 需要映射的用戶地址空間的地址。
  • len:頁內緩沖區的長度。
  • write:如果需要對所映射的頁面有寫權限,該參數的設置得是非零。
  • force:該參數的設置通知 get_user_pages() 函數無需考慮對指定內存頁的保護,直接提供所請求的讀或者寫訪問。
  • page:輸出參數。調用成功后,該參數中包含一個描述用戶空間頁面的 page 結構的指針列表。
  • vmas:輸出參數。若該參數非空,則該參數包含一個指向 vm_area_struct 結構的指針,該 vm_area_struct 結構包含了每一個所映射的頁面。

在使用 get_user_pages() 函數的時候,往往還需要配合使用以下這些函數:

1
2
3
4
void down_read(struct rw_semaphore *sem);
void up_read(struct rw_semaphore *sem);
void SetPageDirty(struct page *page);
void page_cache_release(struct page *page);

首先,在使用 get_user_pages() 函數之前,需要先調用 down_read() 函數將 mmap 為獲得用戶地址空間的讀取者 / 寫入者信號量設置為讀模式;在調用完 get_user_pages() 函數之后,再調用配對函數 up_read() 釋放信號量 sem。若 get_user_pages() 調用失敗,則返回錯誤代碼;若調用成功,則返回實際被映射的頁面數,該數目有可能比請求的數量少。調用成功后所映射的用戶頁面被鎖在內存中,調用者可以通過 page 結構的指針去訪問這些用戶頁面。

直接 I/O 的調用者必須進行善后工作,一旦直接 I/O 操作完成,用戶內存頁面必須從頁緩存中釋放。在用戶內存頁被釋放之前,如果這些頁面中的內容改變了,那么調用者必須要通知操作系統內核,否則虛擬存儲子系統會認為這些頁面是干凈的,從而導致這些數據被修改了的頁面在被釋放之前無法被寫回到永久存儲中去。因此,如果改變了頁中的數據,那么就必須使用 SetPageDirty() 函數標記出每個被改變的頁。對於 Linux 2.6.18.1,該宏定義在 /include/linux/page_flags.h 中。執行該操作的代碼一般需要先檢查頁,以確保該頁不在內存映射的保留區域內,因為這個區的頁是不會被交換出去的,其代碼如下所示:

1
2
if (!PageReserved(page))
     SetPageDirty(page);

但是,由於用戶空間所映射的頁面通常不會被標記為保留,所以上述代碼中的檢查並不是嚴格要求的。

最終,在直接 I/O 操作完成之后,不管頁面是否被改變,它們都必須從頁緩存中釋放,否則那些頁面永遠都會存在在那里。函數 page_cache_release() 就是用於釋放這些頁的。頁面被釋放之后,調用者就不能再次訪問它們。

關於如何在字符設備驅動程序中加入對直接 I/O 的支持,Linux 2.6.18.1 源代碼中 /drivers/scsi/st.c 給出了一個完整的例子。其中,函數 sgl_map_user_pages()和 sgl_map_user_pages()幾乎涵蓋了本節中介紹的所有內容。

直接 I/O 技術的特點

直接 I/O 的優點

直接 I/O 最主要的優點就是通過減少操作系統內核緩沖區和應用程序地址空間的數據拷貝次數,降低了對文件讀取和寫入時所帶來的 CPU 的使用以及內存帶寬的占用。這對於某些特殊的應用程序,比如自緩存應用程序來說,不失為一種好的選擇。如果要傳輸的數據量很大,使用直接 I/O 的方式進行數據傳輸,而不需要操作系統內核地址空間拷貝數據操作的參與,這將會大大提高性能。

直接 I/O 潛在可能存在的問題

直接 I/O 並不一定總能提供令人滿意的性能上的飛躍。設置直接 I/O 的開銷非常大,而直接 I/O 又不能提供緩存 I/O 的優勢。緩存 I/O 的讀操作可以從高速緩沖存儲器中獲取數據,而直接 I/O 的讀數據操作會造成磁盤的同步讀,這會帶來性能上的差異 , 並且導致進程需要較長的時間才能執行完;對於寫數據操作來說,使用直接 I/O 需要 write() 系統調用同步執行,否則應用程序將會不知道什么時候才能夠再次使用它的 I/O 緩沖區。與直接 I/O 讀操作類似的是,直接 I/O 寫操作也會導致應用程序關閉緩慢。所以,應用程序使用直接 I/O 進行數據傳輸的時候通常會和使用異步 I/O 結合使用。

總結

Linux 中的直接 I/O 訪問文件方式可以減少 CPU 的使用率以及內存帶寬的占用,但是直接 I/O 有時候也會對性能產生負面影響。所以在使用直接 I/O 之前一定要對應用程序有一個很清醒的認識,只有在確定了設置緩沖 I/O 的開銷非常巨大的情況下,才考慮使用直接 I/O。直接 I/O 經常需要跟異步 I/O 結合起來使用,本文對異步 I/O 沒有作詳細介紹,有興趣的讀者可以參看 Linux 2.6 中相關的文檔介紹。

相關主題

 

 

 

linux aio

知道異步IO已經很久了,但是直到最近,才真正用它來解決一下實際問題(在一個CPU密集型的應用中,有一些需要處理的數據可能放在磁盤上。預先知道這些數據的位置,所以預先發起異步IO讀請求。等到真正需要用到這些數據的時候,再等待異步IO完成。使用了異步IO,在發起IO請求到實際使用數據這段時間內,程序還可以繼續做其他事情)。
假此機會,也順便研究了一下linux下的異步IO的實現。

linux下主要有兩套異步IO,一套是由glibc實現的(以下稱之為glibc版本)、一套是由linux內核實現,並由libaio來封裝調用接口(以下稱之為linux版本)。


glibc版本

接口
glibc版本主要包含如下接口:
int aio_read(struct aiocb *aiocbp);  /* 提交一個異步讀 */
int aio_write(struct aiocb *aiocbp); /* 提交一個異步寫 */
int aio_cancel(int fildes, struct aiocb *aiocbp); /* 取消一個異步請求(或基於一個fd的所有異步請求,aiocbp==NULL) */
int aio_error(const struct aiocb *aiocbp);        /* 查看一個異步請求的狀態(進行中EINPROGRESS?還是已經結束或出錯?) */
ssize_t aio_return(struct aiocb *aiocbp);         /* 查看一個異步請求的返回值(跟同步讀寫定義的一樣) */
int aio_suspend(const struct aiocb * const list[], int nent, const struct timespec *timeout); /* 阻塞等待請求完成 */

其中,struct aiocb主要包含以下字段:
int               aio_fildes;        /* 要被讀寫的fd */
void *            aio_buf;           /* 讀寫操作對應的內存buffer */
__off64_t         aio_offset;        /* 讀寫操作對應的文件偏移 */
size_t            aio_nbytes;        /* 需要讀寫的字節長度 */
int               aio_reqprio;       /* 請求的優先級 */
struct sigevent   aio_sigevent;      /* 異步事件,定義異步操作完成時的通知信號或回調函數 */

 

實現
glibc的aio實現是比較通俗易懂的:
1、異步請求被提交到request_queue中;
2、request_queue實際上是一個表結構,"行"是fd、"列"是具體的請求。也就是說,同一個fd的請求會被組織在一起;
3、異步請求有優先級概念,屬於同一個fd的請求會按優先級排序,並且最終被按優先級順序處理;
4、隨着異步請求的提交,一些異步處理線程被動態創建。這些線程要做的事情就是從request_queue中取出請求,然后處理之;
5、為避免異步處理線程之間的競爭,同一個fd所對應的請求只由一個線程來處理;
6、異步處理線程同步地處理每一個請求,處理完成后在對應的aiocb中填充結果,然后觸發可能的信號通知或回調函數(回調函數是需要創建新線程來調用的);
7、異步處理線程在完成某個fd的所有請求后,進入閑置狀態;
8、異步處理線程在閑置狀態時,如果request_queue中有新的fd加入,則重新投入工作,去處理這個新fd的請求(新fd和它上一次處理的fd可以不是同一個);
9、異步處理線程處於閑置狀態一段時間后(沒有新的請求),則會自動退出。等到再有新的請求時,再去動態創建;

看起來,換作是我們,要在用戶態實現一個異步IO,似乎大概也會設計成類似的樣子……


linux版本

接口
下面再來看看linux版本的異步IO。它主要包含如下系統調用接口:
int io_setup(int maxevents, io_context_t *ctxp);  /* 創建一個異步IO上下文(io_context_t是一個句柄) */
int io_destroy(io_context_t ctx);  /* 銷毀一個異步IO上下文(如果有正在進行的異步IO,取消並等待它們完成) */
long io_submit(aio_context_t ctx_id, long nr, struct iocb **iocbpp);  /* 提交異步IO請求 */
long io_cancel(aio_context_t ctx_id, struct iocb *iocb, struct io_event *result);  /* 取消一個異步IO請求 */
longio_getevents(aio_context_t ctx_id, long min_nr, long nr, structio_event *events, struct timespec *timeout)  /*等待並獲取異步IO請求的事件(也就是異步請求的處理結果) */

其中,struct iocb主要包含以下字段:
__u16     aio_lio_opcode;     /* 請求類型(如:IOCB_CMD_PREAD=讀、IOCB_CMD_PWRITE=寫、等) */
__u32     aio_fildes;         /* 要被操作的fd */
__u64     aio_buf;            /* 讀寫操作對應的內存buffer */
__u64     aio_nbytes;         /* 需要讀寫的字節長度 */
__s64     aio_offset;         /* 讀寫操作對應的文件偏移 */
__u64     aio_data;           /* 請求可攜帶的私有數據(在io_getevents時能夠從io_event結果中取得) */
__u32     aio_flags;          /* 可選IOCB_FLAG_RESFD標記,表示異步請求處理完成時使用eventfd進行通知(百度一下) */
__u32     aio_resfd;          /* 有IOCB_FLAG_RESFD標記時,接收通知的eventfd */

其中,struct io_event主要包含以下字段:
__u64     data;               /* 對應iocb的aio_data的值 */
__u64     obj;                /* 指向對應iocb的指針 */
__s64     res;                /* 對應IO請求的結果(>=0: 相當於對應的同步調用的返回值;<0: -errno) */

 

實現
io_context_t句柄在內核中對應一個struct kioctx結構,用來給一組異步IO請求提供一個上下文。其主要包含以下字段:
struct mm_struct*     mm;             /* 調用者進程對應的內存管理結構(代表了調用者的虛擬地址空間) */
unsigned long         user_id;        /* 上下文ID,也就是io_context_t句柄的值(等於ring_info.mmap_base) */
struct hlist_node     list;           /* 屬於同一地址空間的所有kioctx結構通過這個list串連起來,鏈表頭是mm->ioctx_list */
wait_queue_head_t     wait;           /* 等待隊列(io_getevents系統調用可能需要等待,調用者就在該等待隊列上睡眠) */
int                   reqs_active;    /* 進行中的請求數目 */
struct list_head      active_reqs;    /* 進行中的請求隊列 */
unsigned              max_reqs;       /* 最大請求數(對應io_setup調用的int maxevents參數) */
struct list_head      run_list;       /* 需要aio線程處理的請求列表(某些情況下,IO請求可能交給aio線程來提交) */
struct delayed_work   wq;             /* 延遲任務隊列(當需要aio線程處理請求時,將wq掛入aio線程對應的請求隊列) */
struct aio_ring_info  ring_info;      /* 存放請求結果io_event結構的ring buffer */

其中,這個aio_ring_info結構比較值得一提,它是用於存放請求結果io_event結構的ring buffer。它主要包含了如下字段:
unsigned long   mmap_base;       /* ring buffer的地始地址 */
unsigned long   mmap_size;       /* ring buffer分配空間的大小 */
struct page**   ring_pages;      /* ring buffer對應的page數組 */
long            nr_pages;        /* 分配空間對應的頁面數目(nr_pages * PAGE_SIZE = mmap_size) */
unsigned        nr, tail;        /* 包含io_event的數目及存取游標 */

這個數據結構看起來有些奇怪,直接弄一個io_event數組不就完事了么?為什么要維護mmap_base、mmap_size、ring_pages、nr_pages這么復雜的一組信息,而又把io_event結構隱藏起來呢?
這里的奇妙之處就在於,io_event結構的buffer是在用戶態地址空間上分配的。注意,我們在內核里面看到了諸多數據結構都是在內核地址空間上分配的,因為這些結構都是內核專有的,沒必要給用戶程序看到,更不能讓用戶程序去修改。而這里的io_event卻是有意讓用戶程序看到,而且用戶就算修改了也不會對內核的正確性造成影響。於是這里使用了這樣一個有些取巧的辦法,由內核在用戶態地址空間上分配buffer。(如果換一個保守點的做法,內核態可以維護io_event的buffer,然后io_getevents的時候,將對應的io_event復制一份到用戶空間。)
按照這樣的思路,io_setup時,內核會通過mmap在對應的用戶空間分配一段內存,mmap_base、mmap_size就是這個內存映射對應的位置和大小。然后,光有映射還不行,還必須立馬分配物理內存,ring_pages、nr_pages就是分配好的物理頁面。(因為這些內存是要被內核直接訪問的,內核會將異步IO的結果寫入其中。如果物理頁面延遲分配,那么內核訪問這些內存的時候會發生缺頁異常。而處理內核態的缺頁異常又很麻煩,所以還不如直接分配物理內存的好。其二,內核在訪問這個buffer里的信息時,也並不是通過mmap_base這個虛擬地址去直接訪問的。既然是異步,那么結果寫回的時候可能是在另一個上下文上面,虛擬地址空間都不同。為了避免進行虛擬地址空間的切換,內核干脆直接通過kmap將ring_pages映射到高端內存上去訪問好了。)

然后,在mmap_base指向的用戶空間的地址上,會存放着一個struct aio_ring結構,用來管理這個ring buffer。其主要包含了如下字段:
unsigned         id;                /* 等於aio_ring_info中的user_id */
unsigned         nr;                /* 等於aio_ring_info中的nr */
unsigned         head,tail;         /* io_events數組的游標 */
unsigned         magic,compat_features,incompat_features;
unsigned         header_length;     /* aio_ring結構的大小 */
struct io_event  io_events[0];      /* io_event的buffer */
終於,我們期待的io_event數組出現了。

看到這里,如果前面的內容你已經理解清楚了,你一定會有個疑問:既然整個aio_ring結構及其中的io_event緩沖都是放在用戶空間的,內核還提供io_getevents系統調用干什么?用戶程序不是直接就可以取用io_event,並且修改游標了么(內核作為生產者,修改aio_ring->tail;用戶作為消費者,修改aio_ring->head)?我想,aio_ring之所以要放在用戶空間,其原本用意應該就是這樣的。
那么,用戶空間如何知道aio_ring結構的地址(aio_ring_info->mmap_base)呢?其實kioctx結構中的user_id,也就是io_setup返回給用戶的io_context_t,就等於aio_ring_info->mmap_base。
然后,aio_ring結構中還有諸如magic、compat_features、incompat_features這樣的字段,用戶空間可以讀這些magic,以確定數據結構沒有被異常篡改。如果一切可控,那么就自己動手、豐衣足食;否則就還是走io_getevents系統調用。而io_getevents系統調用通過aio_ring_info->ring_pages得到aio_ring結構,再將相應的io_event拷貝到用戶空間。
下面貼一段libaio中的io_getevents的代碼(前面提到過,linux版本的異步IO是由用戶態的libaio來封裝的):
int io_getevents_0_4(io_context_t ctx, long min_nr, long nr, struct io_event * events, struct timespec * timeout){
    struct aio_ring *ring;
    ring = (struct aio_ring*)ctx;
    if (ring==NULL || ring->magic != AIO_RING_MAGIC)
        goto do_syscall;
    if (timeout!=NULL && timeout->tv_sec == 0 && timeout->tv_nsec == 0) {
        if (ring->head == ring->tail)
            return 0;
    }
do_syscall:
    return __io_getevents_0_4(ctx, min_nr, nr, events, timeout);
}
其中確實用到了用戶空間上的aio_ring結構的信息,不過尺度還是不夠大。

以上就是異步IO的context的結構。那么,為什么linux版本的異步IO需要“上下文”這么個概念,而glibc版本則不需要呢?
在glibc版本中,異步處理線程是glibc在調用者進程中動態創建的線程,它和調用者必定是在同一個虛擬地址空間中的。這里已經隱含了“同一上下文”這么個關系。
而對於內核來說,要面對的是任意的進程,任意的虛擬地址空間。當處理一個異步請求時,內核需要在調用者對應的地址空間中存取數據,必須知道這個虛擬地址空間是什么。不過當然,如果設計上要想把“上下文”這個概念隱藏了也是肯定可以的(比如讓每個mm隱含一個異步IO上下文)。具體如何選擇,只是設計上的問題。

struct iocb在內核中又對應到struct kiocb結構,主要包含以下字段:
struct kioctx*       ki_ctx;           /* 請求對應的kioctx(上下文結構) */
struct list_head     ki_run_list;      /* 需要aio線程處理的請求,通過該字段鏈入ki_ctx->run_list */
struct list_head     ki_list;          /* 鏈入ki_ctx->active_reqs */
struct file*         ki_filp;          /* 對應的文件指針 */
void __user*         ki_obj.user;      /* 指向用戶態的iocb結構 */
__u64                ki_user_data;     /* 等於iocb->aio_data */
loff_t               ki_pos;           /* 等於iocb->aio_offset */
unsigned short       ki_opcode;        /* 等於iocb->aio_lio_opcode */
size_t               ki_nbytes;        /* 等於iocb->aio_nbytes */
char __user *        ki_buf;           /* 等於iocb->aio_buf */
size_t               ki_left;          /* 該請求剩余字節數(初值等於iocb->aio_nbytes) */
struct eventfd_ctx*  ki_eventfd;       /* 由iocb->aio_resfd對應的eventfd對象 */
ssize_t (*ki_retry)(struct kiocb *);   /*由ki_opcode選擇的請求提交函數*/

調用io_submit后,對應於用戶傳遞的每一個iocb結構,會在內核態生成一個與之對應的kiocb結構,並且在對應kioctx結構的ring_info中預留一個io_events的空間。之后,請求的處理結果就被寫到這個io_event中。
然后,對應的異步讀寫(或其他)請求就被提交到了虛擬文件系統,實際上就是調用了file->f_op->aio_read或file->f_op->aio_write(或其他)。也就是,在經歷磁盤高速緩存層、通用塊層之后,請求被提交到IO調度層,等待被處理。這個跟普通的文件讀寫請求是類似的。
在《linux文件讀寫淺析》中可以看到,對於非direct-io的讀請求來說,如果pagecache不命中,那么IO請求會被提交到底層。之后,do_generic_file_read會通過lock_page操作,等待數據最終讀完。這一點跟異步IO是背道而馳的,因為異步就意味着請求提交后不能等待,必須馬上返回。而對於非direct-io的寫請求,寫操作一般僅僅是將數據更新作用到page cache上,並不需要真正的寫磁盤。pagecache寫回磁盤本身是一個異步的過程。可見,對於非direct-io的文件讀寫,使用linux版本的異步IO接口完全沒有意義(就跟使用同步接口效果一樣)。
為什么會有這樣的設計呢?因為非direct-io的文件讀寫是只跟page cache打交道的。而pagecache是內存,跟內存打交道又不會存在阻塞,那么也就沒有什么異步的概念了。至於讀寫磁盤時發生的阻塞,那是pagecache跟磁盤打交道時發生的事情,跟應用程序又沒有直接關系。
然而,對於direct-io來說,異步則是有意義的。因為direct-io是應用程序的buffer跟磁盤的直接交互(不使用page cache)。

這里,在使用direct-io的情況下,file->f_op->aio_{read,write}提交完IO請求就直接返回了,然后io_submit系統調用返回。(見后面的執行流程。)
通過linux內核異步觸發的IO調度(如:被時鍾中斷觸發、被其他的IO請求觸發、等),已經提交的IO請求被調度,由對應的設備驅動程序提交給具體的設備。對於磁盤,一般來說,驅動程序會發起一次DMA。然后又經過若干時間,讀寫請求被磁盤處理完成,CPU將收到表示DMA完成的中斷信號,設備驅動程序注冊的處理函數將在中斷上下文中被調用。這個處理函數會調用end_request函數來結束這次請求。這個流程跟《linux文件讀寫淺析》中所說的非direct-io讀操作的情況是一樣的。
不同的是,對於同步非direct-io,end_request將通過清除page結構的PG_locked標記來喚醒被阻塞的讀操作流程,異步IO和同步IO效果一樣。而對於direct-io,除了喚醒被阻塞的讀操作流程(同步IO)或io_getevents流程(異步IO)之外,還需要將IO請求的處理結果填回對應的io_event中。
最后,等到調用者調用io_getevents的時候,就能獲取到請求對應的結果(io_event)。而如果調用io_getevents的時候結果還沒出來,流程也會被阻塞,並且會在direct-io的end_request過程中得到喚醒。

linux版本的異步IO也有aio線程(每CPU一個),但是跟glibc版本中的異步處理線程不同,這里的aio線程是用來處理請求重試的。某些情況下,file->f_op->aio_{read,write}可能會返回-EIOCBRETRY,表示需要重試(只有一些特殊的IO設備會這樣)。而調用者既然使用的是異步IO接口,肯定不希望里面會有等待/重試的邏輯。所以,如果遇到-EIOCBRETRY,內核就在當前CPU對應的aio線程添加一個任務,讓aio線程來完成請求的重新提交。而調用流程可以直接返回,不需要阻塞。
請求在aio線程中提交和在調用者進程中提交相比,有一個最大的不同,就是aio線程使用的地址空間可能跟調用者線程不一樣。需要利用kioctx->mm切換到正確的地址空間,然后才能發請求。(參見《淺嘗異步IO》中的討論。)
 
內核處理流程
最后,整理一下direct-io異步讀操作的處理流程:
io_submit。對於提交的iocbpp數組中的每一個iocb(異步請求),調用io_submit_one來提交它們;
io_submit_one。為請求分配一個kiocb結構,並且在對應的kioctx的ring_info中為它預留一個對應的io_event。然后調用aio_rw_vect_retry來提交這個讀請求;
aio_rw_vect_retry。調用file->f_op->aio_read。這個函數通常是由generic_file_aio_read或者其封裝來實現的;
generic_file_aio_read。對於非direct-io,會調用do_generic_file_read來處理請求(見《linux文件讀寫淺析》)。而對於direct-io,則是調用mapping->a_ops->direct_IO。這個函數通常就是blkdev_direct_IO;
blkdev_direct_IO。調用filemap_write_and_wait_range將相應位置可能存在的page cache廢棄掉或刷回磁盤(避免產生不一致),然后調用direct_io_worker來處理請求;
direct_io_worker。一次讀可能包含多個讀操作(對應於類readv系統調用),對於其中的每一個,調用do_direct_IO;
do_direct_IO。調用submit_page_section;
submit_page_section。調用dio_new_bio分配對應的bio結構,然后調用dio_bio_submit來提交bio;
dio_bio_submit。調用submit_bio提交請求。后面的流程就跟非direct-io是一樣的了,然后等到請求完成,驅動程序將調用bio->bi_end_io來結束這次請求。對於direct-io下的異步IO,bio->bi_end_io等於dio_bio_end_aio;
dio_bio_end_aio。調用wake_up_process喚醒被阻塞的進程(異步IO下,主要是io_getevents的調用者)。然后調用aio_complete;
aio_complete。將處理結果寫回到對應的io_event中;


比較

從上面的流程可以看出,linux版本的異步IO實際上只是利用了CPU和IO設備可以異步工作的特性(IO請求提交的過程主要還是在調用者線程上同步完成的,請求提交后由於CPU與IO設備可以並行工作,所以調用流程可以返回,調用者可以繼續做其他事情)。相比同步IO,並不會占用額外的CPU資源。
而glibc版本的異步IO則是利用了線程與線程之間可以異步工作的特性,使用了新的線程來完成IO請求,這種做法會額外占用CPU資源(對線程的創建、銷毀、調度都存在CPU開銷,並且調用者線程和異步處理線程之間還存在線程間通信的開銷)。不過,IO請求提交的過程都由異步處理線程來完成了(而linux版本是調用者來完成的請求提交),調用者線程可以更快地響應其他事情。如果CPU資源很富足,這種實現倒也還不錯。

還有一點,當調用者連續調用異步IO接口,提交多個異步IO請求時。在glibc版本的異步IO中,同一個fd的讀寫請求由同一個異步處理線程來完成。而異步處理線程又是同步地、一個一個地去處理這些請求。所以,對於底層的IO調度器來說,它一次只能看到一個請求。處理完這個請求,異步處理線程才會提交下一個。而內核實現的異步IO,則是直接將所有請求都提交給了IO調度器,IO調度器能看到所有的請求。請求多了,IO調度器使用的類電梯算法就能發揮更大的功效。請求少了,極端情況下(比如系統中的IO請求都集中在同一個fd上,並且不使用預讀),IO調度器總是只能看到一個請求,那么電梯算法將退化成先來先服務算法,可能會極大的增加碰頭移動的開銷。

最后,glibc版本的異步IO支持非direct-io,可以利用內核提供的page cache來提高效率。而linux版本只支持direct-io,cache的工作就只能靠用戶程序來實現了。

 

 

 

Linux aio是Linux下的異步讀寫模型。
對於文件的讀寫,即使以O_NONBLOCK方式來打開一個文件,也會處於"阻塞"狀態。因為文件時時刻刻處於可讀狀態。而從磁盤到內存所等待的時間是驚人的。為了充份發揮把數據從磁盤復制到內存的時間,引入了aio模型。linux下有aio封裝,但是aio采用的是線程或信號用以通知,為了能更多的控制io行為,可以使用更為低級libaio。

一、基本函數與結構

1. libaio函數

extern int io_setup(int maxevents, io_context_t *ctxp);
extern int io_destroy(io_context_t ctx);
extern int io_submit(io_context_t ctx, long nr, struct iocb *ios[]);
extern int io_cancel(io_context_t ctx, struct iocb *iocb, struct io_event *evt);
extern int io_getevents(io_context_t ctx_id, long min_nr, long nr, struct io_event *events, struct timespec *timeout);

2. 結構

struct io_iocb_poll {
	PADDED(int events, __pad1);
};	/* result code is the set of result flags or -'ve errno */

struct io_iocb_sockaddr {
	struct sockaddr *addr;
	int		len;
};	/* result code is the length of the sockaddr, or -'ve errno */

struct io_iocb_common {
	PADDEDptr(void	*buf, __pad1);
	PADDEDul(nbytes, __pad2);
	long long	offset;
	long long	__pad3;
	unsigned	flags;
	unsigned	resfd;
};	/* result code is the amount read or -'ve errno */

struct io_iocb_vector {
	const struct iovec	*vec;
	int			nr;
	long long		offset;
};	/* result code is the amount read or -'ve errno */

struct iocb {
	PADDEDptr(void *data, __pad1);	/* Return in the io completion event */
	PADDED(unsigned key, __pad2);	/* For use in identifying io requests */

	short		aio_lio_opcode;	
	short		aio_reqprio;
	int		aio_fildes;

	union {
		struct io_iocb_common		c;
		struct io_iocb_vector		v;
		struct io_iocb_poll		poll;
		struct io_iocb_sockaddr	saddr;
	} u;
};

struct io_event {
	PADDEDptr(void *data, __pad1);
	PADDEDptr(struct iocb *obj,  __pad2);
	PADDEDul(res,  __pad3);
	PADDEDul(res2, __pad4);
};

3. 內聯函數

static inline void io_set_callback(struct iocb *iocb, io_callback_t cb);
static inline void io_prep_pread(struct iocb *iocb, int fd, void *buf, size_t count, long long offset);
static inline void io_prep_pwrite(struct iocb *iocb, int fd, void *buf, size_t count, long long offset);
static inline void io_prep_preadv(struct iocb *iocb, int fd, const struct iovec *iov, int iovcnt, long long offset);
static inline void io_prep_pwritev(struct iocb *iocb, int fd, const struct iovec *iov, int iovcnt, long long offset);
/* Jeff Moyer says this was implemented in Red Hat AS2.1 and RHEL3.
 * AFAICT, it was never in mainline, and should not be used. --RR */
static inline void io_prep_poll(struct iocb *iocb, int fd, int events);
static inline int io_poll(io_context_t ctx, struct iocb *iocb, io_callback_t cb, int fd, int events);
static inline void io_prep_fsync(struct iocb *iocb, int fd);
static inline int io_fsync(io_context_t ctx, struct iocb *iocb, io_callback_t cb, int fd);
static inline void io_prep_fdsync(struct iocb *iocb, int fd);
static inline int io_fdsync(io_context_t ctx, struct iocb *iocb, io_callback_t cb, int fd);
static inline void io_set_eventfd(struct iocb *iocb, int eventfd);

二、使用方法

1、初使化io_context
2、open文件取得fd
3、根據fd,buffer offset等息建立iocb
4、submit iocb到context
5、io_getevents取得events狀態
6、回到3步

三、例子

  1. #include <unistd.h>  
  2. #include <stdio.h>  
  3. #include <stdlib.h>  
  4. #include <string.h>  
  5. #include <error.h>  
  6. #include <errno.h>  
  7.   
  8. #include <fcntl.h>  
  9. #include <libaio.h>  
  10.   
  11. int main(int argc, char *argv[])  
  12. {  
  13.     // 每次讀入32K字節  
  14.     const int buffer_size = 0x8000;  
  15.   
  16.     // 最大事件數 32  
  17.     const int nr_events   = 32;  
  18.     int rt;  
  19.   
  20.     io_context_t ctx = {0};  
  21.   
  22.     // 初使化 io_context_t  
  23.     rt = io_setup(nr_events, &ctx);  
  24.     if ( rt != 0 )  
  25.         error(1, rt, "io_setup");  
  26.   
  27.     // 依次讀取參數作為文件名加入提交到ctx  
  28.     int pagesize = sysconf(_SC_PAGESIZE);  
  29.     for (int i=1; i<argc; ++i) {  
  30.         iocb *cb = (iocb*)malloc(sizeof(iocb));  
  31.         void *buffer;  
  32.         // 要使用O_DIRECT, 必須要對齊  
  33.         posix_memalign(&buffer, pagesize, buffer_size);  
  34.         io_prep_pread(cb, open(argv[i], O_RDONLY | O_DIRECT), buffer, buffer_size, 0);  
  35.         rt = io_submit(ctx, 1, &cb);  
  36.         if (rt < 0)  
  37.             error(1, -rt, "io_submit %s", argv[i]);;  
  38.     }  
  39.   
  40.     io_event events[nr_events];  
  41.     iocb     *cbs[nr_events];  
  42.   
  43.     int remain = argc - 1;  
  44.     int n      = 0;  
  45.   
  46.     // 接收數據最小返回的請求數為1,最大為nr_events  
  47.     while (remain && (n = io_getevents(ctx, 1, nr_events, events, 0))) {  
  48.         int nr_cbs = 0;  
  49.         for (int i=0; i<n; ++i) {  
  50.             io_event &event = events[i];  
  51.             iocb     *cb    = event.obj;  
  52.             // event.res為unsigned  
  53.             //printf("%d receive %d bytes\n", cb->aio_fildes, event.res);  
  54.             if (event.res > buffer_size) {  
  55.                 printf("%s\n", strerror(-event.res));  
  56.             }  
  57.             if (event.res != buffer_size || event.res2 != 0) {  
  58.                 --remain;  
  59.                 // 釋放buffer, fd 與 cb  
  60.                 free(cb->u.c.buf);  
  61.                 close(cb->aio_fildes);  
  62.                 free(cb);  
  63.             } else {  
  64.                 // 更新cb的offset  
  65.                 cb->u.c.offset += event.res;  
  66.                 cbs[nr_cbs++] = cb;  
  67.             }  
  68.         }  
  69.   
  70.         if (nr_cbs) {  
  71.             // 繼續接收數據  
  72.             io_submit(ctx, nr_cbs, cbs);  
  73.         }  
  74.     }  
  75.     return 0;  
  76. }  
  1. #include <unistd.h>  
  2. #include <stdio.h>  
  3. #include <stdlib.h>  
  4. #include <string.h>  
  5. #include <error.h>  
  6. #include <errno.h>  
  7.   
  8. #include <fcntl.h>  
  9. #include <libaio.h>  
  10.   
  11. int main(int argc, char *argv[])  
  12. {  
  13.     // 每次讀入32K字節  
  14.     const int buffer_size = 0x8000;  
  15.   
  16.     // 最大事件數 32  
  17.     const int nr_events   = 32;  
  18.     int rt;  
  19.   
  20.     io_context_t ctx = {0};  
  21.   
  22.     // 初使化 io_context_t  
  23.     rt = io_setup(nr_events, &ctx);  
  24.     if ( rt != 0 )  
  25.         error(1, rt, "io_setup");  
  26.   
  27.     // 依次讀取參數作為文件名加入提交到ctx  
  28.     int pagesize = sysconf(_SC_PAGESIZE);  
  29.     for (int i=1; i<argc; ++i) {  
  30.         iocb *cb = (iocb*)malloc(sizeof(iocb));  
  31.         void *buffer;  
  32.         // 要使用O_DIRECT, 必須要對齊  
  33.         posix_memalign(&buffer, pagesize, buffer_size);  
  34.         io_prep_pread(cb, open(argv[i], O_RDONLY | O_DIRECT), buffer, buffer_size, 0);  
  35.         rt = io_submit(ctx, 1, &cb);  
  36.         if (rt < 0)  
  37.             error(1, -rt, "io_submit %s", argv[i]);;  
  38.     }  
  39.   
  40.     io_event events[nr_events];  
  41.     iocb     *cbs[nr_events];  
  42.   
  43.     int remain = argc - 1;  
  44.     int n      = 0;  
  45.   
  46.     // 接收數據最小返回的請求數為1,最大為nr_events  
  47.     while (remain && (n = io_getevents(ctx, 1, nr_events, events, 0))) {  
  48.         int nr_cbs = 0;  
  49.         for (int i=0; i<n; ++i) {  
  50.             io_event &event = events[i];  
  51.             iocb     *cb    = event.obj;  
  52.             // event.res為unsigned  
  53.             //printf("%d receive %d bytes\n", cb->aio_fildes, event.res);  
  54.             if (event.res > buffer_size) {  
  55.                 printf("%s\n", strerror(-event.res));  
  56.             }  
  57.             if (event.res != buffer_size || event.res2 != 0) {  
  58.                 --remain;  
  59.                 // 釋放buffer, fd 與 cb  
  60.                 free(cb->u.c.buf);  
  61.                 close(cb->aio_fildes);  
  62.                 free(cb);  
  63.             } else {  
  64.                 // 更新cb的offset  
  65.                 cb->u.c.offset += event.res;  
  66.                 cbs[nr_cbs++] = cb;  
  67.             }  
  68.         }  
  69.   
  70.         if (nr_cbs) {  
  71.             // 繼續接收數據  
  72.             io_submit(ctx, nr_cbs, cbs);  
  73.         }  
  74.     }  
  75.     return 0;  
  76. }  

運行

$ truncate foo.txt -s 100K
$ truncate foo2.txt -s 200K
$ g++ -O3 libaio_simple.cc -laio && ./a.out foo.txt foo2.txt
3 received 32768 bytes
4 received 32768 bytes
3 received 32768 bytes
4 received 32768 bytes
3 received 32768 bytes
4 received 32768 bytes
3 received 4096 bytes
3 done.
4 received 32768 bytes
4 received 32768 bytes
4 received 32768 bytes
4 received 8192 bytes
4 done.

四、其它

這里有個問題,因為O_DIRECT跳過系統緩存,直接從磁盤讀取,對於讀寫來講是個大問題。要自已實現緩存,需要一堆東西要啃,而且還不一定寫得好。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM