1. 程式人生 > >新部落格請訪問- http://oliveryang.net

新部落格請訪問- http://oliveryang.net

1. 背景

Linux Block Driver - 2 中,我們在 Sampleblk 驅動建立了 Ext4 檔案系統,並做了一個簡單的 fio 測試。

本文將繼續之前的實驗,圍繞這個簡單的 fio 測試,探究 Linux 塊裝置驅動和檔案 IO 的運作機制。除非特別指明,本文中所有 Linux 核心原始碼引用都基於 4.6.0。其它核心版本可能會有較大差異。若需對 Sampleblk 塊驅動實現有所瞭解,請參考 Linux Block Driver - 1

2. 準備

閱讀本文前,可能需要如下準備工作,

在上篇文章中,通過使用 Flamegraph,我們把 fio 測試中做的 perf profiling 的結果視覺化,生成了如下火焰圖,

在新視窗中開啟圖片,我們可以看到,火焰圖不但可以幫助我們理解 CPU 全域性資源的佔用情況,而且還能進一步分析到微觀和細節。例如區域性的熱鎖,父子函式的呼叫關係,和所佔 CPU 時間比例。關於進一步的 Flamegraph 的介紹和資料,請參考 Brenden Gregg 的 Flamegraph 相關資源

本文中,該火焰圖將成為我們粗略瞭解該 fio 測試在 Linux 4.6.0 核心涉及到的檔案 IO 內部實現的主要工具。

3. 深入理解檔案 IO

在上一篇文章中,我們發現,儘管測試的主要時間都發生在 write 系統呼叫上。但如果檢視單次系統呼叫的時間,fadvise64

遠遠超過了 write。為什麼 writefadvise64 呼叫的執行時間差異如此之大?如果對 Linux buffer IO 機制,檔案系統 page cache 的工作原理有基本概念的話,這個問題並不難理解,

  • Page cache 加速了檔案讀寫操作

    一般而言,write 系統呼叫雖然是同步 IO,但 IO 資料是寫入 page cache 就立即返回的,因此實際開銷是寫記憶體操作,而且只寫入 page cache 裡,不會對塊裝置發起 IO 操作。應用如果需要保證資料寫到磁碟上,必需在 write 呼叫之後呼叫 fsync 來保證檔案資料在 fsync 返回前把資料從 page cache 甚至硬碟的 cache 寫入到磁碟介質。

  • Flush page cache 會帶來額外的開銷

    雖然 page cache 加速了檔案系統的讀寫操作,但一旦需要 flush page cache,將集中產生大量的磁碟 IO 操作。磁碟 IO 操作比寫 page cache 要慢很多。因此,flush page cache 非常費時而且影響效能。

由於 Linux 核心提供了強大的動態追蹤 (Dynamic Trace) 能力,現在我們可以通過核心的 trace 工具來了解 writefadvise64 呼叫的執行時間差異。

3.1 使用 Ftrace

Linux strace 只能追蹤系統呼叫介面這層的資訊。要追蹤系統呼叫內部的機制,就需要藉助 Linux 核心的 trace 工具了。Ftrace 就是非常簡單易用的追蹤系統呼叫內部實現的工具。

不過,Ftrace 的 UI 是基於 linux debugfs 的。操作起來有些繁瑣。因此,我們用 Brendan Gregg 寫的 funcgraph 來簡化我們對 Ftrace 的使用。這個工具是基於 Ftrace 的用 bash 和 awk 寫的指令碼,非常容易理解和使用。關於 Brendan Gregg 的 perf-tools 的使用,請閱讀 Ftrace: The hidden light switch 這篇文章。此外,Linux 原始碼樹裡的 Documentation/trace/ftrace.txt 就是極好的 Ftrace 入門材料。

3.2 open

執行 Linux Block Driver - 2 中的 fio 測試時,用 funcgraph 可以獲取 open 系統呼叫的核心函式的函式圖 (function graph),

$ sudo ./funcgraph -d 1 -p 95069 SyS_open

詳細的 open 系統呼叫函式圖日誌可以檢視 這裡

仔細察看函式圖日誌就會發現,open 系統呼叫開啟普通檔案時,並沒有呼叫塊裝置驅動的程式碼,而只涉及到下面兩個層次的處理,

  1. VFS 層。

    VFS 層的 open 系統呼叫程式碼為程序分配 fd,根據檔名查詢元資料,為檔案分配和初始化 struct file。在這一層的元資料查詢、讀取,以及檔案的開啟都會呼叫到底層具體檔案系統的回撥函式協助完成。最後在系統呼叫返回前,把 fd,和 struct file 裝配到程序的 struct task_struct 上。

    要強調的是,struct file 是檔案 IO 中最核心的資料結構之一,其內部包含了如下關鍵資訊,

    • 檔案路徑結構。即 f_path 成員,其型別為 struct path

      核心可以直接得到 vfsmountdentry,即可唯一確定一個檔案的位置。

      這個 vfsmountdentry 的初始化由 do_last 函式來搞定。如果檔案以 O_CREAT 方式開啟,則最終呼叫 lookup_open 來搞定。若無 O_CREAT 標誌,則由 lookup_fast 來搞定。最終,dentry 要麼已經有對應的 inode,這時 dentry 要麼在 dentry cache 裡,要麼需要呼叫 lookup 方法 (Ext4 對應的 ext4_lookup 方法) 從磁碟上讀取出來。

      還有一種情況,即檔案是 O_CREAT 方式開啟,檔案雖然不存在,也會由 lookup_open 呼叫 vfs_create 來為這個 dentry 建立對應的 inode。而檔案建立則需要藉助具體檔案系統的 create 方法,對 Ext4 來說,就是 ext4_create

    • 檔案操作表結構。即 f_op 成員,其型別為 struct file_operations

      檔案 IO 相關的方法都定義在該結構裡。檔案系統通過實現該方法來完成對檔案 IO 的具體支援。

      vfsmountdentry 都被正確得到後,就會通過 vfs_open 呼叫 do_dentry_open 來初始化檔案操作表。最終,實際上 struct filef_op 成員來自於其對應 dentry 的對應 inodei_fop 成員。而我們知道,inode 都是呼叫具體檔案系統的方法來分配的。例如,對 Ext4 來說,f_op 成員最終被 ext4_lookupext4_create 初始化成了 ext4_file_operations

    • 檔案地址空間結構。即 f_mapping 成員,其型別為 struct address_space

      地址空間結構內部包括了檔案記憶體對映的記憶體頁面,和其對應的地址空間操作表 a_ops (型別為 struct address_space_operations),都在其中。

      f_op 成員類似,f_mapping 成員也通過 vfs_open 呼叫 do_dentry_open 來初始化。而地址空間結構內部的地址空間操作表,也是通過具體檔案系統初始化的。例如,Ext4 上,該地址空間操作表被 ext4_lookupext4_create 初始化為 ext4_da_aops

      open 呼叫返回時,使用者程式通過 fd 就可以讓核心在檔案操作時訪問到對應的 struct file,從而可以定位到具體檔案,做相關的 IO 操作了。

      在 open 結束時,在 vfs_open 實現裡檢查是否實現了 open 方法,若實現就呼叫到具體檔案系統的 open 方法。例如,Ext4 檔案系統的 ext4_file_open 方法。

      很多檔案系統根本不會實現自己的 open 方法,這時就不悔呼叫 open 方法。或者,一些檔案系統將該方法初始化為 generic_file_open。現在的 ext4_file_open 函式最終會在返回前呼叫 VFS 層的 generic_file_open。 而 generic_file_open 只檢查是否在 32 位系統上開啟大檔案而導致 overflow 的情況,在 64 位系統上,generic_file_open 相當於空函式。

  2. 具體檔案系統層。

    以 Ext4 為例,Ext4 註冊在 VFS 層的入口函式被上層呼叫,為上層查詢元資料 (ext4_lookup),建立檔案 (ext4_create),開啟檔案 (ext4_file_open) 提供服務。

    • 當 dentry cache 裡查詢不到對應檔案的 dentry 結構時,需要從磁碟上查詢。這時呼叫 ext4_lookup 方法來查詢。

    • 當檔案不再磁碟上,因為 O_CREAT 標誌被設定,需要建立檔案時,呼叫 ext4_create 建立檔案。

    • 當檔案被定位後,最終呼叫 f_op 成員的 open 方法,即 ext4_file_open

    本例中的 fio 測試裡,由於檔案已經被建立,而且元資料已經快取在記憶體中,因此,只涉及到 ext4_file_open 的程式碼。早期 Ext4 程式碼並未實現自己獨特的 open 方法。後來 Ext4 為了管理員提供了記錄上次 mount 點的功能,引入了自己的 ext4_file_open 函式。而 ext4_file_open 逐漸也加入了其它邊緣功能。該函式最終會在返回前呼叫 VFS 層的 generic_file_open

3.3 fadvise64

funcgraph 也可以獲取 fadvise64 系統呼叫的核心函式的函式圖 (function graph),

$ sudo ./funcgraph -d 1 -p 95069 SyS_fadvise64

詳細的 fadvise64 系統呼叫的跟蹤日誌請檢視這裡

根據 fadvise64(2),這個系統呼叫的作用是預先宣告檔案資料的訪問模式。

從前面 strace 的日誌裡我們得知,每次 open 檔案之後,fio 都會呼叫兩次 fadvise64 系統呼叫,只不過兩次的 advise 引數使用有所差別,因而起到的作用也不同。下面就對本實驗中涉及到的兩個 advise 引數做簡單介紹。

3.3.1 POSIX_FADV_SEQUENTIAL

POSIX_FADV_SEQUENTIAL 主要是應用用來通知核心,它打算以順序的方式去訪問檔案資料。

如果參考我們獲得的 fadvise64 系統呼叫的函式圖,再結合原始碼,我們知道,POSIX_FADV_SEQUENTIAL 的實現非常簡單,主要有以下兩方面,

  1. 把 VFS 層檔案的預讀視窗增大到預設的兩倍。
  2. 把檔案對應的核心結構 struct file 的檔案模式 file->f_mode 的隨機訪問 FMODE_RANDOM 標誌位清除掉。從而讓 VFS 預讀演算法對順序讀更高效。

由於以上操作僅僅涉及簡單記憶體訪問操作,因此在 fadvise64 系統呼叫的函式圖裡,我們可以看到它僅僅用了 0.891 us 就返回了,遠遠快於另外一個命令。

綜上所述,POSIX_FADV_SEQUENTIAL 操作的程式碼路徑下,全都是對檔案系統預讀的優化。而本文中的 fio 測試只有順序寫操作,因此,POS IX_FADV_SEQUENTIAL 的操作對本測試沒有任何影響。

3.3.2 POSIX_FADV_DONTNEED

POSIX_FADV_DONTNEED 則是應用通知核心,與檔案描述符 fd 關聯的檔案的指定範圍 (offsetlen 描述)的 page cache 都不需要了,髒頁可以刷到盤上,然後直接丟棄了。

Linux 提供了全域性刷 page cache 到磁碟,然後丟棄 page cache 的介面: /proc/sys/vm/drop_pagecache。然而 fadvise64 的 POSIX_FADV_DONTNEED 作用域是檔案內的某段範圍,具有更細的粒度。

在我們 fadvise64 系統呼叫的跟蹤日誌裡,呼叫圖關係最複雜,返回時間最長的就是這個命令了。但如果參考其原始碼實現,其實該命令主要分為兩大步驟,

  1. 回寫 (Write Back) 頁快取。
  2. 清除 (Invalidate) 頁快取。
3.3.2.1 回寫頁快取

回寫 (Write Back) 檔案內部指定範圍的 dirty page cache 到磁碟。

首先,檢查是否符合回寫的觸發條件,然後呼叫 __filemap_fdatawrite_range 對檔案指定範圍回寫。如果檔案 inode 回寫擁塞位被置位的話,則跳過回寫操作。這時,fadvise64 系統呼叫還會在第 2 步時,儘量清除回收檔案所屬的 Page Cache。這個回寫擁塞控制是 Cgroup Write Back 特性的一部分。

如果可以回寫,呼叫 __filemap_fdatawrite_range。 該函式支援時以下兩種同步寫模式,

  • WB_SYNC_ALL 指示程式碼在回寫頁面時,遇到某個頁已經正在被別人回寫時,睡眠等待。這樣可以保證資料的完整性。因此 fsync 或者 msync 這類呼叫必須使用這個同步模式。
  • WB_SYNC_NONE 指示程式碼遇到某個頁被別人回寫時,跳過該頁而避免等待。這種方式通常只用於記憶體回收的時候。

不論設定為上述哪種方式,頁面回寫都是同步的。也就是說,當磁碟 IO 結束返回之前,回寫會等待。兩者的差別僅僅是當有頁面正在其它 IO 上下文時,是否要跳過。

POSIX_FADV_DONTNEED 的主要目的是清除 page cache,因此它使用了 WB_SYNC_NONE。

完整的髒頁回寫過程經歷了以下 5 個層次,

  1. VFS 層

    如前所述,fadvise64 系統呼叫使用的 __filemap_fdatawrite_range, 是 VFS 層的函式。VFS 層最終會使用 MM 子系統提供的頁快取回寫函式 do_writepages 來完成回寫。

  2. MM 子系統

    函式 do_writepages 最終會根據檔案系統是否對該地址空間 struct address_spacea_ops 操作表是否初始化了 writepages 成員來決定頁回寫的處理。主要有以下兩種情況,

    • 未初始化 writepages 成員。
      這時由 MM 子系統的 generic_writepages 呼叫 write_cache_pages 來遍歷檔案地址空間內的髒頁,並最終呼叫具體檔案系統的 writepage 回撥來對每一個頁做寫 IO。

    • 初始化了 writepages 成員。
      由於具體檔案系統模組已經初始化了 writepages 成員,則頁快取回寫由具體檔案系統的 writepages 的回撥來直接處理。

    本文實驗環境中,屬於第二種情況,即 writepages 成員已經被 Ext4 初始化。

  3. 具體檔案系統層

    本實驗裡使用的 Ext4 檔案系統在 struct address_space_operations 裡已經把 writepages 初始化為 ext4_writepages。因此,回寫快取的處理會由該函式來完成。

    函式 ext4_writepages 處理頁快取回寫的要點如下,

    • 當 Ext4 檔案系統以 data=journal 方式 mount 時

      在函式一開始就檢查,如果是 data=journal 方式,使用 MM 子系統的 write_cache_pages 來做頁快取的 page cache 處理。這條程式碼路徑的實際效果和檔案系統不實現 writepages 成員的處理是一樣的。最終 write_cache_pages 還會使用 Ext4 的另一個 writepage 回撥,即 ext4_writepage 來對單個髒頁做 IO 操作。實際上,在早期的核心版本,Ext4 會根據 mount 是否支援或者使用了 delalloc ((Delay Allocation) 特性,來決定使用不同的 struct address_space_operations 的操作表宣告。根據 mount 模式是否支援 delalloc 特性,Ext4 的快取回寫使用了不同的入口函式。關閉 Delay Allocation 特性時,不初始化 writepages,就都使用 write_cache_pages

      但 Linux 3.11 版本開始 Ext4 使用統一的 ext4_writepages 入口函式處理快取回寫。這個改動使得 data=ordered 模式下,即使 Delay Allocation 特性是關閉的,也會使用 ext4_writepages 方式,而不使用 write_cache_pages 方式。

    • 當 Ext4 使用非 data=journal 方式 mount 時

      例如 data=ordereddata=writeback。本文中就是 Ext4 預設模式,data=ordered

      • 呼叫 mpage_prepare_extent_to_map。找到連續的還未在磁碟上建立塊對映的髒頁,把它們加入 extent 並呼叫 mpage_map_and_submit_extent 來對映和提交這些髒頁。
        如果髒頁已經在磁碟上有塊影射了,則直接提交這些頁面。兩種情況最終都會呼叫 ext4_bio_write_page 將要提交 IO 的頁面加入到 struct ext4_io_submit 成員 io_biobio 結構裡。

      • 通過 ext4_io_submit 呼叫 submit_bio,從而把之前提交到 struct ext4_io_submit 成員 io_bio 裡的 bio 結構提交給通用塊層。

    篇幅有限,這裡不再對 delalloc 特性做更詳細的解讀。

  4. 通用塊層

    Ext4 的 ext4_writepages 使用了以下通用塊層的機制或介面,

    • Plug (蓄水) 機制。
      對應函式為 blk_start_plug,會在提交 IO 請求之前,在當前程序的 task_struct 裡初始化一個列表,用於通用塊層的 IO 請求排隊。隨後通過 submit_bio 提交給通用塊層的 bio 請求,都在通用塊層排隊,而不立刻下發給更低層的塊驅動。這個過程被叫做 Plug (蓄水)。

    • submit_bio 介面。
      在呼叫 submit_bio 提交 bio 給通用塊層之後,通用塊層會呼叫 generic_make_request。在此函式內,通過呼叫 blk_queue_bio 根據 bio 來構造 IO request,把或者將 bio 合併到一個已經存在的 request 裡。這時的 request 可以在當前任務的 plug 列表裡,或在塊裝置的 request_queue 佇列裡。當新提交的 bio 被構造或者合併入一個 IO request 以後,這些 request 並不是立刻被髮送給下層的塊驅動程式,而是在 plug 列表或者 request_queue 裡快取,submit_bio 會直接返回。

    • Unplug (排水) 機制。
      對應函式為 blk_finish_plugblk_flush_plug_list,該函式呼叫 __blk_run_queue 將所有 IO 請求都交給塊驅動程式傳送。

      Unplug 機制在以下兩個時機會被觸發,

      • 在通用塊層,如果 blk_queue_bio 發現當前任務的 plug 列表裡蓄積了足夠多的 IO request,這時通用塊層會主動觸發 Unplug 機制,呼叫塊驅動程式做真正的 IO 操作。
      • Ext4 檔案系統在完成所有回寫操作後,主動觸發 Unplug 操作。
  5. 具體塊驅動

    最終,通用塊驅動的策略函式被呼叫來發送 IO 請求。在 Linux Block Driver - 1 中,我們知道,本實驗中的 Sampleblk 塊驅動的策略函式為 sampleblk_request。這個函式的實現在那篇文章裡有詳細的講解。

上述 5 個層次中提到的函式名稱都可以在前面提到的 fadvise64函式圖的日誌裡找到。由於內部呼叫關係很複雜,另一個直觀和簡單的方式就是檢視前面章節中儲存的火焰圖

3.3.2.2 清除頁快取

將檔案對應的指定範圍的 page cache 儘可能清除 (Invalidate)。儘可能,就意味著這個清除操作可能會跳過一些頁面,例如,跳過已經被加鎖的頁面,從而避免因等待 IO 完成而阻塞。其主要過程如下,

  • 利用 pagevec_lookup_entries 對範圍內的每個頁面呼叫 invalidate_inode_page 操作清除快取頁面。在此之前,使用 trylock_page 來嘗試鎖頁。如果該頁已經被鎖住,則跳過該頁,從而避免阻塞。由於 Ext4 為每一個屬於 page cache 的頁面建立了與之關聯的 meta data, 因此這個過程中還需要呼叫 releasepage 回撥,即 ext4_releasepage 來進行釋放。
  • 呼叫 pagevec_release 減少這些頁面的引用計數。如果之前的清除操作成功,此時頁面引用計數為 0,會將頁面從 LRU 連結串列拿下,並釋放頁面。釋放後的頁面被歸還到 per zone 的 Buddy 分配器的 free 連結串列裡。

3.4 write

funcgraph 也可以獲取 write 系統呼叫的核心函式的函式圖 (function graph),

$ sudo ./funcgraph -d 1 -p 95069 SyS_write

詳細的 write 系統呼叫的跟蹤日誌請檢視這裡

由於我們的測試是 buffer IO,因此,write 系統呼叫只會將資料寫在檔案系統的 page cache 裡,而不會寫到 Sampleblk 的塊裝置上。
系統呼叫 write 過程會經過以下層次,

  1. VFS 層。

    核心的 sys_write 系統呼叫會直接呼叫 vfs_write 進入到 VFS 層程式碼。VFS 層為每個檔案系統都抽象了檔案操作表 struct file_operations。如果 write 回撥被底層檔案系統模組初始化了,就優先呼叫 write。否則,就呼叫 write_iter

    這裡不得不說,基於 iov_iter 的介面正在成為 Linux 核心處理使用者態和核心態 buffer 傳遞的標準。而 write_iter 就是基於 iov_iter 的新的檔案寫 IO 的標準入口。Linux 核心 IO 和網路棧的各種與使用者態 buffer 打交道的介面都在被 iov_iter 的新介面所取代。進一步討論請閱讀 The iov_iter interface

    此外,Linux 4.1 中,原有的 aio_readaio_write 也都被刪除,取而代之的正是 read_iterwrite_iter。其中 write_iter 相關的要點如下,

    • write_iter 既支援同步 (sync) IO,又支援非同步 (async) IO。
    • Linux 核心的同步 IO,即 sys_write 系統呼叫,最終通過 new_sync_write 來呼叫 write_iter
    • new_sync_write 是通過呼叫 init_sync_kiocb 來呼叫 write_iter 的。這使得底層檔案系統可以通過 is_sync_kiocb 來判斷當前發起的 IO 是同步還是非同步。而 is_sync_kiocb 主要判斷依據是 struct kiocbki_complete 的取值為 NULL,即沒有設定完成回撥。
    • Linux 核心的非同步 IO,則通過 sys_io_submit 系統呼叫來呼叫 write_iter。而此時非同步 IO 恰恰設定了 ki_complete 的回撥為 aio_complete
  2. 具體檔案系統和 MM 子系統

    Linux 4.1 之後,檔案系統在宣告檔案操作表 struct file_operations 時,若要支援讀寫,可以不實現標準的 readwrite,但一定要實現 read_iterwrite_iter。本文中的 Ext4 檔案系統,只實現了 write_iter 入口`,即 ext4_file_write_iter。對一些特殊情況做處理之後,MM 子系統的 入口函式 __generic_file_write_iter 被呼叫。在 MM 子系統的處理邏輯裡,主要有以下兩大分支,

    • Direct IO。
      如果檔案開啟方式為 O_DIRECT,這時 kiocbki_flags 被設定為 IOCB_DIRECT。此標誌作為 Direct IO 模式檢查的依據。而 MM 子系統的 generic_file_direct_write 會再次呼叫檔案系統的地址空間操作表的 direct_IO 方法,在 Ext4 裡就是 ext4_direct_IO

      在 Direct IO 上下文,檔案系統可以通過 is_sync_kiocb 來判斷是否是同步 IO 或非同步 IO,然後進入到具體的處理邏輯。本文中的實驗,fio 使用的是 Buffer IO,因此這部分不進行詳細討論。

    • Buffer IO。
      使用 Buffer IO 的時候,檔案系統的資料都會寫入到檔案系統的 page cache 後立即返回。這也是本文中測試實驗的情況。MM 子系統的 generic_perform_write 方法會做如下處理,

      • 呼叫檔案系統的 write_begin 方法。Ext4 就是 ext4_da_write_begin
        此函式通過呼叫 grab_cache_page_write_begiin 來分配新的 page cache。然後呼叫 ext4_map_blocks 在磁碟上分配新的,或者對映已存在的 block。
      • 呼叫 iov_iter_copy_from_user_atomicsys_write 系統呼叫使用者態 buffer 裡的資料拷貝到 write_begin 方法返回的頁面。
      • 呼叫檔案系統的 write_end 方法,即 ext4_da_write_end。該函式最終會將寫完的 buffer_head 標記為 dirty。

3.5 close

funcgraph 也可以獲取 close 系統呼叫的核心函式的函式圖 (function graph),

$ sudo ./funcgraph -d 1 -p 95069 SyS_close

詳細的 close 系統呼叫的跟蹤日誌請檢視這裡

close 系統呼叫主要分兩個階段,

  • 系統呼叫部分。首先,由 close 系統呼叫的引數 fd,可以得到其對應的 struct file 結構。而該結構裡定義了該檔案的檔案操作表。如果檔案系統實現了該檔案操作表的 flush 方法,則呼叫該方法,保證所有與該檔案相關的 pending operations 都可以在 close 呼叫返回前結束。最後通過 schedule_delayed_work 來讓核心延遲執行 delayed_fput 處理函式。

    需要指出的是,這種關閉時自動執行的 flush 方法不是必須的,很多檔案系統,例如 Ext4 並不支援該方法。事實上,應用程式通常是主動呼叫 fsync 系統呼叫,從而呼叫檔案系統的 fsync 方法來保證資料落盤。

  • 延遲執行部分。為防止死鎖等不可預期情況發生,保證 fput 操作可以被安全的執行,核心實現了 delay fput 的機制。該機制利用了 task_work_add 機制,保證延遲執行的 fput 操作可以在 close 系統呼叫返回到使用者程序程式碼執行前,先被執行和立即返回。真正的 fput 實現會呼叫檔案操作表的 fasyncrelease 方法,來實現檔案系統層面上所需要的處理。最後,struct file 結構會被徹底釋放掉。

    Linux 3.6 開始,fput 的實現開始遷移到 task_work_add 之上。而這個機制和一般非同步執行機制如 work queue 最大的區別在於,該機制保證延遲執行的函式會在使用者程序從系統呼叫程式碼返回到使用者空間時被同步的執行。這樣可以保證 close 語義不會被改變,其所有核心操作都會在使用者程序取得控制權前被完成。

    以 Ext4 為例,它並沒有實現 fasync 方法,但卻實現了 release 方法,即 ext4_release_file。該方法與 open 方法不同的是,open 方法每次開啟檔案都必須被呼叫,但 release 方法只有在最後一個關閉檔案的操作執行時被呼叫。此時,當檔案系統以 auto_da_alloc 為 mount option 時,ext4_release_file 會呼叫 filemap_flush 來保證在 close 前,延遲分配的塊可以被寫入到磁碟。關於auto_da_alloc 的目的和意義,請閱讀相關核心文件。

4. 實驗

執行 Linux Block Driver - 2 中的 fio 那個測試時,我們也可以利用 Linux Crash 工具來檢查檔案 IO 涉及的關鍵資料結構。Redhat 自帶的 Crash 版本無法支援 4.6.0 核心,為此,您可能需要參考 Linux Crash - coding notes 最後一小節,重新編譯新版 crash。

按照如下步驟,即可在執行 fio 時,以只讀方式檢視核心資料結構,

  • 進入 crash,以只讀方式檢視核心。

    $ sudo ./crash

  • foreach files 檢視哪些程序打開了 /mnt/test 檔案。可以看到,兩個 fio 任務分別各自打開了該檔案,

    crash> foreach files -R /mnt/test
    PID: 19670 TASK: ffff8800000715c0 CPU: 1 COMMAND: “fio”
    ROOT: / CWD: /ws/mytools/test/fio
    FD FILE DENTRY INODE TYPE PATH
    3 ffff880027172400 ffff880027678c00 ffff880077bf99a8 REG /mnt/test

    PID: 19671 TASK: ffff880035284140 CPU: 0 COMMAND: “fio”
    ROOT: / CWD: /ws/mytools/test/fio
    FD FILE DENTRY INODE TYPE PATH
    3 ffff880027170500 ffff880027678c00 ffff880077bf99a8 REG /mnt/test

    可以看到,輸出中分別給出了兩個 /mnt/test 檔案的各自的 struct filestruct dentrystruct inode 的資料結構地址。

  • 利用上一步得到的地址,檢視 struct file 的檔案操作表地址和地址空間地址,

    crash> struct file.f_op,f_mapping ffff880027172400
    f_op = 0xffffffffa0777940
    f_mapping = 0xffff880077bf9b10

  • 利用之前得到的地址,檢視 struct dentryd_named_inode 地址。可以看出,輸出與之前的結構吻合。

    crash> dentry.d_name,d_inode ffff880027678c00
    d_name = {
    {
    {
    hash = 3378392626,
    len = 4
    },
    hash_len = 20558261810
    },
    name = 0xffff880027678c38 “test”
    }
    d_inode = 0xffff880077bf99a8

  • 利用之前得到的地址,檢視 struct inode 的檔案操作表地址和地址空間地址,發現與之前 struct file 的檔案操作表地址和地址空間地址是一致的。如前所述,這是因為,struct file 的這兩個操作表就是從 struct inode 對應的成員複製過來的。

    crash> inode.i_fop,i_mapping ffff880077bf99a8
    i_fop = 0xffffffffa0777940
    i_mapping = 0xffff880077bf9b10

  • 根據地址空間地址,可以檢視其對應的地址空間操作表地址,

    crash> address_space.a_ops 0xffff880077bf9b10
    a_ops = 0xffffffffa0777ec0

  • 由於 /mnt/test 在 Ext4 檔案系統中建立,因此檔案系統操作表被初始化為 ext4_file_operations。其內容可以列印如下,

    crash> p ext4_file_operations
    ext4_file_operations = $10 = {
    owner = 0x0,
    llseek = 0xffffffffa07230c0 ,
    read = 0x0,
    write = 0x0,
    read_iter = 0xffffffff81191340 ,
    write_iter = 0xffffffffa0722a40 ,
    iterate = 0x0,
    poll = 0x0,
    unlocked_ioctl = 0xffffffffa0730f70 ,
    compat_ioctl = 0xffffffffa0732380 ,
    mmap = 0xffffffffa0722940 ,
    open = 0xffffffffa0722780 ,
    flush = 0x0,
    release = 0xffffffffa0723470 ,
    fsync = 0xffffffffa0723530 ,
    aio_fsync = 0x0,
    fasync = 0x0,
    lock = 0x0,
    sendpage = 0x0,
    get_unmapped_area = 0x0,
    check_flags = 0x0,
    flock = 0x0,
    splice_write = 0xffffffff81249100 ,
    splice_read = 0xffffffff81249d30 ,
    setlease = 0x0,
    fallocate = 0xffffffffa075c920 ,
    show_fdinfo = 0x0,
    copy_file_range = 0x0,
    clone_file_range = 0x0,
    dedupe_file_range = 0x0
    }

  • 而 /mnt/test 對應的地址空間操作表內容也可以列印如下,

    crash> p ext4_da_aops
    ext4_da_aops = $11 = {
    writepage = 0xffffffffa072b030 ,
    readpage = 0xffffffffa0726f90 ,
    writepages = 0xffffffffa072c0b0 ,
    set_page_dirty = 0x0,
    readpages = 0xffffffffa0726b40 ,
    write_begin = 0xffffffffa072dbf0 ,
    write_end = 0xffffffffa072e7d0 ,
    bmap = 0xffffffffa0727ed0 ,
    invalidatepage = 0xffffffffa0727b90 ,
    releasepage = 0xffffffffa07266f0 ,
    freepage = 0x0,
    direct_IO = 0xffffffffa07281b0 ,
    migratepage = 0xffffffff811f9370 ,
    launder_page = 0x0,
    is_partially_uptodate = 0xffffffff8124d950 ,
    is_dirty_writeback = 0x0,
    error_remove_page = 0xffffffff811a08e0 ,
    swap_activate = 0x0,
    swap_deactivate = 0x0
    }

仔細對比前面幾小節內容,我們即可清除直觀的瞭解到,Ext4 檔案系統是如何初始化 VFS 層提供的檔案操作表和地址空間操作表的。

關於 Linux Crash 命令的使用,請參考延伸閱讀的相關文章。

5. 小結

本文基於 fio 的測試用例,通過 Flamegraph 和 functongraph 等工具,結合原始碼來進一步理解其中涉及到的檔案 IO 操作。一般說來,從學習原始碼角度看,Flamegraph 更適合對被研究的程式碼和功能做一個全域性的疏理,而 functongraph 則更適合研究程式碼實現的細節。

通過這些簡單的瞭解,我們可以對 writefadvise64 做較為深入地比較,以充分了解為什麼兩個函式在執行時間上有如此大差異。

此外,我們也可以藉助 Linux Crash 工具,對 Linux 核心的關鍵資料結構以只讀方式檢視,幫助我們直觀瞭解核心實現。進一步資訊,請參考延伸閱讀章節列出的文章。

6. 延伸閱讀