Linux 內核 File cache 機制(上篇)

一、什麼是 File cache?

1.  File cache 概述

Linux File cache 機制,每次動筆想寫到該知識點的時候,我心裏總會猶豫遲疑,衆所周知內存管理是 Linux 系統的比較難啃的子系統之一,而內核文件緩存機制是內存管理框架中難度較大的知識點。其中包括文件緩存預讀取流程、寫流程、回收流程等,希望我們這次將其一探究竟。

討論 Linux File cache 前,先看下什麼是 Linux cache 機制呢?

我們在使用 Linux 系統的時候,經常會發現系統的空閒內存(後文以 memfree 代替)經常處於一個較低的狀態,有時 8G 的手機剛開機 memfree 就低於 2G,而此時可能並無啓動多少應用。仔細查看發現,此時系統的 cached 可能達到 3G 以上【圖 1:meminfo@1】,這時很多用戶會有疑問:cached 是什麼?是內存泄露嗎?顯然不是。cached 表示系統的緩存內存大小,當用戶需要讀取文件中的數據時,操作系統會先分配內存,然後將數據從存儲器讀入到內存中,最後將內存中的數據分發給用戶;當用戶需要往文件中寫數據時,操作系統會先分配內存接收用戶的數據,然後再將數據從內存寫到磁盤中。而 Linux cache 機制就是對這些由操作系統內核分配,並用來存儲文件數據的內存進行管理。

那麼可能有人會問:Cache 機制爲什麼會緩存這麼大?是否會被回收?

如果系統內存充足,緩存在內存中的文件數據是可以在內存中長時間駐留的,如果有其他的進程訪問這部分的數據,就不需要訪問磁盤,我們知道內存訪問速度比磁盤訪問速度要快,該機制可以避免用戶因爲磁盤訪問導致的長時間等待。所以在內存充足的情況下,系統的 cache 大小是會越來越大的;當系統的內存不足,Linux 內存回收機制就會把 cache 的內存進行回收,以緩解內存壓力。

在 Linux 內核中,cache 主要主要包括:(對應【圖 1:meminfo@2】)

cache 中大部分是文件緩存,即本文討論的 File cache,其包含活躍和非活躍的部分,對應如下:Active(file) 和 Inactive(file)【圖 1:meminfo@3@4】。

【圖 1:meminfo】

2.  File cache 機制框架

(1)系統層面下的 File cache 機制

【圖 2:Linux I/O 操作流程圖】

當用戶發起一個讀或者寫文件請求時,流程如【圖 2】,整體的流程如下:

VFS 用於與系統調用 read/write 等接口進行交互,通過 VFS 後可以通過 DIRECT_IO 直接與具體的文件系統進行交互,如果沒有 DIRECT_IO,則會通過 cache 機制與具體的文件系統交互。具體的文件系統例如 ext3/ext4 等,通過 Generic block layer 和 IO schedule layer 與具體的塊設備驅動交互。

所以理論上 cached 的機制的設計邏輯在於具體的具體文件系統之上,VFS 之下,即上圖中 “Buffer page Cache” 部分。

(2)File cache 機制內部框架梳理

File cache 機制,從內部框架簡單分爲兩部分:File cache 的產生和回收。學習文件緩存按照下面的框架進行由淺至深進行分析,更加容易抓住設計的邏輯。

以下的分析基於 Linux-4.19,並且基於不討論 DIRECT_IO 模式。

二、read 發起讀文件流程分析

1. read 函數生命週期

用戶讀取的文件,可以有不同的實現方法,但是普遍是通過 read 系統和 mmap 接口進行讀取,該章節介紹 read 的讀取流程分析:

ssize_t read(int fd, void *buf, size_t count);

用戶調用該接口會調用內核的 sys_read 接口,並最終會通過 VFS 調用到具體文件系統的讀文件接口,並經過內核 “六個階段”,最終調用塊設備驅動程序的接口,通過向磁盤控制器發送相應的命令,執行真正的數據傳輸;

【圖 3:Linux read 函數生命週期圖】

從圖 3,我們可以看出 filecache 的設計邏輯集中 “file cache” 方框,這部分在具體文件系統之上,在 VFS 之下。

從圖 3 我們也可以看出具體的文件系統(EXT2/3/4 等)負責在文件 cache 和存儲設備之間交換數據,而 VFS 負責應用程序和文件 cache 之間通過 read()/write() 等接口交換數據。

2. 預讀機制

從圖 3 中,當文件緩存讀取的過程主要是通過 page_cache_sync_readhead()和 page_cache_async_readahead()兩部分,從函數的名字可以看出兩個函數的作用分別是 “同步預讀” 和“異步預讀”。但是從代碼的邏輯看,其實 page_cache_sync_readhead()這個名字取得並不準確,因爲同步預讀的語義應該是進程同步等待直至讀取文件內容成功,但是後面分析我們會發現這兩個函數僅僅做到事情如下,並非真正的等到文件內容讀取完成。

說回預讀(read ahead)機制,就是在數據真正訪問之前,從普通文件或者塊設備文件批量的讀取更多的連續文件頁面到內存中。內核爲了提供 IO 性能,當用戶要求讀取文件頁面時,會通過預讀算法計算是否將相鄰的文件頁面提前從磁盤讀入到內存中。

(1)預讀機制的優勢和風險

預讀機制有兩個優勢:

當然預讀也是存在風險,特別是隨機讀的時候,預讀對系統是有害的,因爲對於隨機讀取這種場景,預讀的文件頁面被用戶訪問的概率偏低。如果被提前預讀的文件頁面沒有被用戶訪問,該場景會浪費系統的物理內存,並且會造成階段性的 IO 負載。

(2)Linux 內核預讀規則

爲了規避上面提到的預讀風險,Linux 內核對預讀的機制秉承着的規則:(這裏不討論用戶調用 madvise 等系統調用指定特定區域即將被訪問的場景,讀者可以自行分析源碼)

(3)Linux 內核預讀機制設計模式

內核對預讀的設計是通過 “窗口” 來實現的,有兩個窗口:當前窗口和前進窗口。

簡單理解窗口表示一些頁面的集合,例如圖 4,當用戶要求讀取 1 個文件頁面時,其實系統總共預讀 4 個頁面,當前窗口表示用戶要求讀取的頁面,前進窗口表示提前預讀的 3 個頁面,不管這三個文件頁面是否完整讀入到內存中了。

【圖 4:預讀窗口解析 1】

如果經過一段時間,前進窗口已經成功提前將文件頁面讀入到內存中,並且用戶命中了預讀的文件頁面,則此時圖 4 中的前進窗口會轉變爲當前窗口,並重新構建前進窗口,如圖 5:

【圖 5:預讀窗口解析 2】

所以如果說預讀不斷命中,前進窗口是不斷轉換爲當前窗口的,當然如果預讀沒有命中,例如隨機讀場景,那麼預讀機制就會被關閉,此時就不存在前進窗口了,但是當前窗口總是存在的。最理想的狀態,就是當前窗口的頁面被用戶訪問完後,前進窗口的頁面也預讀完成,並且接下來被用戶讀取命中,此時前進窗口轉爲當前窗口,並且重新構建新的前進窗口進行預讀。

“窗口”的概念,Linux 通過 “struct file_ra_state” 進行抽象,定義在 include/linux/fs.h:

這裏需要注意的 “窗口” 的概念是針對文件個體設定的,即不同文件對應着各自的窗口實體,所以如果連續打開不同的文件,不同文件之間的預讀大小是不會互相影響的。系統打開的每個文件,都有一個 file_ra_state 實例:

每個文件被打開時會對該文件的 file_ra_state 結構體進行初始化,默認狀態 file_ra_state 的成員狀態如下:

3. generic_file_buffered_read

明白了總體框架後,那就跟隨者那句經典的 “read the f***ing source code”,來看看代碼 generic_file_buffered_read 流程,該函數定義在 mm/filemap.c:

@0:函數一開始,首先拿到該文件的窗口實例,如果是第一次讀取那麼該實體就是初始狀態,如果非第一次讀取頁面,該實體就是上一次預讀的狀態,本次讀取會根據上次的預讀狀態和本次是否命中調整窗口實例。另外這裏有兩個局部變量需要關注一下:

last_index 減去 index 表示用戶要求讀取的頁面數目。

該函數會執行同步預讀和異步預讀兩個部分,這裏分開分析:

(1)同步預讀

@1 調用 find_get_page 根據 mapping 和 index 查看一個文件頁面是否在緩存中了,其實就是在文件緩存樹中進行查找。此時有兩種結果:在文件緩存樹中找到頁面或者找不到頁面。

@2 調用 page_cache_sync_readahead() 函數進行同步預讀,對於該函數需要注意的兩點:

@3 重新判斷是否分配好頁面並加入到緩存樹中,這裏有兩個結果:在文件緩存中找到頁面或者找不到頁面;

@5 通過 PageUptodate() 判斷頁面是否讀取到最新數據,如果不是最新的數據沒有讀取完成,就會調用 wait_on_page_locked_killable()->io_schedule() 進行等待,這就是 systrace 中 read 進程 Block IO 的原因。這裏可能有人會問題 PG_uptodate 和 PG_locked 是在哪裏設置的?

當分配內存並將頁面插入到緩存樹以及 zone lruvec 中前會通過 add_to_page_cache_lru()->__SetPageLocked() 設置頁面的 PG_locked,而 PG_uptodate 內存申請時默認不設置。當發起 IO 請求,並且 IO 操作完成時會及時將頁面的 PG_locked 清除,並設置 PG_uptodate。

所以 wait_on_page_locked_killable(page) 函數此刻就能起到同步等待的數據讀取完成的作用,而並非是在 page_cache_sync_readahead() 同步等待,該函數命名比較迷惑。

@6 表示一個頁面已經更新完數據了,此時會做幾件事:

1) 將讀取的頁面發送拷貝給用戶;

2)記錄當前讀取的數據對應的頁面序號到在 prev_index 中;以便 @9 更新到窗口中,用於下一次頁面讀取判斷用戶是否是順序讀;

3)然後更新 index,記錄要讀取的下一個文件頁面序號;

4)通過 iov_iter_count 判斷是否已經讀取完成,完成則執行 @9 更新當前窗口的狀態,並退出;否則根據 index,讀取下一個頁面;

@3 找不到頁面的情況,即此時可能是因爲不支持預讀或者頁面分配沒有成功等原因,此時就需要改變內存分配的標誌,並且等到該文件更新完數據。

@7 通過 page_cache_alloc 分配頁面,分配標誌沒有__GFP_NORETRY | __GFP_NOWARN,表示內存緊張會進入慢速路徑,分配成功後將頁面插入到緩存樹和 zone lruvec 中。

@8 調用文件系統的 readpage 進行文件數據讀取,並同步等待讀取完成;讀取完成後就執行 @6 進行下一個頁面讀取或者退出本次讀取過程;

如果一開始就在緩存樹中找到了頁面,那麼就直接執行 @5 的流程執行,等待頁面的數據讀取完成,後續流程跟上面一致。

所以同步預讀核心因素是 page_cache_async_readahead 函數,定義在 mm/readahead.c 中,該函數僅僅是 ondemand_readahead 的封裝,該函數在 “4.ondemand_readahead” 分析。

(2)異步預讀

異步預讀的處理集中在 @4,先通過 PageReadahead(page) 判斷頁面的是否設置了 PG_readahead,如果該頁面設置該標誌,表示本地當前窗口讀取的文件頁面命中了上一個前進窗口預讀的頁面,此時就要通過異步預讀操作發起一個新預讀。

關於 PG_readahead 是在預讀時標記的,規則如圖 6,當用戶要求讀入一個文件頁面,系統預讀的其後連續的 3 個文件頁面,那麼第一個預讀的頁面就會被標記 PG_readahead;

【圖 6:PG_readahead 設置規則】

page_cache_async_readahead() 函數的參數和同步預讀一樣,只多一個 struct page 結構體,作用是將該 page 的 PG_readahead 的標誌清空,接着也是調用 ondemand_readahead() 函數。我們發現 generic_file_buffered_read() 發起的同步預讀和異步預讀最終都是調用 ondemand_readahead() 函數,區別是第四個傳參 hit_readahead_marker 爲 true 或 false。

4. ondemand_readahead

ondemand_readahead() 函數定義在 mm/readahead.c 中,總共 6 個參數,這裏主要關注 4 個參數:

函數一開始先獲取該窗口單次預讀最大的頁面數。該函數分爲兩種場景:從文件頭開始讀取或者非文件頭讀取。

@1:判斷當前讀取是否從文件頭開始讀?offset 表示讀取的第一個頁面在文件中的頁面序號,爲 0 表示爲從文件頭讀取。

(1)從文件頭開始讀

從文件頭部開始讀,代碼流程如下:

@1:如果該頁面是文件中的第一頁面,即從頭開始讀,那麼就判斷爲順序讀,開始初始化當前的窗口;

@2:先把讀取的第一個頁面序號賦值給 ra->start;然後調用 get_init_ra_size() 函數根據用戶要求讀取的頁面數和單次最大允許的預讀頁面數,得到本次窗口的預讀頁面數;

如果本次讀取的頁面數大於用戶請求讀取的頁面數,則將多預讀的頁面數記錄到 ra->async_size,這部分頁面表示異步讀取;

get_init_ra_size() 函數定義在 mm/readahead.c 中,該函數的參數:

對於內核這種固定數值,又沒給出註釋的方式的公式,個人覺得不是很 “優雅”。

這套計算公式分別用最大預讀數 128Kbytes 和 512Kbytes,推導結果如下:(req_size 表示用戶要求讀取的頁面數,new_size 表示實際預讀的頁面數)。從這個結果可以得出,設置單次最大的預讀頁面數目,影響不僅僅是最大的預讀頁面數,對預讀的每個環節都有影響。

@3:調用 ra_submit() 發起讀頁面請求,該函數定義在 mm/internal.h,是對__do_page_cache_readahead 的封裝,傳入本次預讀的起始頁面序號,預讀頁面數,異步預讀頁面數。

__do_page_cache_readahead() 函數,比較關鍵的三個參數:

__do_page_cache_readahead() 邏輯是比較簡單的,這裏不做過多闡述,這裏需要注意:

@1:該函數分配的頁面是帶__GFP_NORETRY 的,也就是內存緊張時不會進入分配慢速路徑。

@2:如果一個文件所有數據讀取完成,必須停止剩下的預讀;

@3:對第一個異步預讀的頁面標誌 PG_readahead,對應【圖 4:PG_readahead 設置規則】。

@4:調用具體文件系統的 readpages 接口發起 IO 流程,並將 page 加入到緩存樹和 zone lru(請查閱文件緩存回收流程解析章節);

(2)非文件頭讀取

回到 ondemand_readahead 函數,如果是非文件頭讀取文件頁面,有幾種可能 :

@4:順序讀情況處理:如果請求的第一個頁面序號與上次預讀的最後一個頁面時相鄰的(page(hit2)),或者剛好是上次第一個異步預讀的頁面 (page(hit1)),則表示此時讀取是順序讀,增加預讀頁面數進行預讀。假設用戶上次要求讀取一個頁面,加上預讀總共讀取了 4 個頁面,如果此次我們讀取到 page(hit1) 或者 page(hit2),則表示順序讀,此時直接增加預讀數,最後走到 @3 通過 ra_submit 發起預讀就完成了。

【圖 7:順序讀】

更新到 ra->start 到 page(hit1) 或者 page(hit2) 的序號,然後通過 get_next_ra_size() 獲取下一次的預讀的大小:

根據上面的規則,大多數情況都是上次預讀頁面數目的兩倍。我們看下最大預讀數分配爲 128Kbytes 和 512Kbytes 的情況下,用戶需要命中多少輪才能達到最大的預讀頁面數。

@5:異步預讀命中處理:如果是 page_cache_async_readahead()函數調用進來,hit_readahead_marker 爲 true,這種情況已經確認命中 PG_readahead 的頁面,所以肯定增大預讀頁面數,再次發起預讀。首先查找 [index+1, max_pages] 這個文件區間內第一個沒有在緩存樹中的頁面,以此頁面爲新的起點,增加好預讀數,並構建好前進窗口,最後跳到 @5:ra_submit 發起預讀請求;

@6:該場景有兩種,其一是此時讀取的頁面和上一次訪問的頁面相同;其二是如果用戶要求讀入多個頁面,如果預讀來不及處理多個頁面,那麼就會出現多個頁面連續進來預讀的情況,如圖 8 讀取到 page*。這兩種情況需要重新初始化預讀狀態,並將第一個讀取頁面序號指向當前讀取頁面;

【圖 8】

@7 的場景是通過預讀歷史判斷是否繼續預讀;

@8 隨機讀場景:表示系統判斷此時頁面讀取是隨機讀,這種場景會關閉預讀,__do_page_cache_readahead() 的 nr_to_read 參數傳入 req_size,表示只讀取用戶要求的文件頁面。

三、File cache 寫流程分析

1. write 函數生命週期

用戶寫文件沒有像讀文件類似的預讀模式,所以整個過程是比較簡單的,以下不考慮 Direct_IO 的模式:

ssize_t write(int fd, void *buf, size_t count);

內核調用的流程如下,其中 ext4_write_begin 會判斷需要寫的頁面是否在內存中,如果不在會分配內存並通過 add_to_page_cache_lru() 將頁面插入到緩存樹和 zone lru 中; ext4_write_end 會發起 IO 操作。由於篇幅的原因,本文就不再貼出具體的分析過程,如果有興趣可以跟着源碼細讀。

【圖 9:Linux write 函數生命週期圖】

四、mmap 發起讀文件流程分析

Linux mmap 讀文件流程涉及缺頁中斷和部分虛擬內存管理,此部分內容將放置在《Linux 內核 File cache 機制(中篇)》中,後續發佈,有興趣的可以關注。

五、File cache 回收流程分析

Linux File cache 的回收涉及的知識點很多,包括內存管理 LRU 機制、workingset 機制、內存回收 shrink 機制和髒頁管理機制等,此部分內容將放置在《Linux 內核 File cache 機制(下篇)》中,後續發佈,有興趣的可以關注。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/fW8NS4wQgjumq5fPDMzkag