一、基本概念
設計背景
檔案通路類型一般是順序的,通路[A, B]範圍的資料後,接下來很可能通路[B+1, B+N]資料。由于通路磁盤、flash等存儲器件比較耗時,在通路 [A, B]的時候,如果提前把[B+1, B+N]資料從存儲器件讀取到ram中,那麼後繼需要用[B+1, B+N]資料時,就不需要通過耗時的disk io從存儲器件讀取資料了,進而提高性能。
預讀帶來的好處
對于順序讀,适量的預讀可以提升性能。原因如下:
1)提前讀取資料,避免耗時的disk io讀取資料
預讀資料緩存在page cache中(struct file->f_mapping->page_tree),用的時候,直接從緩存讀取,不需要從存儲器件讀取資料(這個操作耗時)。
2)提高存儲棧、器件處理效率
預讀本質是提前讀取即将通路的資料,以備将來使用,這意味着“目前資料”和“預讀資料”的邏輯位址是連續的,這兩個io請求在核心存儲棧中(filesytem-->block layer-->device)就可以進行合并(merge)處理。處理一個io請求,即可滿足原先的兩個io請求需求。
3)降低硬中斷、軟中斷帶來的負載
器件處理io請求完成時,核心通過硬中斷、軟中斷處理io完成時的工作,合并io請求,可降低中斷數量,減少中斷帶來的額外負載。
4)避免存儲棧擁塞導緻read響應不及時
器件的io數量是有上限的(預設值/sys/block/sdc/queue/nr_requests=128),當器件待處理的io數超過7/8 * nr_requests時,存儲棧進入擁塞狀态并限制生成io請求的速度;當待處理的io請求數達到最大值nr_requests時,read因申請不到struct request而阻塞等待,直至一些io請求處理完後被存儲棧回收,read、write才能轉換成io請求,然後送出io請求給器件處理。合并io請求,可減少io數量,降低存儲棧擁塞的機率。
5)避免磁盤磁頭來回移動
這一點是針對機械硬碟的。檔案資料[A,B]和[B+1, B+N]在邏輯位址上是連續的,磁頭隻需往一個方向移動。作為對比,通路[A,B],[A-N, A-1],[B+1, B+N],就會出現磁頭來回移動的情況,磁頭的機械運動是很耗時的。
預讀帶來的壞處
1)對于随機讀,預讀的資料不會被用到,白白占用了存儲器件帶寬及系統記憶體。
2)大量預讀可能導緻io負載增大。
3)大量預讀可能導緻記憶體壓力增大。
同步預讀& 異步預讀
源碼中有兩個函數page_cache_sync_readahead和page_cache_async_readahead,分别稱作同步預讀、異步預讀。這個地方容易讓人迷糊,需解釋一下二者的差別。
同步預讀:從存儲器件讀取多個page資料,一些page給read使用,一些page留給将來使用。在下圖,read檔案資料時,根據page->index在page cache中(struct file->f_mapping->page_tree)找需要的頁面(藍色頁面),如果找不到就執行同步預讀page_cache_sync_readahead。同步預讀根據待讀資料的邏輯位址、待讀page數量及位址資訊,封裝成bio,然後調用submit_bio送出給器件處理。不過要注意預讀送出bio後就傳回了,并不會等待page中的資料變成PageUptodate。
異步預讀:本次讀的page純粹是為将來準備的,觸發異步預讀的read目前不需要這些資料。這些page提前從存儲器件讀取并将page加入到page cache中。
二、關鍵資料結構及原理
資料結構
預讀的核心資料結構是struct file_ra_state,定義在linux/fs.h中:
/*
* Track a single file's readahead state
*/
/* 這個結構體是struct file相關的,描述了file的預讀狀态。
預讀按預讀視窗推進,預讀視窗中含有file_ra_state->size個page。
read通路預讀視窗中的page,通路到第size-async_size個page,也
即預讀視窗中還剩async_size個page沒有通路時,啟動異步預讀讀入一
批新的page作為預讀視窗 */
struct file_ra_state {
//從start這個page開始讀
pgoff_t start; /* where readahead started */
//讀取size個page放入預讀視窗。
如記憶體不足無法申請page,則預讀小于size個page
unsigned int size; /* # of readahead pages */
//預讀視窗中還剩async_size個page時,啟動異步預讀
unsigned int async_size; /* do asynchronous readahead when
there are only # of pages ahead */
//預讀視窗上限值(機關: page).
預設等于struct backing_dev_info->ra_pages, 可通過fadvise調整。
如果read需要讀的page數量小于ra_pages,最多讀取ra_pages個頁面.
如果read需要讀的page數量大于ra_pages,最多讀取 min { read的page數量,存儲器件單次io最大page數量 }個頁面.
預讀視窗中目前有多少個頁面由size成員變量表示.
unsigned int ra_pages; /* Maximum readahead window */
unsigned int mmap_miss; /* Cache miss stat for mmap accesses */
//最後一次的讀位置(機關:位元組)
loff_t prev_pos; /* Cache last read() position */
};
關鍵字段意義如下圖:
原理
read請求N 個page資料,通過預讀從存儲器件讀取M個page資料(M > N),并對其中的第a個page設定PageReadahead标記(0 ≤ a < N)。PageReadahead标記起到一個辨別作用,表示預讀視窗中剩餘的page不多了,需要啟動異步預讀再讀入一批page存放page cache。
如果是順序讀,肯定會讀到PageReadahead标記的頁(單線程讀的場景),或者讀到預讀視窗的最後一個頁(多線程穿插讀的場景),是以代碼作者認為,如果通路到這兩種頁就是順序讀(注意:這樣的判斷不是很準,但代碼寫起來簡單高效),否則認為是随機通路。
如果是順序通路,預讀視窗在之前的基礎上擴大2倍或者4倍(上限值不超過struct file_ra_state->ra_pages),然後從存儲器件讀入資料填充這些page,并緩存在page cache中,最後,在新讀入的這批page中標明一個page設定PageReadahead标記(一般是新讀入的這批page中的第一個page)。
如果是随機通路,預讀機制僅從存儲器件中讀取read函數需要的資料,read請求幾個page,就讀幾個page,并緩存在page cache中。可以看出随機讀不會預讀多餘的page,另外注意随機讀不更改預讀視窗。
三、預讀示例
按4k順序通路檔案的[0, 8*4K]的資料(順序通路),然後lseek到108*4k處通路檔案(随機通路)。過程如下:
1)通路第0個page資料
page->index=0的頁面在page cache中找不到,觸發同步預讀page_cache_sync_readahead,一次性讀了4個page(read需要1個page,預讀3個page。預讀page數量與實際請求的page數量、file_ra_state->ra_pages有關,通過get_init_ra_size計算)。本次預讀建立的預讀視窗如下:
注意,預讀視窗中第ra->size - ra->async_size = 1個page,即page->index=1設定了PageReadahead标記。
2)通路第1個page資料
page->index=1的頁面在page cache中能找到(預讀命中),不需要從存儲器件中讀取資料。又因該page有PageReadahead标記,觸發異步預讀page_cache_async_readahead,預讀頁面數量由get_next_ra_size計算得到,因為本次請求的資料起始位置與上一次讀結束位置相同,屬于順序讀,get_next_ra_size加大預讀量,預讀量從之前的4 page增大到8 page。本次預讀建立的預讀視窗如下:
注意,預讀視窗中第ra->size - ra->async_size = 0個page,即page->index=4設定了PageReadahead标記。
3)通路第2、3個page資料
這兩個page在page cache中可以找到(預讀命中),直接從page cache中讀取資料。
4)通路第4個page資料
page->index=4的頁面在page cache中可以找到(預讀命中),直接從page cache中讀取資料。不過這個page設定了PageReadahead标記,觸發異步預讀page_cache_async_readahead,由于是順序讀,get_next_ra_size将預讀量從8 page增大到16 page,本次預讀建立的預讀視窗如下:
後繼的順序通路流程重複上面過程,遇到page被标記成PageReadahead,增大預讀量(最大不超過struct file_ra_state->ra_pages)後啟動異步預讀。
5)lseek跳到108*4k處通路第108個page資料
通路page->index=108的page,不符合順序讀的條件,是以代碼判斷成随機讀。如果是随機讀,則不做預讀,read請求幾個頁的資料,就從存儲器件中讀幾個頁資料(ondemand_readahead --> __do_page_cache_readahead)。
注意,這次随機讀,不會更改預讀視窗狀态。
四、關鍵代碼分析
同步預讀generic_file_buffered_read --> page_cache_sync_readahead 與 異步預讀generic_file_buffered_read --> page_cache_async_readahead都會調用ondemand_readahead,在這個函數中計算預讀量,然後ra_submit送出io請求。
/* @offset: 從哪個page開始讀
@req_size: 需要讀多少個page,這是read需要的數量
如果是順序讀,ondemand_readahead讀取至少req_size個page。
如果是随機讀,ondemand_readahead僅讀取req_szie個page。
*/
ondemand_readahead(struct address_space *mapping,
struct file_ra_state *ra, struct file *filp,
bool hit_readahead_marker, pgoff_t offset,
unsigned long req_size)
{
struct backing_dev_info *bdi = inode_to_bdi(mapping->host);
unsigned long max_pages = ra->ra_pages;
unsigned long add_pages;
pgoff_t prev_offset;
/*
* If the request exceeds the readahead window, allow the read to
* be up to the optimal hardware IO size
*/
/* read請求的page大于預讀視窗大小,預讀的page數量上調至存儲器件單次io最大的page數量 */
if (req_size > max_pages && bdi->io_pages > max_pages)
max_pages = min(req_size, bdi->io_pages);
/*
* start of file
*/
if (!offset)
goto initial_readahead;
/*
* It's the expected callback offset, assume sequential access.
* Ramp up sizes, and push forward the readahead window.
*/
/* 條件1:offset == ra->start + ra->size - ra->async_size
/ 同步建立的預讀視窗 \
/ \
|----------------------------------------|
| | | | ^ | | | | | | |
|--------------|-------------------------|
|
ra->start + ra->size - ra->async_size
該page設定了PageReadahead
上圖是同步預讀建立的預讀視窗,從第0個page到第ra->size - ra->async_size
個page,是read需要用的,後面的page是預留給下次read用的。
讀到第ra->size - ra->async_size個pag,表明預讀視窗已經開始使用,
需要啟動異步預讀,讀入一批page建立一個新的預讀視窗,為下下次read做準備。
條件2:offset == ra->start + ra->size
前一次 / 異步建立的預讀視窗 \
預讀視窗 / \
-----------|-----------------------------------|
| | | ^ | | | | | | | | | | | |
------------|-----------------------------------|
|
ra->start + ra->size(前一次預讀用完了,執行下一個預讀視窗的第一個page)
該page設定了PageReadahead
異步預讀建立的預讀視窗中,第一個page标記為PageReadahead,通路到這個标記的
頁面,表明新預讀視窗已經開始使用,需要啟動異步預讀,讀入一批page建立一個新的
預讀視窗,為下下次read做準備。
滿足這兩個條件,可認為本次read預上一次read是順序讀類型 (見原理部分描述)。
這個條件是不準确的,但是代碼實作簡單高效。*/
if ((offset == (ra->start + ra->size - ra->async_size) ||
offset == (ra->start + ra->size))) {
ra->start += ra->size;
/*既然是順序讀,就擴大預讀視窗:
如果目前預讀視窗大小ra->size < 1/16 * 允許讀的最多page數
1)則預讀視窗在原基礎上擴大4倍。否則擴大2倍。
2)擴大後的視窗大小,不能超過“允許讀的最多page數量”
*/
ra->size = get_next_ra_size(ra, max_pages);
ra->async_size = ra->size;
goto readit;
}
/*
* Hit a marked page without valid readahead state.
* E.g. interleaved reads.
* Query the pagecache for async_size, which normally equals to
* readahead size. Ramp it up and use it as the new readahead size.
*/
/* 多線程順序讀一個檔案(interleaved read),破壞了file->ra_state,是以沒法根據前面的條件
判斷出是順序讀類型。如果是順序讀,最終一定會通路到預讀視窗中的PageReadahead标記的page。
是以,反過來推,通路到PageReadahead标記的page,就認為是順序讀。當然這樣的判斷是不準确的,
但準确率高,代碼簡單。*/
if (hit_readahead_marker) {
pgoff_t start;
/* offset + 1開始,找到第一個不在page cache中的檔案資料頁,作為預讀的開始 */
rcu_read_lock();
start = page_cache_next_hole(mapping, offset + 1, max_pages);
rcu_read_unlock();
if (!start || start - offset > max_pages)
return 0;
ra->start = start;
ra->size = start - offset; /* old async_size */
ra->size += req_size;
ra->size = get_next_ra_size(ra, max_pages);
ra->async_size = ra->size;
goto readit;
}
/*
* oversize read
*/
if (req_size > max_pages)
goto initial_readahead;
/*
* sequential cache miss
* trivial case: (offset - prev_offset) == 1
* unaligned reads: (offset - prev_offset) == 0
*/
/* 上面的場景根據下面3個條件判斷是否是順序讀:
offset == ra->start + ra->size - ra->async_size
offset == ra->start + ra->size
hit_readahead_marker
代碼執行到這裡,屬于随機讀場景。
*/
prev_offset = (unsigned long long)ra->prev_pos >> PAGE_SHIFT;
if (offset - prev_offset <= 1UL)
goto initial_readahead;
/*
* Query the page cache and look for the traces(cached history pages)
* that a sequential stream would leave behind.
*/
if (try_context_readahead(mapping, ra, offset, req_size, max_pages))
goto readit;
/*
* standalone, small random read
* Read as is, and do not pollute the readahead state.
*/
/* read請求req_size,__do_page_cache_readahead隻讀req_size個page。注意2點:
1)__do_page_cache_readahead-->read_pages條件io就傳回了,不會等待page變成PageUptodate。
2)__do_page_cache_readahead不會更改 struct file_ra_state。
*/
return __do_page_cache_readahead(mapping, filp, offset, req_size, 0);
initial_readahead:
/* 第一次讀檔案,初始化預讀視窗 */
ra->start = offset;
ra->size = get_init_ra_size(req_size, max_pages);
ra->async_size = ra->size > req_size ? ra->size - req_size : ra->size;
readit:
/*
* Will this read hit the readahead marker made by itself?
* If so, trigger the readahead marker hit now, and merge
* the resulted next readahead window into the current one.
* Take care of maximum IO pages as above.
*/
/* hit_readahead_marker預期建立了一個預讀視窗A,待通路offset剛好是這個預讀視窗的第一個page,
這說明通路A視窗,後面沒有預留的預讀視窗了,需建立下一個預讀視窗B備用,既然A、B都還沒預讀,
就合并後一起預讀。*/
if (offset == ra->start && ra->size == ra->async_size) {
add_pages = get_next_ra_size(ra, max_pages);
if (ra->size + add_pages <= max_pages) {
ra->async_size = add_pages;
ra->size += add_pages;
} else {
ra->size = max_pages;
ra->async_size = max_pages >> 1;
}
}
/* ra_submit --> __do_page_cache_readahead盡量配置設定ra->size個page,
配置設定失敗就停止配置設定,這些page加入page_pool連結清單。
read-->page_cache_sync_readahead,假設read需要讀取nr_to_read個page,
預讀M個page,則第nr_to_read個page(從0開始計數)設定PageReadahead标記。
read-->page_cache_async_readahead,則預讀的第0個page(從0開始計數)設定
PageReadahead标記。
ra_submit -->__do_page_cache_readahead --> read_pages調用
mapping->a_ops->readpages或者mapping->a_ops->readpage依次讀入page_pool
連結清單中的page。
ra_submit送出io後就傳回,不會等待page變成PageUptodate。
*/
return ra_submit(ra, mapping, filp);
}
五、代碼待優化的地方
場景
generic_file_buffered_read -->page_cache_sync_readahead或page_cache_async_readahead --> ondemand_readahead --> ra_submit --> __do_page_cache_readahead 會把剛剛申請的一批page(用于預讀)加入到page cache中,如果讀失敗了,這些page并不會從page cache中清除,隻是标記成PageError。
問題
generic_file_buffered_read -->find_get_page到page,但不是PageUptodate狀态,則認為io錯誤導緻的問題,就通過mapping->a_ops->readpage重新讀取單個page資料。是以預讀發生io err後,後繼read不會批量讀入這些page了,隻會單個page讀,性能較差。
解決
對于這個問題,5.18核心版本已經修複。
作者:Vier
來源-微信公衆号:酷派技術團隊
出處:https://mp.weixin.qq.com/s/vYWWwnkxpyffrHlqAMdBlQ