Fuse(filesystem in userspace),是一個用戶空間的文件系統。通過fuse內核模塊的支持,開發者只需要根據fuse提供的接口實現具體的文件操作就可以實現一個文件系統。由於其主要實現代碼位於用戶空間中,而不需要重新編譯內核,這給開發者帶來了眾多便利。Google在Android 11上,為了實現scoped storage,也引入了fuse。下面我們從Fuse的架構設計以及具體的實現細節來談一談fuse文件系統。
一、 Fuse架構設計
圖片摘自《To FUSE or Not to FUSE: Performance of User-Space File Systems》
Fuse包含一個內核模塊和一個用戶空間守護進程(下文稱fuse daemon)。內核模塊加載時被注冊成 Linux 虛擬文件系統的一個 fuse 文件系統驅動。此外,還注冊了一個/dev/fuse的塊設備。該塊設備作為fuse daemon與內核通信的橋梁,fuse daemon通過/dev/fuse讀取fuse request,處理后將reply寫入/dev/fuse。
上圖詳細展示了fuse的構架。當application掛在fuse文件系統上,並且執行一些系統調用時,VFS會將這些操作路由至fuse driver,fuse driver創建了一個fuse request結構體,並把request保存在請求隊列中。此時,執行操作的進程會被阻塞,同時fuse daemon通過讀取/dev/fuse將request從內核隊列中取出,並且提交操作到底層文件系統中(例如 EXT4 或 F2FS)。當處理完請求后,fuse daemon會將reply寫回/dev/fuse,fuse driver此時把requset標記為completed,最終喚醒用戶進程。
二、 Fuse實現細節
下面我們基於Android 11 AOSP 以及 kernel4.19的開源代碼,討論一些fuse的實現細節,包括:fuse 用戶空間流程、內核隊列、/dev/fuse的讀寫流程等。
1. 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性能改善。
參考文獻:
[1] Bharath Kumar Reddy Vangoor, Vasily Tarasov, Erez Zadok.To FUSE or Not to FUSE: Performance of User-Space File Systems. in Proceedings of the 15th USENIX Conference on File and Storage Technologies (FAST ’17), 2017 • Santa Clara, CA, USA

“內核工匠”微信公眾號
Linux 內核黑科技 | 技術文章 | 精選教程