Linux文件系統分為多層,從上到下分別為用戶層、VFS層、文件系統層、緩存層、塊設備層、磁盤驅動層、磁盤物理層
- 用戶層:最上面用戶層就是我們日常使用的各種程序,需要的接口主要是文件的創建、刪除、打開、關閉、寫、讀等。
- VFS層:我們知道Linux分為用戶態和內核態,用戶態請求硬件資源需要調用System Call通過內核態去實現。用戶的這些文件相關操作都有對應的System Call函數接口,接口調用 VFS對應的函數。
- 緩存層:文件系統底下有緩存,Page Cache,加速性能。對磁盤LBA的讀寫數據緩存到這里。
- 文件系統層:不同的文件系統實現了VFS的這些函數,通過指針注冊到VFS里面。所以,用戶的操作通過VFS轉到各種文件系統。文件系統把文件讀寫命令轉化為對磁盤LBA的操作,起了一個翻譯和磁盤管理的作用。
- 通用塊層和IO調度層:塊設備接口Block Device是用來訪問磁盤LBA的層級,讀寫命令組合之后插入到命令隊列,磁盤的驅動從隊列讀命令執行。Linux設計了電梯算法等對很多LBA的讀寫進行優化排序,盡量把連續地址放在一起。
- 磁盤驅動層:磁盤的驅動程序把對LBA的讀寫命令轉化為各自的協議,比如變成ATA命令,SCSI命令,或者是自己硬件可以識別的自定義命令,發送給磁盤控制器。Host Based SSD甚至在塊設備層和磁盤驅動層實現了FTL,變成對Flash芯片的操作。
- 磁盤物理層:讀寫物理數據到磁盤介質。


從這幅圖可以看出,我們可以繞過page_cache,直接操作通用塊層
VFS
Linux為了支持不同的底層文件系統,提供了一個中間層VFS,它為上層應用屏蔽了底層分揀系統的實現。
VFS中主要有四大對象,分別是:
- 超級塊對象:代表一個具體的已安裝的文件系統(從磁盤讀取)
- 索引節點對象:代表一個具體的文件(從磁盤讀取)
- 目錄項對象:它代表具體的目錄項,路徑的組成部分
- 文件對象:代表進程打開的文件

超級塊對象
通常對應存放在磁盤特定扇區的系統超級塊或文件控制塊。對於非基於磁盤的文件系統,會現場創建超級塊。保存文件系統的類型、大小、狀態等等。
超級塊提供的操作:創建,刪除,修改inode;卸載,加載文件系統
索引節點對象
保存的其實是實際的數據的一些信息,這些信息稱為“元數據”(也就是對文件屬性的描述)。我們必須從讀取磁盤中的inode到內存中,這樣才算使用了磁盤文件inode。inode號是唯一的,文件和inode節點也是意義對應。當創建一個文件的時候,就給文件分配了一個inode。
索引節點提供操作:創建,刪除,移動文件
查找文件(通過目錄inode和待查文件dentry(提供文件名), 返回dentry(包含inode))
硬連接和軟連接
- 硬連接:共享inode節點,只是增加inode計數。缺點是不能跨文件系統
- 軟連接:有自己的inode,只是使用路徑名作為指針。可以跨文件系統
目錄項
目錄項是文件的邏輯屬性,並不對應磁盤上的描述,為了加快查找速度而設計。
目錄項狀態:
- 已使用:d_inode指向相應的inode節點,並且d_count>0。表明正在被vfs指向有用的數據,不能丟棄。
- 未使用:d_inode指向相應的inode節點,並且d_count=0。表明正在被vfs指向無用的數據,必要的時候可以回收。
- 負狀態:d_inode指向不存在的索引節點,d_inode=NULL。但是為了快速索引路徑仍然可以保留,有需要可以回收。
目錄項緩存(denry_cache)
- “被使用的”目錄項鏈表:通過索引節點中i_dentry連接,因為一個索引節點可能有多個名字(硬連接),就有多個目錄項
- “最近被使用”目錄項:含有未被使用和負狀態的目錄項對象,從頭插入,必要的時候從尾回收
- 散列表和散列函數快速將給定路徑解析為目錄項對象
目錄項和inode是綁定的,所以再緩存目錄項的同時,也把inode緩存了。
目錄項提供的操作:創建,刪除目錄項對象;通過路徑名查找dentry
文件對象
文件對象代表一個已打開的文件,包含了訪問模式,偏移量等信息。文件對象通過f_denty指向目錄項對象,目錄項對象指向inode節點,inode節點記錄文件是否是臟的。
文件對象提供的操作:提供read,write,刷盤等操作
其他對象
- file_system_type:描述文件系統類型
- vfsmount:描述一個安裝文件系統,主要是理清文件系統和其他安裝點之間的關系(限制權限之類?)
進程相關數據結構
- file_struct:文件描述符表,文件對象指針數組
- fs_struct:文件系統和進程相關信息(工作路徑,根目錄路徑,正在執行的文件)
- mmt_namespact:文件系統命名空間,大家都一樣
頁高速緩存
頁高速緩存是內核實現的磁盤緩存,它的作用是為了減少磁盤IO操作。緩存的存在有兩個原因:匹配速度的不一致;局部性原理
讀緩存和寫緩存簡介
下面先介紹物理頁,塊,和扇區。扇區是磁盤的單位,是磁盤操作的最小粒度;物理頁和塊是操作系統的概念,塊是操作系統操作磁盤的最小單元。
物理頁 >= 塊 >= 扇區
讀緩存
頁緩存是由一個個物理頁面組成的,它的大小可以動態調整。我們讀取數據的時候首先先檢查頁緩存中數據是否存在,如果存在直接讀取,如果不存在就從磁盤中讀取,然后將數據放到頁緩存中。
寫緩存
一般而言,寫緩存有三種實現方式:
- 不緩存:直接寫到磁盤,並且標記頁緩存中的數據過期
- 寫透緩存:寫緩存同時寫磁盤,保持了良好的一致性
- “回寫”:只寫到緩存中,標記頁面為臟,過段時間刷新。臟頁標記的是磁盤中的數據過期。
緩存回收
緩存空間有限,緩存回收策略至關重要。
- LRU:最近最少使用
- 雙鏈策略
Linux實現的是修改過策略LRU,稱為雙鏈策略。有兩個鏈表,分別為熱鏈表,冷鏈表。熱鏈表上的頁面不會被換出,冷鏈表上的頁面可以被換出。頁面首先加入冷鏈表中,如果再次被訪問就加入熱鏈表,當熱鏈表過長,需要將溢出的頁面重新加入冷鏈表。
Linux高速緩存
page_cache和buffer_cache

文件 Cache 分為兩層,一是 Page Cache,另一個 Buffer Cache,每一個 Page Cache 包含若干 Buffer Cache。內存管理系統負責維護每項 Page Cache 的分配和回收,同時在使用 memory map 方式訪問時負責建立映射;VFS 負責 Page Cache 與用戶空間的數據交換。而具體文件系統則一般只與 Buffer Cache 交互,讀緩存以Page Cache為單位(主要是為了加速,其實只需要讀需要的buffer cache就行),每次讀取若干個Page Cache,回寫磁盤以Buffer Cache為單位,每次回寫若干個Buffer Cache。

address_space對象
一個物理頁可能包含了多個不連續的物理磁盤塊,一個物理頁大小是4K,一個塊大小通常是512B。因為文件可能存在於多個塊中,所以也不要求頁面映射的塊連續。也正是因為磁盤塊不連續,所以通過塊號來做頁高速緩存的索引是不可行的。
Linux引入了一個新對象管理緩存項和頁IO操作,稱為address_space對象,是vm_area_struct的物理地址對等體。address_space這個概念來作為文件系統和頁緩存的中間適配器,屏蔽了底層設備的細節。一個文件inode對應一個地址空間address_space。而一個address_space對應一個頁緩存基數樹。這幾個組件的關系如下

這樣就可以通過inode --> address_space --> page_tree找打一個文件對應的頁緩存頁。
基數樹4(22)的結構如下,基數樹的key拼起來是一個地址,代表文件的偏移。

文件讀寫流程
讀文件
- 用戶發起read操作
- 操作系統查找頁緩存
- 若未命中,則產生缺頁異常,然后創建頁緩存,並從磁盤讀取相應頁填充頁緩存(只需要讀取相應的塊?)
- 若命中,則直接從頁緩存返回要讀取的內容
- 用戶read調用完成
寫文件
- 用戶發起write操作
- 操作系統查找頁緩存
- 若未命中,則產生缺頁異常,然后創建頁緩存,將用戶傳入的內容寫入頁緩存
- 分配page,具體原理是將page內的每個buffer與物理磁盤塊建立映射。我們只填充需要用到的buffer_head,將buffer_head設置為臟
- 若命中,則直接將用戶傳入的內容寫入頁緩存
- 若未命中,則產生缺頁異常,然后創建頁緩存,將用戶傳入的內容寫入頁緩存
- 用戶write調用完成
- 頁被修改后成為臟頁,操作系統有兩種機制將臟頁寫回磁盤
- 用戶手動調用fsync()
- 由pdflush進程定時將臟頁寫回磁盤
問題:為什么普通讀寫文件需要兩次,而mmap只需要一次。
| - | read/write | mmap |
|---|---|---|
| 第一次 | 讀到page_cache | 讀到page_cache |
| 第二次 | 讀到用戶空間 | mmap映射 |
直接IO
一開始,提到過直接IO。
當我們以O_DIRECT標志調用open函數打開文件時,后續針對該文件的read、write操作都將以直接I/O(direct I/O)的方式完成;對於裸設備,I/O方式也為直接I/O。
直接I/O跳過了文件系統這一層,但塊層仍發揮作用,其將內存頁與磁盤扇區對應上,這時不再是建立cache到DMA映射,而是進程的buffer映射到DMA。進行直接I/O時要求讀寫一個扇區(512bytes)的整數倍,否則對於非整數倍的部分,將以帶cache的方式進行讀寫。
使用直接I/O,寫磁盤少了用戶態到內核態的拷貝過程,這提升了寫磁盤的效率,也是直接I/O的作用所在。而對於讀操作,第一次直接I/O將比帶cache的方式快,但因帶cache方式后續再讀時將從cache中讀,因而后續的讀將比直接I/O快。有些數據庫使用直接I/O,同時實現了自己的cache方式。
通用塊層和IO調度器
在Linux Block IO層,有三種關鍵的數據結構。Linux page、buffer_head、bio。
DMA操作
對磁盤的讀寫是按段為單位的
簡單DMA操作
數據段在內存中必須是連續的
分散/聚集DMA
可以段在內存中分散,寫到連續的扇區
page
page在內核中被稱為緩存頁,在文件系統中扮演最核心的角色。Linux使用內存緩存文件數據,而所有的文件內容都被分割成page然后通過一定方式組織起來,便於查找。

buffer_head
buffer_head顧名思義,表示緩沖區頭部。這個緩沖區緩沖的是磁盤等塊設備數據,而buffer_head則是描述緩沖區的元數據。同時,它是VFS讀寫數據的基本單元,是VFS眼中的磁盤。
page與buffer_head數據結構之間關系如下圖所示:假設page大小為4KB,而文件系統塊大小為1KB。

- page通過private字段索引該page的第一個buffer_head,而所有的buffer_head通過b_this_page形成一個單循環鏈表;
- buffer_head中的b_data指向緩存文件的塊數據;
- buffer_head內還通過b_page指向其所屬的page(圖中未畫出)
bio
buffer_head是kernel中非常重要的數據結構,它曾經是kernel中I/O的基本單位(現在已經是bio結構)。但是這樣會存在一些問題。
- buffer_head只能代表一個塊,而bio可以代表非連續的一個或多個頁
- bio結構可以充分利用分散/聚集 IO方式
- bio結構可以代表直接IO,也能處理文件系統的請求
因此,我們向磁盤讀取頁面,需要先將buffer_head封裝成bio,再向通用塊層發送請求。

struct bio {
sector_t bi_sector; //512
struct bio *bi_next; /* request queue link */
struct block_device *bi_bdev;
unsigned long bi_flags; /* status,command,etc */
unsigned long bi_rw;
unsigned short bi_vcnt; /* how many bio_vec's */
unsigned short bi_idx;
unsigned int bi_phys_segments;
......
// bio完成時的回調函數
bio_end_io_t *bi_end_io;
void *bi_private;
bio_destructor_t *bi_destructor;
struct bio_vec bi_inline_vecs[0];
};
struct bio_vec {
struct page *bv_page;
unsigned int bv_len;
unsigned int bv_offset;
};
IO調度
塊IO層接收了bio請求,通過IO調度算法,將bio加入IO請求隊列。
這里的關鍵在於將bio合並至某個request內,所謂的合並指的是該bio所請求的io是否與當前已有request在物理磁盤塊上連續,如果是,無需分配新的request,直接將該請求添加至已有request,這樣一次便可傳輸更多數據,提升IO效率,這其實也是整個IO系統的核心所在。
函數elv_merge()負責合並操作,具體邏輯是:
- 可以后向合並:該bio可以合並至某個request的尾部;
- 可以前向合並:該bio可以合並至某個request的頭部;
- 無法合並:該bio無法與任何request進行合並。
Linux中的IO調度算法:Noop算法,Deadline算法,Anticipatory算法,CFQ算法
Noop算法
Noop調度算法是內核中最簡單的IO調度算法。Noop調度算法也叫作電梯調度算法,它將IO請求放入到一個FIFO隊列中,然后逐個執行這些IO請求,當然對於一些在磁盤上連續的IO請求,Noop算法會適當做一些合並(根本不排序)。這個調度算法特別適合那些不希望調度器重新組織IO請求順序的應用。
這種調度算法在以下場景中優勢比較明顯:
- 在IO調度器下方有更加智能的IO調度設備。
- 上層的應用程序比IO調度器更懂底層設備。
- 對於一些非旋轉磁頭式的存儲設備,使用Noop的效果更好。
Deadline算法
Deadline算法的核心在於保證每個IO請求在一定的時間內一定要被服務到,以此來避免某個請求飢餓。
Deadline算法中引入了四個隊列,這四個隊列可以分為兩類,每一類都由讀和寫兩類隊列組成,一類隊列用來對請求按起始扇區序號進行排序,通過紅黑樹來組織,稱為sort_list;另一類對請求按它們的生成時間進行排序,由鏈表來組織,稱為fifo_list。每當確定了一個傳輸方向(讀或寫),那么將會從相應的sort_list中將一批連續請求dispatch到requst_queue的請求隊列里,具體的數目由fifo_batch來確定。
對於讀請求的期限時長默認為為500ms,寫請求的期限時長默認為5s。
Anticipatory算法
本質上與Deadline一樣,但在最后一次讀操作后,要等待6ms,才能繼續進行對其它I/O請求進行調度。為了撞大運,期待讀請求連續,已經被移除。
CFQ算法
它試圖為競爭塊設備使用權的所有進程分配一個請求隊列和一個時間片,在調度器分配給進程的時間片內,進程可以將其讀寫請求發送給底層塊設備,當進程的時間片消耗完,進程的請求隊列將被掛起,等待調度。每個進程都會有一個IO優先級。
| 算法 | 場景 |
|---|---|
| deadline | 業務比較單一並且IO壓力比較重的業務,數據庫 |
| CFQ算法 | 通用,公平 |
| Noop算法 | 固盤等非旋轉磁頭式的存儲設備 |
