linux的同步IO操作函數: sync、fsync與fdatasync-聊聊 Linux IO


linux的同步IO操作函數: sync、fsync與fdatasync

VFS(Virtual File System)的存在使得Linux可以兼容不同的文件系統,例如ext3、ext4、xfs、ntfs等等,其不僅具有為所有的文件系統實現一個通用的 外接口的作用,還具有另一個與系統性能相關的重要作用——緩存。VFS中引入了高速磁盤緩存的機制,這屬於一種軟件機制,允許內核將原本存在磁盤上的某些 信息保存在RAM中,以便對這些數據的進一步訪問能快速進行,而不必慢速訪問磁盤本身。高速磁盤緩存可大致分為以下三種:

  • 目錄項高速緩存——主要存放的是描述文件系統路徑名的目錄項對象

  • 索引節點高速緩存——主要存放的是描述磁盤索引節點的索引節點對象

  • 頁高速緩存——主要存放的是完整的數據頁對象,每個頁所包含的數據一定屬於某個文件,同時,所有的文件讀寫操作都依賴於頁高速緩存。其是Linux內核所使用的主要磁盤高速緩存。

正是由於緩存的引入,所以VFS文件系統采用了文件數據延遲寫的技術,因此,如果在調用系統接口寫入數據時沒有使用同步寫模式,那么大多數據將會先保存在緩存中,待等到滿足某些條件時才將數據刷入磁盤里。

內核是如何將數據刷入磁盤的呢?

何時把臟頁寫入磁盤

內核不斷用包含塊設備數據的頁填充頁高速緩存。只要進程修改了數據,相應的頁就被標記為臟頁,即把它的PG_dirty標志位置。

    Unix系統允許把臟緩沖區寫入塊設備的操作延遲執行,因為這種策略可以顯著地提高系統的性能。對高速緩存中的頁的幾次寫操作可能只需對相應的磁盤塊進行 一次緩慢的物理更新就可以滿足。此外,寫操作沒有讀操作那么緊迫,因為進程通常是不會因為延遲寫而掛起,而大部分情況都因為延遲讀而掛起。

一個臟頁可能直到最后一刻(即直到系統關閉時)都一直逗留在主存中。然而,從延遲寫策略的局限性來看,它有兩個主要的缺點:

  一、如果發生了硬件錯誤或者電源掉電的情況,那么就無法再獲得RAM的內容,因此,從系統啟動以來對文件進行的很多修改就丟失了。

  二、頁高速緩存的大小(由此存放它所需的RAM的大小)就可要很大——至少要與所訪問塊設備的大小不同。

因此,在下列條件下把臟頁刷新(寫入)到磁盤:

  • 頁高速緩存變得太滿,但還需要更多的頁,或者臟頁的數量已經太多。

  • 自從頁變成臟頁以來已過去太長時間。

  • 進程請求對塊設備或者特定文件任何待定的變化都進行刷新。通過調用sync()、fsync()或者fdatasync()系統調用來實現。

緩沖區頁的 引入是問題更加復雜。與每個緩沖區頁相關的緩沖區首部使內核能夠了解每個獨立塊緩沖區的狀態。如果至少有一個緩沖區首部的PG_Dirty標志被置位,就 應該設置相應緩沖區頁的PG_dirty標志。當內核選擇要刷新的緩沖區時,它掃描相應的緩沖區首部,並只把臟塊的內容有效的寫到磁盤。一旦內核把緩沖區 的所有臟頁刷新到磁盤,就把頁的PG_dirty標志清0。

 

誰來把臟頁寫入磁盤

由pdflush內核線程負責。早期版本的Linux使用bdfllush內核線程系統地掃描頁高速緩存以搜索要刷新的臟頁,並且使用另一個內核線程kupdate來保證所有的頁不會“臟”太長時間。Linux 2.6用一組通用內核線程pdflush替代上述兩個線程。當系統沒有要刷新的臟頁時,pdflush線程會自動處於睡眠狀態,最后由pdflush_operation()函數來喚醒。

在下面幾種情況下,系統會喚醒pdflush回寫臟頁:

1 、定時方式:
     定時機制定時喚醒pdflush內核線程,周期為/proc/sys/vm/dirty_writeback_centisecs ,單位
是(1/100)秒,每次周期性喚醒的pdflush線程並不是回寫所有的臟頁,而是只回寫變臟時間超過
/proc/sys/vm/dirty_expire_centisecs(單位也是1/100秒)。
注意:變臟的時間是以文件的inode節點變臟的時間為基准的,也就是說如果某個inode節點是10秒前變臟的,
pdflush就認為這個inode對應的所有臟頁的變臟時間都是10秒前,即使可能部分頁面真正變臟的時間不到10秒,
細節可以查看內核函數wb_kupdate()。

2、 內存不足的時候:
    這時並不將所有的dirty頁寫到磁盤,而是每次寫大概1024個頁面,直到空閑頁面滿足需求為止。

3 、寫操作時發現臟頁超過一定比例:
    當臟頁占系統內存的比例超過/proc/sys/vm/dirty_background_ratio 的時候,write系統調用會喚醒
pdflush回寫dirty page,直到臟頁比例低於/proc/sys/vm/dirty_background_ratio,但write系統調
用不會被阻塞,立即返回。當臟頁占系統內存的比例超過/proc/sys/vm/dirty_ratio的時候, write系
統調用會被被阻塞,主動回寫dirty page,直到臟頁比例低於/proc/sys/vm/dirty_ratio,這一點在
2.4內核中是沒有的。

4 、用戶調用sync系統調用:
    這是系統會喚醒pdflush直到所有的臟頁都已經寫到磁盤為止。

 

linux系統在向存儲設備上寫數據的時候,其實,數據沒有被立即寫入到物理設備上,而一般處理過程是:

  1. 調用fwrite()將數據寫入文件緩沖區(用戶態進程的buffer)。

  2. 進程定期調用fflush()函數之后,把文件緩沖區中的文件數據寫到文件系統中,此時數據還沒有被真正寫入到物理介質中。

  3. fsync(fileno(fp))。該函數返回后,才能保證寫入到了物理介質上。即先調用fileno獲得文件描述符之后,再調用fsync函數返回后才將文件寫入到物理介質上。

     

     

 

fflush和fsync的一些總結

1.提供者fflush是libc.a中提供的方法,fsync是linux系統內核提供的系統調用。
2.原形fflush接受一個參數FILE *.fflush(FILE *);fsync接受的時一個Int型的文件描述符。fsync(int fd);
3.功能fflush:是把C庫中的緩沖調用write函數寫到磁盤[其實是寫到內核的緩沖區]。fsync:是把內核緩沖刷到磁盤上。
4.fsync 將文件相關的所有更改都發送到disk device。 這個調用是阻塞的,直到disk通知此函數傳輸完成。此函數也會將該文件的文件信息flush到disk。
5.fsync最終將緩沖的數據更新到文件里。

所以可以看出fflush和fsync的調用順序應該是:
c庫緩沖-----fflush---------〉內核頁高速緩存--------fsync-----〉磁盤

 

與文件讀寫相關的幾個重要概念

臟頁:linux內核中的概念,因為硬盤的讀寫速度遠趕不上內存的速度,系統就把讀寫比較頻繁的數據事先放到內存中,以提高讀寫速度,這就叫高速緩存,linux是以頁作為高速緩存的單位,當進程修改了高速緩存里的數據時,該頁就被內核標記為臟頁,內核將會在合適的時間把臟頁的數據寫到磁盤中去,以保持高速緩存中的數據和磁盤中的數據是一致的

內存映射:內存映射文件,是由一個文件到一塊內存的映射。Win32提供了允許應用程序把文件映射到一個進程的函數 (CreateFileMapping)。內存映射文件與虛擬內存有些類似,通過內存映射文件可以保留一個地址空間的區域,同時將物理存儲器提交給此區域,內存文件映射的物理存儲器來自一個已經存在於磁盤上的文件,而且在對該文件進行操作之前必須首先對文件進行映射。使用內存映射文件處理存儲於磁盤上的文件時,將不必再對文件執行I/O操作,使得內存映射文件在處理大數據量的文件時能起到相當重要的作用。

延遲寫(delayed write):傳統的UNIX實現在內核中設有緩沖區高速緩存或頁面高速緩存,大多數磁盤I/O都通過緩沖進行。當將數據寫入文件時,內核通常先將該數據復制到其中一個緩沖區中,如果該緩沖區尚未寫滿,則並不將其排入輸出隊列,而是等待其寫滿或者當內核需要重用該緩沖區以便存放其他磁盤塊數據時,再將該緩沖排入輸出隊列,然后待其到達隊首時,才進行實際的I/O操作。這種輸出方式被稱為延遲寫(delayed write)(Bach [1986]第3章詳細討論了緩沖區高速緩存)。
延遲寫減少了磁盤讀寫次數,但是卻降低了文件內容的更新速度,使得欲寫到文件中的數據在一段時間內並沒有寫到磁盤上。當系統發生故障時,這種延遲可能造成 文件更新內容的丟失。為了保證磁盤上實際文件系統與緩沖區高速緩存中內容的一致性,UNIX系統提供了sync、fsync和fdatasync三個函數。
sync函數只是將所有修改過的塊緩沖區排入寫隊列,然后就返回,它並不等待實際寫磁盤操作結束。
通常稱為update的系統守護進程會周期性地(一般每隔30秒)調用sync函數。這就保證了定期沖洗內核的塊緩沖區。命令sync(1)也調用sync函數。
fsync函數只對由文件描述符filedes指定的單一文件起作用,並且等待寫磁盤操作結束,然后返回。fsync可用於數據庫這樣的應用程序,這種應用程序需要確保將修改過的塊立即寫到磁盤上。
fdatasync函數類似於fsync,但它只影響文件的數據部分。而除數據外,fsync還會同步更新文件的屬性。

對於提供事務支持的數據庫,在事務提交時,都要確保事務日志(包含該事務所有的修改操作以及一個提交記錄)完全寫到硬盤上,才認定事務提交成功並返回給應用層。

一個簡單的問題:在*nix操作系統上,怎樣保證對文件的更新內容成功持久化到硬盤?

1.  write不夠,需要fsync

一般情況下,對硬盤(或者其他持久存儲 設備)文件的write操作,更新的只是內存中的頁緩存(page cache),而臟頁面不會立即更新到硬盤中,而是由操作系統統一調度,如由專門的flusher內核線程在滿足一定條件時(如一定時間間隔、內存中的臟 頁達到一定比例)內將臟頁面同步到硬盤上(放入設備的IO請求隊列)。

因為write調用不會等到硬盤IO完 成之后才返回,因此如果OS在write調用之后、硬盤同步之前崩潰,則數據可能丟失。雖然這樣的時間窗口很小,但是對於需要保證事務的持久化 (durability)和一致性(consistency)的數據庫程序來說,write()所提供的“松散的異步語義”是不夠的,通常需要OS提供的同步IO(synchronized-IO)原語來保證:

#include <unistd.h>2 int fsync(int fd);

fsync的功能是確保文件fd所有已修改的內容已經正確同步到硬盤上,該調用會阻塞等待直到設備報告IO完成。

 

 

PS:如果采用內存映射文件的方式進行文件IO(使用mmap,將文件的page cache直接映射到進程的地址空間,通過寫內存的方式修改文件),也有類似的系統調用來確保修改的內容完全同步到硬盤之上:

1 #incude <sys/mman.h>2 int msync(void *addr, size_t length, int flags)

msync需要指定同步的地址區間,如此細粒度的控制似乎比fsync更加高效(因為應用程序通常知道自己的臟頁位置),但實際上(Linux)kernel中有着十分高效的數據結構,能夠很快地找出文件的臟頁,使得fsync只會同步文件的修改內容。

 

2. fsync的性能問題,與fdatasync

除了同步文件的修改內容(臟頁),fsync還會同步文件的描述信息(metadata,包括size、訪問時間st_atime & st_mtime等等),因為文件的數據和metadata通常存在硬盤的不同地方,因此fsync至少需要兩次IO寫操作,fsync的man page這樣說:

"Unfortunately fsync() will always initialize two write operations : one for the newly written data and another one in order to update the modification time stored in the inode. If the modification time is not a part of the transaction concept fdatasync() can be used to avoid unnecessary inode disk write operations."

多余的一次IO操作,有多么昂貴呢?根據Wikipedia的數據,當前硬盤驅動的平均尋道時間(Average seek time)大約是3~15ms,7200RPM硬盤的平均旋轉延遲(Average rotational latency)大約為4ms,因此一次IO操作的耗時大約為10ms左右。這個數字意味着什么?下文還會提到。

 

Posix同樣定義了fdatasync,放寬了同步的語義以提高性能:

#include <unistd.h>2 int fdatasync(int fd);

fdatasync的功能與fsync類似,但是僅僅在必要的情況下才會同步metadata,因此可以減少一次IO寫操作。那么,什么是“必要的情況”呢?根據man page中的解釋:

"fdatasync does not flush modified metadata unless that metadata is needed in order to allow a subsequent data retrieval to be corretly handled."

舉例來說,文件的尺寸(st_size)如果變化,是需要立即同步的,否則OS一旦崩潰,即使文件的數據部分已同步,由於metadata沒有同步,依然讀不到修改的內容。而最后訪問時間(atime)/修改時間(mtime)是不需要每次都同步的,只要應用程序對這兩個時間戳沒有苛刻的要求,基本無傷大雅。

 

 

PS:open時的參數O_SYNC/O_DSYNC有着和fsync/fdatasync類似的語義:使每次write都會阻塞等待硬盤IO完成。(實際上,Linux對O_SYNC/O_DSYNC做了相同處理,沒有滿足Posix的要求,而是都實現了fdatasync的語義)相對於fsync/fdatasync,這樣的設置不夠靈活,應該很少使用。

 

 

3. 使用fdatasync優化日志同步

文章開頭時已提到,為了滿足事務要求,數據庫的日志文件是常常需要同步IO的。由於需要同步等待硬盤IO完成,所以事務的提交操作常常十分耗時,成為性能的瓶頸。

在Berkeley DB下,如果開啟了AUTO_COMMIT(所有獨立的寫操作自動具有事務語義)並使用默認的同步級別(日志完全同步到硬盤才返回),寫一條記錄的耗時大約為5~10ms級別,基本和一次IO操作(10ms)的耗時相同。

 我們已經知道,在同步上fsync是低效的。但是如果需要使用fdatasync減少對metadata的更新,則需要確保文件的尺寸在write前后沒有發生變化。日志文件天生是追加型(append-only)的,總是在不斷增大,似乎很難利用好fdatasync。

 

且看Berkeley DB是怎樣處理日志文件的:

1.每個log文件固定為10MB大小,從1開始編號,名稱格式為“log.%010d"

2.每次log文件創建時,先寫文件的最后1個page,將log文件擴展為10MB大小

3.向log文件中追加記錄時,由於文件的尺寸不發生變化,使用fdatasync可以大大優化寫log的效率

4.如果一個log文件寫滿了,則新建一個log文件,也只有一次同步metadata的開銷

 

參考:http://blog.csdn.net/cywosp/article/details/8767327

http://blog.chinaunix.net/uid-1911213-id-3412851.html

http://blog.csdn.net/lwj103862095/article/details/38268647

 

轉載於:https://my.oschina.net/u/1377774/blog/529847

 

[IO系統]11 回寫機制(writeback)

 

     在Linux-3.2新內核中,page cache和buffer cache的刷新機制發生了改變。放棄了原有的pdflush機制,改成了bdi_writeback機制。這種變化主要解決原有pdflush機制存在的一個問題:在多磁盤的系統中,pdflush管理了所有磁盤的page/buffer cache,從而導致一定程度的IO性能瓶頸。bdi_writeback機制為每個磁盤都創建一個線程,專門負責這個磁盤的pagecache或者buffer cache的數據刷新工作,從而實現了每個磁盤的數據刷新程序在線程級的分離,這種處理可以提高IO性能。

1.1    writeback機制模型

        writeback機制的基本原理可以描述如下:

 

        在Linux內核中有一個常駐內存的線程bdi_forker_thread,該線程負責為bdi_object創建writeback線程,同時檢測如果writeback線程長時間處於空閑狀態,bdi_forker_thread線程便會將其進行銷毀。bdi_forker_thread在系統中只有一個,其會被定時喚醒,檢查全局鏈表bdi_list隊列中是否存在dirty的數據需要刷新到磁盤。如果存在dirty數據並且對應bdi的writeback線程還沒有被創建,bdi_forker_thread會為該bdi創建一個writeback的線程進行寫回操作。

 

        writeback線程被創建之后會處理等待的work。writeback線程擁有一個定時器會周期性喚醒這個線程處理相應的work。當用戶(page cache/buffer cache)有需要處理的inode時,將inode掛載到writeback->b_dirty鏈表中,然后喚醒writeback線程去處理相應的dirty_page。inode鏈表就是writeback線程需要處理的數據;work鏈表就是控制處理過程中的一些策略,不同的策略可以定義成不同的任務。 

        通過上述模型,對於塊設備或者文件系統而言,實現dirty page的后台刷新主要做如下幾個方面的工作:

        1,將自己的bdi注冊到系統的bdi鏈表中,通過bdi_forker_thread實現對bdi對象的管理,從而可以實現writeback線程的動態創建、銷毀。每個塊設備和文件系統都有自己的bdi對象。Ext3文件系統在創建的時候會生成superblock對象,系統會將底層塊設備的backing_device關系到這個superblock對象上(在set_bdev_super函數中完成)。如果是塊設備的話,在add_disk的時候直接從request_queue中得到bdi對象,然后對其進行初始化。注冊bdi對象使用bdi_register_dev函數,對於ext3之類的文件系統不需要重新注冊bdi對象,因為其本身就采用了底層塊設備的bdi對象。 

        2,將需要刷新的inode節點掛載到bdi對象所屬的writeback->b_dirty上,如果有特殊的work需要writeback線程完成,那么提交一個work即可;如果是通常的周期性刷新,writeback線程會自動創建相應的work。 

        3,操作writeback的喚醒定時器延遲喚醒writeback線程,或者直接喚醒線程,從而使得inode中radix tree上的dirty page刷新到磁盤。 

1.2     bdi對象的注冊

        每個塊設備在創建的時候會注冊bdi對象(參見add_disk函數),這是Linux-3.2內核不同的地方。文件系統在mount的時候會創建superblock對象,並且通過底層塊設備的request queue獲取bdi對象(mount_bdev->sget->set_bdev_super)。所以,像ext3之類的文件系統都不需要重新注冊bdi對象。當然,如果文件系統重新創建了一個bdi對象,那么還需要調用bdi_register_dev函數注冊bdi對象。 

1.3     小結

         本文對linux-3.2中的writeback機制模型進行了闡述,后面還會對writeback機制中的關鍵函數進行分析說明。該機制是對老系統(Linux-2.6.23等)中pdflush機制的替代,其最重要的變化是每個塊設備都分配了writeback線程,使得回寫的IO流在各個磁盤之間獨立,從而從機制上提高了IO的吞吐量。

 

1.4     參考文獻

 

[博客] http://alanwu.blog.51cto.com/3652632/1109952

聊聊 Linux IO

寫在前面

在開始正式的討論前,我先拋出幾個問題:

  • 談到磁盤時,常說的HDD磁盤和SSD磁盤最大的區別是什么?這些差異會影響我們的系統設計嗎?
  • 單線程寫文件有點慢,那多開幾個線程一起寫是不是可以加速呢?
  • write(2)函數成功返回了,數據就已經成功寫入磁盤了嗎?此時設備斷電會有影響嗎?會丟失數據嗎?
  • write(2)調用是原子的嗎?多線程寫文件是否要對文件加鎖?有沒有例外,比如O_APPEND方式?
  • 坊間傳聞,mmap(2)的方式讀文件比傳統的方式要快,因為少一次拷貝。真是這樣嗎?為什么少一次拷貝?

如果你覺得這些問題都很簡單,都能很明確的回答上來。那么很遺憾這篇文章不是為你准備的,你可以關掉網頁去做其他更有意義的事情了。如果你覺得無法明確的回答這些問題,那么就耐心地讀完這篇文章,相信不會浪費你的時間。受限於個人時間和文章篇幅,部分議題如果我不能給出更好的解釋或者已有專業和嚴謹的資料,就只會給出相關的參考文獻的鏈接,請讀者自行參閱。

言歸正傳,我們的討論從存儲器的層次結構開始。

存儲器的金字塔結構

受限於存儲介質的存取速率和成本,現代計算機的存儲結構呈現為金字塔型[1]。越往塔頂,存取效率越高、但成本也越高,所以容量也就越小。得益於程序訪問的局部性原理[2],這種節省成本的做法也能取得不俗的運行效率。從存儲器的層次結構以及計算機對數據的處理方式來看,上層一般作為下層的Cache層來使用(廣義上的Cache)。比如寄存器緩存CPU Cache的數據,CPU Cache L1~L3層視具體實現彼此緩存或直接緩存內存的數據,而內存往往緩存來自本地磁盤的數據。

本文主要討論磁盤IO操作,故只聚焦於Local Disk的訪問特性和其與DRAM之間的數據交互。

無處不在的緩存

如圖,當程序調用各類文件操作函數后,用戶數據(User Data)到達磁盤(Disk)的流程如圖所示[3]。圖中描述了Linux下文件操作函數的層級關系和內存緩存層的存在位置。中間的黑色實線是用戶態和內核態的分界線。

從上往下分析這張圖,首先是C語言stdio庫定義的相關文件操作函數,這些都是用戶態實現的跨平台封裝函數。stdio中實現的文件操作函數有自己的stdio buffer,這是在用戶態實現的緩存。此處使用緩存的原因很簡單——系統調用總是昂貴的。如果用戶代碼以較小的size不斷的讀或寫文件的話,stdio庫將多次的讀或者寫操作通過buffer進行聚合是可以提高程序運行效率的。stdio庫同時也支持fflush(3)函數來主動的刷新buffer,主動的調用底層的系統調用立即更新buffer里的數據。特別地,setbuf(3)函數可以對stdio庫的用戶態buffer進行設置,甚至取消buffer的使用。

系統調用的read(2)/write(2)和真實的磁盤讀寫之間也存在一層buffer,這里用術語Kernel buffer cache來指代這一層緩存。在Linux下,文件的緩存習慣性的稱之為Page Cache,而更低一級的設備的緩存稱之為Buffer Cache. 這兩個概念很容易混淆,這里簡單的介紹下概念上的區別:Page Cache用於緩存文件的內容,和文件系統比較相關。文件的內容需要映射到實際的物理磁盤,這種映射關系由文件系統來完成;Buffer Cache用於緩存存儲設備塊(比如磁盤扇區)的數據,而不關心是否有文件系統的存在(文件系統的元數據緩存在Buffer Cache中)。

綜上,既然討論Linux下的IO操作,自然是跳過stdio庫的用戶態這一堆東西,直接討論系統調用層面的概念了。對stdio庫的IO層有興趣的同學可以自行去了解。從上文的描述中也介紹了文件的內核級緩存是保存在文件系統的Page Cache中的。所以后面的討論基本上是討論IO相關的系統調用和文件系統Page Cache的一些機制。

Linux內核中的IO棧

這一小節來看Linux內核的IO棧的結構。先上一張全貌圖[4]:

由圖可見,從系統調用的接口再往下,Linux下的IO棧致大致有三個層次:

  1. 文件系統層,以 write(2) 為例,內核拷貝了write(2)參數指定的用戶態數據到文件系統Cache中,並適時向下層同步
  2. 塊層,管理塊設備的IO隊列,對IO請求進行合並、排序(還記得操作系統課程學習過的IO調度算法嗎?)
  3. 設備層,通過DMA與內存直接交互,完成數據和具體設備之間的交互

結合這個圖,想想Linux系統編程里用到的Buffered IOmmap(2)Direct IO,這些機制怎么和Linux IO棧聯系起來呢?上面的圖有點復雜,我畫一幅簡圖,把這些機制所在的位置添加進去:

這下一目了然了吧?傳統的Buffered IO使用read(2)讀取文件的過程什么樣的?假設要去讀一個冷文件(Cache中不存在),open(2)打開文件內核后建立了一系列的數據結構,接下來調用read(2),到達文件系統這一層,發現Page Cache中不存在該位置的磁盤映射,然后創建相應的Page Cache並和相關的扇區關聯。然后請求繼續到達塊設備層,在IO隊列里排隊,接受一系列的調度后到達設備驅動層,此時一般使用DMA方式讀取相應的磁盤扇區到Cache中,然后read(2)拷貝數據到用戶提供的用戶態buffer中去(read(2)的參數指出的)。

整個過程有幾次拷貝?從磁盤到Page Cache算第一次的話,從Page Cache到用戶態buffer就是第二次了。而mmap(2)做了什么?mmap(2)直接把Page Cache映射到了用戶態的地址空間里了,所以mmap(2)的方式讀文件是沒有第二次拷貝過程的。那Direct IO做了什么?這個機制更狠,直接讓用戶態和塊IO層對接,直接放棄Page Cache,從磁盤直接和用戶態拷貝數據。好處是什么?寫操作直接映射進程的buffer到磁盤扇區,以DMA的方式傳輸數據,減少了原本需要到Page Cache層的一次拷貝,提升了寫的效率。對於讀而言,第一次肯定也是快於傳統的方式的,但是之后的讀就不如傳統方式了(當然也可以在用戶態自己做Cache,有些商用數據庫就是這么做的)。

除了傳統的Buffered IO可以比較自由的用偏移+長度的方式讀寫文件之外,mmap(2)Direct IO均有數據按頁對齊的要求,Direct IO還限制讀寫必須是底層存儲設備塊大小的整數倍(甚至Linux 2.4還要求是文件系統邏輯塊的整數倍)。所以接口越來越底層,換來表面上的效率提升的背后,需要在應用程序這一層做更多的事情。所以想用好這些高級特性,除了深刻理解其背后的機制之外,也要在系統設計上下一番功夫。

Page Cache 的同步

廣義上Cache的同步方式有兩種,即Write Through(寫穿)Write back(寫回). 從名字上就能看出這兩種方式都是從寫操作的不同處理方式引出的概念(純讀的話就不存在Cache一致性了,不是么)。對應到Linux的Page Cache上所謂Write Through就是指write(2)操作將數據拷貝到Page Cache后立即和下層進行同步的寫操作,完成下層的更新后才返回。而Write back正好相反,指的是寫完Page Cache就可以返回了。Page Cache到下層的更新操作是異步進行的。

Linux下Buffered IO默認使用的是Write back機制,即文件操作的寫只寫到Page Cache就返回,之后Page Cache到磁盤的更新操作是異步進行的。Page Cache中被修改的內存頁稱之為臟頁(Dirty Page),臟頁在特定的時候被一個叫做pdflush(Page Dirty Flush)的內核線程寫入磁盤,寫入的時機和條件如下:

  • 當空閑內存低於一個特定的閾值時,內核必須將臟頁寫回磁盤,以便釋放內存。
  • 當臟頁在內存中駐留時間超過一個特定的閾值時,內核必須將超時的臟頁寫回磁盤。
  • 用戶進程調用sync(2)fsync(2)fdatasync(2)系統調用時,內核會執行相應的寫回操作。

刷新策略由以下幾個參數決定(數值單位均為1/100秒):

1
2
3
4
5
6
7
8
9
# flush每隔5秒執行一次
root@082caa3dfb1d / $ sysctl vm.dirty_writeback_centisecs
vm.dirty_writeback_centisecs = 500
# 內存中駐留30秒以上的臟數據將由flush在下一次執行時寫入磁盤
root@082caa3dfb1d / $ sysctl vm.dirty_expire_centisecs
vm.dirty_expire_centisecs = 3000
# 若臟頁占總物理內存10%以上,則觸發flush把臟數據寫回磁盤
root@082caa3dfb1d / $ sysctl vm.dirty_background_ratio
vm.dirty_background_ratio = 10

默認是寫回方式,如果想指定某個文件是寫穿方式呢?即寫操作的可靠性壓倒效率的時候,能否做到呢?當然能,除了之前提到的fsync(2)之類的系統調用外,在open(2)打開文件時,傳入O_SYNC這個flag即可實現。這里給篇參考文章[5],不再贅述(更好的選擇是去讀TLPI相關章節)。

文件讀寫遭遇斷電時,數據還安全嗎?相信你有自己的答案了。使用O_SYNC或者fsync(2)刷新文件就能保證安全嗎?現代磁盤一般都內置了緩存,代碼層面上也只能講數據刷新到磁盤的緩存了。當數據已經進入到磁盤的高速緩存時斷電了會怎么樣?這個恐怕不能一概而論了。不過可以使用hdparm -W0命令關掉這個緩存,相應的,磁盤性能必然會降低。

文件操作與鎖

當多個進程/線程對同一個文件發生寫操作的時候會發生什么?如果寫的是文件的同一個位置呢?這個問題討論起來有點復雜了。首先write(2)調用不是原子操作,不要被TLPI的中文版5.2章節的第一句話誤導了(英文版也是有歧義的,作者在這里給出了勘誤信息)。當多個write(2)操作對一個文件的同一部分發起寫操作的時候,情況實際上和多個線程訪問共享的變量沒有什么區別。按照不同的邏輯執行流,會有很多種可能的結果。也許大多數情況下符合預期,但是本質上這樣的代碼是不可靠的。

特別的,文件操作中有兩個操作是內核保證原子的。分別是open(2)調用的O_CREATO_APPEND這兩個flag屬性。前者是文件不存在就創建,后者是每次寫文件時把文件游標移動到文件最后追加寫(NFS等文件系統不保證這個flag)。有意思的問題來了,以O_APPEND方式打開的文件write(2)操作是不是原子的?文件游標的移動和調用寫操作是原子的,那寫操作本身會不會發生改變呢?有的開源軟件比如apache寫日志就是這樣寫的,這是可靠安全的嗎?坦白講我也不清楚,有人說Then O_APPEND is atomic and write-in-full for all reasonably-sized> writes to regular files.但是我也沒有找到很權威的說法。這里給出一個郵件列表上的討論,可以參考下[6]。今天先放過去,后面有時間的話專門研究下這個問題。如果你能給出很明確的說法和證明,還望不吝賜教。

Linux下的文件鎖有兩種,分別是flock(2)的方式和fcntl(2)的方式,前者源於BSD,后者源於System V,各有限制和應用場景。老規矩,TLPI上講的很清楚的這里不贅述。我個人是沒有用過文件鎖的,系統設計的時候一般會避免多個執行流寫一個文件的情況,或者在代碼邏輯上以mutex加鎖,而不是直接加鎖文件本身。數據庫場景下這樣的操作可能會多一些(這個純屬臆測),這就不是我了解的范疇了。

磁盤的性能測試

在具體的機器上跑服務程序,如果涉及大量IO的話,首先要對機器本身的磁盤性能有明確的了解,包括不限於IOPS、IO Depth等等。這些數據不僅能指導系統設計,也能幫助資源規划以及定位系統瓶頸。比如我們知道機械磁盤的連續讀寫性能一般不會超過120M/s,而普通的SSD磁盤隨意就能超過機械盤幾倍(商用SSD的連續讀寫速率達到2G+/s不是什么新鮮事)。另外由於磁盤的工作原理不同,機械磁盤需要旋轉來尋找數據存放的磁道,所以其隨機存取的效率受到了“尋道時間”的嚴重影響,遠遠小於連續存取的效率;而SSD磁盤讀寫任意扇區可以認為是相同的時間,隨機存取的性能遠遠超過機械盤。所以呢,在機械磁盤作為底層存儲時,如果一個線程寫文件很慢的話,多個線程分別去寫這個文件的各個部分能否加速呢?不見得吧?如果這個文件很大,各個部分的尋道時間帶來極大的時間消耗的話,效率就很低了(先不考慮Page Cache)。SSD呢?可以明確,設計合理的話,SSD多線程讀寫文件的效率會高於單線程。當前的SSD盤很多都以高並發的讀取為賣點的,一個線程壓根就喂不飽一塊SSD盤。一般SSD的IO Depth都在32甚至更高,使用32或者64個線程才能跑滿一個SSD磁盤的帶寬(同步IO情況下)。

具體的SSD原理不在本文計划內,這里給出一篇詳細的參考文章[7]。有時候一些文章中所謂的SATA磁盤一般說的就是機械盤(雖然SATA本身只是一個總線接口)。接口會影響存儲設備的最大速率,基本上是SATA -> PCI-E -> NVMe的發展路徑,具體請自行Google了解。

具體的設備一般使用fio工具[8]來測試相關磁盤的讀寫性能。fio的介紹和使用教程有很多[9],不再贅述。這里不想貼性能數據的原因是存儲介質的發展實在太快了,一方面不想貼某些很快就過時的數據以免讓初學者留下不恰當的第一印象,另一方面也希望讀寫自己實踐下fio命令。

前文提到存儲介質的原理會影響程序設計,我想稍微的解釋下。這里說的“影響”不是說具體的讀寫能到某個速率,程序中就依賴這個數值,換個工作環境就性能大幅度降低(當然,為專門的機型做過優化的結果很可能有這個副作用)。而是說根據存儲介質的特性,程序的設計起碼要遵循某個設計套路。舉個簡單的例子,SATA機械盤的隨機存取很慢,那系統設計時,就要盡可能的避免隨機的IO出現,盡可能的轉換成連續的文件存取來加速運行。比如Google的LevelDB就是轉換隨機的Key-Value寫入為Binlog(連續文件寫入)+ 內存插入MemTable(內存隨機讀寫可以認為是O(1)的性能),之后批量dump到磁盤(連續文件寫入)。這種LSM-Tree的設計便是合理的利用了存儲介質的特性,做到了最大化的性能利用(磁盤換成SSD也依舊能有很好的運行效率)。

寫在最后

每天抽出不到半個小時,零零散散地寫了一周,這是說是入門都有些謬贊了,只算是對Linux下的IO機制稍微深入的介紹了一點。無論如何,希望學習完Linux系統編程的同學,能繼續的往下走一走,嘗試理解系統調用背后隱含的機制和原理。探索的結果無所謂,重要的是探索的過程以及相關的學習經驗和方法。前文提出的幾個問題我並沒有刻意去解答所有的,但是讀到現在,不知道你自己能回答上幾個了?

計算機文件讀寫原理

 
 

前言

在一個txt文件中,修改其中一個字,然后保存,這期間計算機內部到底發生了什么?操作系統如何將鍵盤輸入的字符通過主板上密密麻麻的總線和芯片送到磁盤上的盤片上的? 這其中涉及的所有環節,就算用一本書也不一定能完全說明。然而我想首先從宏觀上認知整個過程,再逐步的去細化。因此,本篇文章的目的是從一從一個非常抽象的層次、一個非常局限的角度來分析文件讀寫的原理。

為什么說局限

為什么說是局限的角度呢,因為文件IO整個生命流程涉及到的環節太多了,每個環節還有很多不同的實現模式,因此我嘗試着每個環節只考慮一種情況,從而能認知整個流程。
文件操作分為讀和寫,寫可以分為覆蓋寫和追加寫;寫操作可以使用操作系統緩存cache,也可以bypass操作系統cache;寫IO可以分為Write Through(透寫)和Write Back(回寫);另外文件數據可以存放在本地,也可以存放在外置存儲中;存放在本地也分有無raid卡;硬盤也分傳統硬盤或SSD,SAS或SATA接口,甚至nvme,等等。如果這些都考慮,很難說清楚文件讀寫的原理,我認為首先弄明白其中一種情況下的原理,再運用聯系思維考慮其他情況,更有效率。
下文我將以Linux系統為例,闡述IO覆蓋寫,使用Write Back模式,使用操作系統緩存cache,底層使用傳統SAS磁盤情況下的寫IO流程。

寫IO

為了便於理解,下圖我將文件寫IO涉及到的重要硬件設備和軟件邏輯結構表示在一張圖中,大致上左邊是硬件,右邊是軟件邏輯結構,以下以應用程序覆蓋寫文件A為例
這里寫圖片描述
應用程序需要修改文件A中的部分字段,首先應用程序將待寫數據存放在其user buffer結構中,user buffer 通過MMU 映射,數據實際存放在物理內存中。現在應用程序需要將待寫數據寫入硬盤。
①程序進程調用內核函數write(),將待寫文件標識(句柄)、待寫數據相對文件首部的字節偏移量(offset xx)、待寫數據長度(2KB)和待寫數據的位置一並傳給內核 ;


注:在程序打開文件時,內核在PageCache中創建一個虛擬的文件 A’,這個文件A’從文件系統inode結構(下文講)中映射出來,由若干個page組成,初始情況下文件A’存在與邏輯地址空間內,不占用物理內存。文件A’的邏輯長度參考文件實際長度占用page的整數倍,這里假設page 大小為4KB。


②如圖,內核根據文件字節偏移量和上文提到的虛擬文件A’計算出待寫數據占用的page1;(這里面待寫數據只有2KB,小於page大小,因此待寫數據落入page1中)
③計算出page號后,內核嘗試找到page1對應的物理地址,以進行下一步操作。此時發現page1對應的數據並沒有調入內存中,產生缺頁,此時需要內核將page1對應的數據完整的從磁盤調入內存;(注意:此處和內存換頁沒有關系,這里可以看到使用操作系統Pagecache的寫IO可能會產生IO讀懲罰)


注:文件系統的主要功能就是組織文件在磁盤上的分布,文件是連續的結構,但其在磁盤上卻是離散分布的。文件系統將磁盤格式化成若干個塊,每個塊由若干個連續物理扇區組成,這個真實存在的塊叫做物理塊。為了提高利用率,同一個文件映射出的物理塊可能在磁盤的任何位置,不一定是連續的。因此文件系統需要一個鏈表來記錄文件對應的物理塊位置,這個結構在linux中就是inode


④文件系統將page號映射到對應的塊,然后根據inode可查到文件塊對應的真實物理地址LBA,內核將請求封裝后轉給設備驅動層(此步內核需要將Page所包含的所有字節都調入內存——”Page對齊“)。
⑤設備驅動將請求翻譯成若干各個SCSI指令,驅動SAS控制器通過SAS總線向磁盤發送指令:
SCSI Read() LBA0x****** Len=N N=讀取字節大小/扇區大小


注:上述過程主要發生在CPU與內存之間,CPU從內存中讀出指令並執行,最后CPU將指令通過PCIe總線發送給了SAS控制器,SAS控制器將指令發送到SAS總線上


⑥磁盤收到SCSI指令后,找到LBA對應的實際盤面和柱面,讀出對應的扇區,發回SAS控制器;
⑦從磁盤讀出的數據(這里是4KB大小)從原路返回,最后寫入到page1對應的物理內存中;
⑧內核用代寫2KB數據替換掉Page1對應的2KB待替換數據;
⑨此時內核向程序進程反饋:寫入成功;
⑩內核在合適時機將內存中的臟頁刷入磁盤。


注:⑨⑩兩步表示 Write Back模式,內核在沒有將數據寫入磁盤時就返回寫入成功,以提高效率,相當於內核“欺騙”了應用程序。實際上不光內核會這樣做,底層的很多環節也會有這樣的情況,比如磁盤也會“欺騙”SAS控制器。如果此時發生系統掉電,所有易失性存儲中的數據全部丟失,並未寫入磁盤,而應用程序認為寫IO已經完成了,下次開機時就會產生數據不一致。程序可以設置Write Through 模式,此時內核會等底層層層上報寫入成功后,才會反饋寫入成功。


小結

為了能完整的表述整個IO過程,這其中我簡化掉了很多流程。比如這里面沒有提卷管理層,還有一個IO中很重要的概念——隊列(queue),只要有IO瓶頸,就會有隊列,操作系統中有多處隊列,比如Linux中io scheduler。磁盤上也有隊列,以實現NCQ等算法。

 
 

 

 

淺談Linux內核IO體系之磁盤IO

Linux I/O體系是Linux內核的重要組成部分,主要包含網絡IO、磁盤IO等。基本所有的技術棧都需要與IO打交道,分布式存儲系統更是如此。本文主要簡單分析一下磁盤IO,看看一個IO請求從發起到完成到底經歷了哪些流程。

目錄

  • 名詞解釋
  • IO體系
  • VFS層
  • PageCache層
  • 映射層
  • 通用塊層
  • IO調度層
  • 設備驅動層
  • 物理設備層
  • FAQ

名詞解釋

  • Buffered I/O:緩存IO又叫標准IO,是大多數文件系統的默認IO操作,經過PageCache。
  • Direct I/O:直接IO,By Pass PageCache。offset、length需對齊到block_size。
  • Sync I/O:同步IO,即發起IO請求后會阻塞直到完成。緩存IO和直接IO都屬於同步IO。
  • Async I/O:異步IO,即發起IO請求后不阻塞,內核完成后回調。通常用內核提供的Libaio。
  • Write Back:Buffered IO時,僅僅寫入PageCache便返回,不等數據落盤。
  • Write Through:Buffered IO時,不僅僅寫入PageCache,而且同步等待數據落盤。

IO體系

我們先看一張總的Linux內核存儲棧圖片:

 

 

Linux IO存儲棧主要有以下7層:

 

 

VFS層

我們通常使用open、read、write等函數來編寫Linux下的IO程序。接下來我們看看這些函數的IO棧是怎樣的。在此之前我們先簡單分析一下VFS層的4個對象,有助於我們深刻的理解IO棧。

VFS層的作用是屏蔽了底層不同的文件系統的差異性,為用戶程序提供一個統一的、抽象的、虛擬的文件系統,提供統一的對外API,使用戶程序調用時無需感知底層的文件系統,只有在真正執行讀寫操作的時候才調用之前注冊的文件系統的相應函數。

VFS支持的文件系統主要有三種類型:

  • 基於磁盤的文件系統:Ext系列、XFS等。
  • 網絡文件系統:NFS、CIFS等。
  • 特殊文件系統:/proc、裸設備等。

VFS主要有四個對象類型(不同的文件系統都要實現):

  • superblock:整個文件系統的元信息。對應的操作結構體:struct super_operations
  • inode:單個文件的元信息。對應的操作結構體:struct inode_operations
  • dentry:目錄項,一個文件目錄對應一個dentry。對應的操作結構體:struct dentry_operations
  • file:進程打開的一個文件。對應的操作結構體:struct file_operations

關於VFS相關結構體的定義都在include/linux/fs.h里面。

superblock

superblock結構體定義了整個文件系統的元信息,以及相應的操作。

static const struct super_operations xfs_super_operations = { ...... }; static struct file_system_type xfs_fs_type = { .name = "xfs", ...... }; 

inode

inode結構體定義了文件的元數據,比如大小、最后修改時間、權限等,除此之外還有一系列的函數指針,指向具體文件系統對文件操作的函數,包括常見的open、read、write等,由i_fop函數指針提供。

文件系統最核心的功能全部由inode的函數指針提供。主要是inode的i_opi_fop字段。

struct inode { ...... // inode 文件元數據的函數操作  const struct inode_operations *i_op; // 文件數據的函數操作,open、write、read等  const struct file_operations *i_fop; ...... } 

在設置inode的i_fop時候,會根據不同的inode類型設置不同的i_fop。我們以xfs為例:

如果inode類型為普通文件的話,那么設置XFS提供的xfs_file_operations

如果inode類型為塊設備文件的話,那么設置塊設備默認提供的def_blk_fops

void xfs_setup_iops(struct xfs_inode *ip) { struct inode *inode = &ip->i_vnode; switch (inode->i_mode & S_IFMT) { case S_IFREG: inode->i_op = &xfs_inode_operations; // 在IO棧章節會分析一下xfs_file_operations  inode->i_fop = &xfs_file_operations; inode->i_mapping->a_ops = &xfs_address_space_operations; break; ...... default: inode->i_op = &xfs_inode_operations; init_special_inode(inode, inode->i_mode, inode->i_rdev); break; } } void init_special_inode(struct inode *inode, umode_t mode, dev_t rdev) { inode->i_mode = mode; ...... if (S_ISBLK(mode)) { // 塊設備相應的系列函數  inode->i_fop = &def_blk_fops; inode->i_rdev = rdev; } ...... } 

dentry

dentry是目錄項,由於每一個文件必定存在於某個目錄內,我們通過路徑查找一個文件時,最終肯定找到某個目錄項。在Linux中,目錄和普通文件一樣,都是存放在磁盤的數據塊中,在查找目錄的時候就讀出該目錄所在的數據塊,然后去尋找其中的某個目錄項。

struct dentry { ...... const struct dentry_operations *d_op; ...... }; 

在我們使用Linux的過程中,根據目錄查找文件的例子無處不在,而目錄項的數據又都是存儲在磁盤上的,如果每一級路徑都要讀取磁盤,那么性能會十分低下。所以需要目錄項緩存,把dentry放在緩存中加速。

VFS把所有的dentry放在dentry_hashtable哈希表里面,使用LRU淘汰算法。

file

用戶程序能接觸的VFS對象只有file,由進程管理。我們常用的打開一個文件就是創建一個file對象,並返回一個文件描述符。出於隔離性的考慮,內核不會把file的地址返回,而是返回一個整形的fd。

struct file { // 操作文件的函數指針,和inode里面的i_fop一樣,在open的時候賦值為i_fop。  const struct file_operations *f_op; // 指向對應inode對象  struct inode *f_inode; // 每個文件都有自己的一個偏移量  loff_t f_pos; ...... } 

file對象是由內核進程直接管理的。每個進程都有當前打開的文件列表,放在files_struct結構體中。

struct files_struct { ...... struct file __rcu * fd_array[NR_OPEN_DEFAULT]; ...... }; 

fd_array數組存儲了所有打開的file對象,用戶程序拿到的文件描述符(fd)實際上是這個數組的索引。

IO棧

SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count) { ...... ret = vfs_read(f.file, buf, count, &pos); ...... return ret; } SYSCALL_DEFINE3(write, unsigned int, fd, char __user *, buf, size_t, count) { ...... ret = vfs_write(f.file, buf, count, &pos); ...... return ret; } 

由此可見,我們經常使用的read、write系統調用實際上是對vfs_read、vfs_write的一個封裝。

size_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos) { ...... if (file->f_op->read) ret = file->f_op->read(file, buf, count, pos); else ret = do_sync_read(file, buf, count, pos); ...... } ssize_t vfs_write(struct file *file, const char __user *buf, size_t count, loff_t *pos) { ...... if (file->f_op->write) ret = file->f_op->write(file, buf, count, pos); else ret = do_sync_write(file, buf, count, pos); ...... } 

我們發現,VFS會調用具體的文件系統的實現:file->f_op->readfile->f_op->write

對於通用的文件系統,Linux封裝了很多基本的函數,很多文件系統的核心功能都是以這些基本的函數為基礎,再封裝一層。接下來我們以XFS為例,簡單分析一下XFS的read、write都做了什么操作。

const struct file_operations xfs_file_operations = { ...... .llseek = xfs_file_llseek, .read = do_sync_read, .write = do_sync_write, // 異步IO,在之后的版本中名字為read_iter、write_iter。  .aio_read = xfs_file_aio_read, .aio_write = xfs_file_aio_write, .mmap = xfs_file_mmap, .open = xfs_file_open, .fsync = xfs_file_fsync, ...... }; 

這是XFS的f_op函數指針表,我們可以看到read、write函數直接使用了內核提供的do_sync_readdo_sync_write函數。

ssize_t do_sync_read(struct file *filp, char __user *buf, size_t len, loff_t *ppos) { ...... ret = filp->f_op->aio_read(&kiocb, &iov, 1, kiocb.ki_pos); ...... } ssize_t do_sync_write(struct file *filp, const char __user *buf, size_t len, loff_t *ppos) { ...... ret = filp->f_op->aio_write(&kiocb, &iov, 1, kiocb.ki_pos); ...... } 

這兩個函數最終也是調用了具體文件系統的aio_readaio_write函數,對應XFS的函數為xfs_file_aio_readxfs_file_aio_write

xfs_file_aio_readxfs_file_aio_write雖然有很多xfs自己的實現細節,但其核心功能都是建立在內核提供的通用函數上的:xfs_file_aio_read最終會調用generic_file_aio_read函數,xfs_file_aio_write最終會調用generic_perform_write函數,這些通用函數是基本上所有文件系統的核心邏輯。

接下來便要進入PageCache層的相關邏輯了,我們先簡單概括一下讀寫多了哪些事情。

generic_file_aio_read

  1. 根據文件偏移量計算出要讀取數據在PageCache中的位置。
  2. 如果命中PageCache則直接返回,否則觸發磁盤讀取任務,會有預讀的操作,減少IO次數。
  3. 數據讀取到PageCache后,拷貝到用戶態Buffer中。

generic_perform_write

  1. 根據文件偏移量計算要寫入的數據再PageCache中的位置。
  2. 將用戶態的Buffer拷貝到PageCache中。
  3. 檢查PageCache是否占用太多,如果是則將部分PageCache的數據刷回磁盤。

使用Buffered IO時,VFS層的讀寫很大程度上是依賴於PageCache的,只有當Cache-Miss,Cache過滿等才會涉及到磁盤的操作。

塊設備文件

我們在使用Direct IO時,通常搭配Libaio使用,避免同步IO阻塞程序。而往往Direct IO + Libaio應用於裸設備的場景,盡量不要應用於文件系統中的文件,這時仍然會有文件系統的種種開銷。

通常Direct IO + Libaio使用的場景有幾種:

  • write back journal,journal也是裸設備。
  • 不怎么依賴文件系統的絕大部分功能,僅僅是讀寫即可,可直接操作裸設備。

上面基本都是普通文件的讀寫,我們通常的使用場景中還有一種特殊的文件即塊設備文件(/dev/sdx),這些塊設備文件仍然由VFS層管理,相當於一個特殊的文件系統。當進程訪問塊設備文件時,直接調用設備驅動程序提供的相應函數,默認的塊設備函數列表如下:

const struct file_operations def_blk_fops = { ...... .open = blkdev_open, .llseek = block_llseek, .read = do_sync_read, .write = do_sync_write, .aio_read = blkdev_aio_read, .aio_write = blkdev_aio_write, .mmap = generic_file_mmap, .fsync = blkdev_fsync, ...... }; 

使用Direct IO + Libaio + 裸設備時,VFS層的函數指針會指向裸設備的def_blk_fops。因為我們通常使用DIO+Libaio+裸設備,所以我們簡單分析一下Libaio的IO流程。

Libaio提供了5個基本的方法,只能以DIO的方式打開,否則可能會進行Buffered IO。

io_setup, io_cancal, io_destroy, io_getevents, io_submit 

Linux內核AIO的實現在,我們簡單分析一下io_submit的操作。

SYSCALL_DEFINE3(io_submit, aio_context_t, ctx_id, long, nr, struct iocb __user * __user *, iocbpp) { return do_io_submit(ctx_id, nr, iocbpp, 0); } long do_io_submit(aio_context_t ctx_id, long nr,struct iocb __user *__user *iocbpp, bool compat){ ... for (i=0; i<nr; i++) { ret = io_submit_one(ctx, user_iocb, &tmp, compat); } ... } static int io_submit_one(struct kioctx *ctx, struct iocb __user *user_iocb,struct iocb *iocb, bool compat){ ... ret = aio_run_iocb(req, compat); ... } static ssize_t aio_run_iocb(struct kiocb *req, bool compat){ ... case IOCB_CMD_PREADV: rw_op = file->f_op->aio_read; case IOCB_CMD_PWRITEV: rw_op = file->f_op->aio_write; ... } 

可以發現,最終也是調用f_opaio_read函數,對應於文件系統的文件就是xfs_file_aio_read函數,對應於塊設備文件就是blkdev_aio_read函數,然后進入通用塊層,放入IO隊列,進行IO調度。由此可見Libaio的隊列也就是通用塊層之下的IO調度層中的隊列。

PageCache層

在HDD時代,由於內核和磁盤速度的巨大差異,Linux內核引入了頁高速緩存(PageCache),把磁盤抽象成一個個固定大小的連續Page,通常為4K。對於VFS來說,只需要與PageCache交互,無需關注磁盤的空間分配以及是如何讀寫的。

當我們使用Buffered IO的時候便會用到PageCache層,與Direct IO相比,用戶程序無需offset、length對齊。是因為通用塊層處理IO都必須是塊大小對齊的。

Buffered IO中PageCache幫我們做了對齊的工作:如果我們修改文件的offset、length不是頁大小對齊的,那么PageCache會執行RMW的操作,先把該頁對應的磁盤的數據全部讀上來,再和內存中的數據做Modify,最后再把修改后的數據寫回磁盤。雖然是寫操作,但是非對齊的寫仍然會有讀操作。

Direct IO由於跳過了PageCache,直達通用塊層,所以需要用戶程序處理對齊的問題。

臟頁刷盤

如果發生機器宕機,位於PageCache中的數據就會丟失;所以僅僅寫入PageCache是不可靠的,需要有一定的策略將數據刷入磁盤。通常有幾種策略:

  • 手動調用fsync、fdatasync刷盤,可參考淺談分布式存儲之sync詳解
  • 臟頁占用比例超過了閾值,觸發刷盤。
  • 臟頁駐留時間過長,觸發刷盤。

Linux內核目前的做法是為每個磁盤都建立一個線程,負責每個磁盤的刷盤。

預讀策略

從VFS層我們知道寫是異步的,寫完PageCache便直接返回了;但是讀是同步的,如果PageCache沒有命中,需要從磁盤讀取,很影響性能。如果是順序讀的話PageCache便可以進行預讀策略,異步讀取該Page之后的Page,等到用戶程序再次發起讀請求,數據已經在PageCache里,大幅度減少IO的次數,不用阻塞讀系統調用,提升讀的性能。

映射層

映射層是在PageCache之下的一層,由多個文件系統(Ext系列、XFS等,打開文件系統的文件)以及塊設備文件(直接打開裸設備文件)組成,主要完成兩個工作:

  • 內核確定該文件所在文件系統或者塊設備的塊大小,並根據文件大小計算所請求數據的長度以及所在的邏輯塊號。
  • 根據邏輯塊號確定所請求數據的物理塊號,也即在在磁盤上的真正位置。

由於通用塊層以及之后的的IO都必須是塊大小對齊的,我們通過DIO打開文件時,略過了PageCache,所以必須要自己將IO數據的offset、length對齊到塊大小。

我們使用的DIO+Libaio直接打開裸設備時,跳過了文件系統,少了文件系統的種種開銷,然后進入通用塊層,繼續之后的處理。

通用塊層

通用塊層存在的意義也和VFS一樣,屏蔽底層不同設備驅動的差異性,提供統一的、抽象的通用塊層API。

通用塊層最核心的數據結構便是bio,描述了從上層提交的一次IO請求。

struct bio { ...... // 要提交到磁盤的多段數據  struct bio_vec *bi_io_vec; // 有多少段數據  unsigned short bi_vcnt; ...... } struct bio_vec { struct page *bv_page; unsigned int bv_len; unsigned int bv_offset; }; 

所有到通用塊層的IO,都要把數據封裝成bio_vec的形式,放到bio結構體內。

在VFS層的讀請求,是以Page為單位讀取的,如果改Page不在PageCache內,那么便要調用文件系統定義的read_page函數從磁盤上讀取數據。

const struct address_space_operations xfs_address_space_operations = { ...... .readpage = xfs_vm_readpage, .readpages = xfs_vm_readpages, .writepage = xfs_vm_writepage, .writepages = xfs_vm_writepages, ...... }; 

IO調度層

Linux調度層是Linux IO體系中的一個重要組件,介於通用塊層和塊設備驅動層之間。IO調度層主要是為了減少磁盤IO的次數,增大磁盤整體的吞吐量,會隊列中的多個bio進行排序和合並,並且提供了多種IO調度算法,適應不同的場景。

Linux內核為每一個塊設備維護了一個IO隊列,item是struct request結構體,用來排隊上層提交的IO請求。一個request包含了多個bio,一個IO隊列queue了多個request。

struct request { ...... // total data len  unsigned int __data_len; // sector cursor  sector_t __sector; // first bio  struct bio *bio; // last bio  struct bio *biotail; ...... } 

上層提交的bio有可能分配一個新的request結構體去存放,也有可能合並到現有的request中。

Linux內核目前提供了以下幾種調度策略:

  • Deadline:默認的調度策略,加入了超時的隊列。適用於HDD。
  • CFQ:完全公平調度器。
  • Noop:No Operation,最簡單的FIFIO隊列,不排序會合並。適用於SSD、NVME。

塊設備驅動層

每一類設備都有其驅動程序,負責設備的讀寫。IO調度層的請求也會交給相應的設備驅動程序去進行讀寫。大部分的磁盤驅動程序都采用DMA的方式去進行數據傳輸,DMA控制器自行在內存和IO設備間進行數據傳送,當數據傳送完成再通過中斷通知CPU。

通常塊設備的驅動程序都已經集成在了kernel里面,也即就算我們直接調用塊設備驅動驅動層的代碼還是要經過內核。

spdk實現了用戶態、異步、無鎖、輪詢方式NVME驅動程序。塊存儲是延遲非常敏感的服務,使用NVME做后端存儲磁盤時,便可以使用spdk提供的NVME驅動,縮短IO流程,降低IO延遲,提升IO性能。

物理設備層

物理設備層便是我們經常使用的HDD、SSD、NVME等磁盤設備了。

FAQ

1、write返回成功數據落盤了嗎?

Buffered IO:write返回數據僅僅是寫入了PageCache,還沒有落盤。

Direct IO:write返回數據僅僅是到了通用塊層放入IO隊列,依舊沒有落盤。

此時設備斷電、宕機仍然會發生數據丟失。需要調用fsync或者fdatasync把數據刷到磁盤上,調用命令時,磁盤本身緩存(DiskCache)的內容也會持久化到磁盤上。

2、write系統調用是原子的嗎?

write系統調用不是原子的,如果有多線程同時調用,數據可能會發生錯亂。可以使用O_APPEND標志打開文件,只能追加寫,這樣多線程寫入就不會發生數據錯亂。

3、mmap相比read、write快在了哪里?

mmap直接把PageCache映射到用戶態,少了一次系統調用,也少了一次數據在用戶態和內核態的拷貝。

mmap通常和read搭配使用:寫入使用write+sync,讀取使用mmap。

4、為什么Direct IO需要數據對齊?

DIO跳過了PageCache,直接到通用塊層,而通用塊層的IO都必須是塊大小對齊的,所以需要用戶程序自行對齊offset、length。

5、Libaio的IO棧?

write()--->sys_write()--->vfs_write()--->通用塊層--->IO調度層--->塊設備驅動層--->塊設備 

6、為什么需要 by pass pagecache?

當應用程序不滿Linux內核的Cache策略,有更適合自己的Cache策略時可以使用Direct IO跳過PageCache。例如Mysql。

7、為什么需要 by pass kernel?

當應用程序對延遲極度敏感時,由於Linux內核IO棧有7層,IO路徑比較長,為了縮短IO路徑,降低IO延遲,可以by pass kernel,直接使用用戶態的塊設備驅動程序。例如spdk的nvme,阿里雲的ESSD。

8、為什么需要直接操作裸設備?

當應用程序僅僅使用了基本的read、write,用不到文件系統的大而全的功能,此時文件系統的開銷對於應用程序來說是一種累贅,此時需要跳過文件系統,接管裸設備,自己實現磁盤分配、緩存等功能,通常使用DIO+Libaio+裸設備。例如Ceph FileStore的Journal、Ceph BlueStore。

 


免責聲明!

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



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