Fuse(filesystem in userspace),是一個使用者空間的檔案系統。透過fuse核心模組的支援,開發者只需要根據fuse提供的介面實現具體的檔案操作就可以實現一個檔案系統。由於其主要實現程式碼位於使用者空間中,而不需要重新編譯核心,這給開發者帶來了眾多便利。Google在Android 11上,為了實現scoped storage,也引入了fuse。下面我們從Fuse的架構設計以及具體的實現細節來談一談fuse檔案系統。
一、 Fuse架構設計
二、 Fuse實現細節
下面我們基於Android 11 AOSP 以及 kernel4.19的開原始碼,討論一些fuse的實現細節,包括:fuse 使用者空間流程、核心佇列、/dev/fuse的讀寫流程等。
- fuse使用者空間流程
(1) fuse mount
Fuse的掛載透過mount函式,將指定的fuse_path掛載到/dev/fuse裝置上。之後對於fuse_path下的檔案操作,都會透過fuse檔案系統,並透過/dev/fuse被fuse daemon讀取處理。
(2) fuse thread
Fuse daemon還會建立一個服務執行緒,基於libfuse庫來處理檔案操作請求。這裡主要關注fuse_session_new和fuse_session_loop_mt。透過fuse_session_new在libfuse中註冊了fuse daemon實現的fuse_lowlevel_ops,之後透過fuse的所有的檔案操作,都會透過libfuse回撥到fuse daemon進行處理。
fuse_session_loop_mt在libfuse中實現了一個多執行緒模式來讀取請求,相比單執行緒,在請求處理上效率更高。
(3) libfuse
由fuse_session_loop_mt在libfuse中的呼叫流程如下:
這裡我們關注兩點:
a) splice實現記憶體零複製。在預設情況下,fuse daemon必須透過read()從/dev/fuse讀取請求,透過write()將請求回覆寫入/dev/fuse。每次讀寫系統呼叫都需要進行一次核心-使用者空間的記憶體複製。這樣對讀寫的效能損耗十分嚴重,因為一次記憶體複製需要處理大量資料。為了緩解這個問題,fuse支援了Linux核心提供的 splice 功能。splice 允許使用者空間在兩個核心記憶體緩衝區之間傳輸資料,而無需將資料複製給使用者空間。如果fuse daemon實現了write_buf()方法,則 FUSE 從/dev/fuse讀取資料,並以包含檔案描述符的緩衝區的形式將資料直接傳遞給此方法處理,從而省去了一次記憶體申請與複製。
b) 多執行緒模式。在多執行緒模式下,fuse daemon以一個執行緒開始,如果核心佇列中有兩個以上的request,則會自動生成其他執行緒。預設最大支援10個執行緒同時處理請求。
2. fuse核心佇列
圖片摘自《To FUSE or Not to FUSE: Performance of User-Space File Systems》
fuse在核心中維護了五個佇列,分別為:Backgroud、Pending、Processing、Interrupts、Forgets。一個請求在任何時候只會存在於一個佇列中。
a) Backgroud:background 佇列用於暫存非同步請求。在預設情況下,只有讀請求進入 background 佇列;當writeback cache啟用時,寫請求也會進入 background 佇列。當開啟writeback cache時,來自使用者程序的寫請求會先在頁快取中累積,然後當bdflush 執行緒被喚醒時會下刷髒頁。在下刷髒頁時,FUSE會構造非同步請求,並將它們放入 background 佇列中。
b) Pending:同步請求(例如,後設資料)放在 pending 佇列中,並且pending佇列會週期性接收來自background 的請求。但是pending佇列中非同步請求的個數最大為max_background(最大為12),當pending佇列的非同步請求未達到12時,background佇列的請求將被移動到pending佇列中。這樣做的目的是為了控制pending佇列中非同步請求的個數,防止在突發大量非同步請求的情況下,阻塞了同步請求。
c) Processing:當pending佇列中的請求被轉發到fuse daemon的同時,也被移動到processing佇列。所以processing佇列中的請求,表示正在被處理fuse daemon處理的請求。當fuse daemon真正處理完請求,透過/dev/fuse下發reply時,該請求將從processing佇列中刪除。
d) Interrupts:用於存放中斷請求,比如當傳送的請求被使用者取消時,核心會傳送一個Interrupts請求,來取消已被髮送的請求。中斷請求的優先順序最高,Interrupts中的請求會最先得到處理。
e) Forgets:forget請求用於刪除dcache中快取的inode。
3. /dev/fuse 讀寫呼叫流程
Fuse driver載入過程中註冊了對/dev/fuse的操作介面fuse_dev_operations。fuse_dev_do_read/fuse_dev_do_write分別對應fuse daemon從核心讀取請求,以及處理完請求後寫回reply的函式呼叫。我們分別看下具體的程式碼片段
當pending 、interrups、forgets佇列都沒有請求時,讀程序進入休眠。一旦有請求到達,這個等待佇列上的程序將被喚醒。Interrups 和 forgets的請求優先順序高於pending佇列。當請求的資料內容被複製至使用者空間後,該請求會被移至processing佇列,並且req->flags會儲存當前請求的狀態。
當fuse daemon處理完請求後,會將結果寫回到/dev/fuse。寫資料儲存在struct fuse_copy_state中,並且會根據unique id在fc(fuse_conn)中找到對應的req,並將寫回的引數從fuse_copy_state複製至req->out。
最後我們以unlink為例,看下fuse整體是如何工作的:
圖片摘自fuse核心官方文件
首先,fuse daemon會阻塞在讀/dev/fuse,當app程序在fuse掛載點下面有新的檔案操作(unlink),這時系統呼叫會呼叫fuse核心介面,並生成request,同時喚醒阻塞的fuse daemon。fuse daemon讀到request後,在libfuse中進行解析,根據request的opcode來執行對應的ops,完成後會把處理結果返回給/dev/fuse。此時vfs呼叫阻塞的行為將被喚醒,最後返回vfs呼叫。
三、 總結
雖然Fuse簡化了檔案系統的實現,給開發者帶來了便利。但是其額外的核心態/使用者態切換帶來的效能開銷不能被忽視,所以fuse效能問題,一直是業界繞不開的話題。前面說到的splice、多執行緒、writeback cache都是為了改善其效能問題。後續,我們再具體談談fuse效能改善。