Buffer Cache
Buffer cache是指磁盤設(shè)備上的raw data(指不以文件的方式組織)以block為單位在內(nèi)存中的緩存,早在1975年發(fā)布的Unix第六版就有了它的雛形,Linux最開始也只有buffer cache。事實上,page cache是1995年發(fā)行的1.3.50版本中才引入的。不同于buffer cache以磁盤的block為單位,page cache是以內(nèi)存常用的page為單位的,位于虛擬文件系統(tǒng)層(VFS)與具體的文件系統(tǒng)之間。
在很長一段時間內(nèi),buffer cache和page cache在Linux中都是共存的,但是這會存在一個問題:一個磁盤block上的數(shù)據(jù),可能既被buffer cache緩存了,又因為它是基于磁盤建立的文件的一部分,也被page cache緩存了,這時一份數(shù)據(jù)在內(nèi)存里就有兩份拷貝,這顯然是對物理內(nèi)存的一種浪費。更麻煩的是,內(nèi)核還要負(fù)責(zé)保持這份數(shù)據(jù)在buffer cache和page cache中的一致性。所以,現(xiàn)在Linux中已經(jīng)基本不再使用buffer cache了。
讀寫操作
CPU如果要訪問外部磁盤上的文件,需要首先將這些文件的內(nèi)容拷貝到內(nèi)存中,由于硬件的限制,從磁盤到內(nèi)存的數(shù)據(jù)傳輸速度是很慢的,如果現(xiàn)在物理內(nèi)存有空余,干嘛不用這些空閑內(nèi)存來緩存一些磁盤的文件內(nèi)容呢,這部分用作緩存磁盤文件的內(nèi)存就叫做page cache。
用戶進(jìn)程啟動read()系統(tǒng)調(diào)用后,內(nèi)核會首先查看page cache里有沒有用戶要讀取的文件內(nèi)容,如果有(cache hit),那就直接讀取,沒有的話(cache miss)再啟動I/O操作從磁盤上讀取,然后放到page cache中,下次再訪問這部分內(nèi)容的時候,就又可以cache hit,不用忍受磁盤的龜速了(相比內(nèi)存慢幾個數(shù)量級)。
和CPU里的硬件cache是不是很像?兩者其實都是利用的局部性原理,只不過硬件cache是CPU緩存內(nèi)存的數(shù)據(jù),而page cache是內(nèi)存緩存磁盤的數(shù)據(jù),這也體現(xiàn)了memory hierarchy分級的思想。
相對于磁盤,內(nèi)存的容量還是很有限的,所以沒必要緩存整個文件,只需要當(dāng)文件的某部分內(nèi)容真正被訪問到時,再將這部分內(nèi)容調(diào)入內(nèi)存緩存起來就可以了,這種方式叫做demand paging(按需調(diào)頁),把對需求的滿足延遲到最后一刻,很懶很實用。
page cache中那么多的page frames,怎么管理和查找呢?這就要說到之前的文章提到的address_space結(jié)構(gòu)體,一個address_space管理了一個文件在內(nèi)存中緩存的所有pages。
這篇文章講到,
mmap映射可以將文件的一部分區(qū)域映射到虛擬地址空間的一個VMA,如果有5個進(jìn)程,每個進(jìn)程mmap同一個文件兩次(文件的兩個不同部分),那么就有10個VMAs,但address_space只有一個。
每個進(jìn)程打開一個文件的時候,都會生成一個表示這個文件的struct file,但是文件的struct inode只有一個,inode才是文件的唯一標(biāo)識,指向address_space的指針就是內(nèi)嵌在inode結(jié)構(gòu)體中的。在page cache中,每個page都有對應(yīng)的文件,這個文件就是這個page的owner,address_space將屬于同一owner的pages聯(lián)系起來,將這些pages的操作方法與文件所屬的文件系統(tǒng)聯(lián)系起來。
來看下address_space結(jié)構(gòu)體具體是怎樣構(gòu)成的:
struct address_space {
struct inode *host; /* Owner, either the inode or the block_device */
struct radix_tree_root page_tree; /* Cached pages */
spinlock_t tree_lock; /* page_tree lock */
struct prio_tree_root i_mmap; /* Tree of private and shared mappings */
struct spinlock_t i_mmap_lock; /* Protects @i_mmap */
unsigned long nrpages; /* total number of pages */
struct address_space_operations *a_ops; /* operations table */
...
}
- host指向address_space對應(yīng)文件的inode。
- address_space中的page cache之前一直是用radix tree的數(shù)據(jù)結(jié)構(gòu)組織的,tree_lock是訪問這個radix tree的spinlcok(現(xiàn)在已換成xarray)。
- i_mmap是管理address_space所屬文件的多個VMAs映射的,用priority search tree的數(shù)據(jù)結(jié)構(gòu)組織,i_mmap_lock是訪問這個priority search tree的spinlcok。
- nrpages是address_space中含有的page frames的總數(shù)。
a_ops是關(guān)于page cache如何與磁盤(backing store)交互的一系列operations。
address_space中的a_ops定義了關(guān)于page和磁盤文件交互的一系列操作,它是由struct address_space_operations包含的一組函數(shù)指針組成的,其中最重要的就是readpage()和writepage()。
struct address_space_operations {
int (*writepage)(struct page *page, struct writeback_control *wbc);
int (*readpage)(struct file *, struct page *);
/* Set a page dirty. Return true if this dirtied it */
int (*set_page_dirty)(struct page *page);
int (*releasepage) (struct page *, gfp_t);
void (*freepage)(struct page *);
...
}
之所以使用函數(shù)指針的方式,是因為不同的文件系統(tǒng)對此的實現(xiàn)會有所不同,比如在ext3中,page-->mapping-->a_ops-->writepage調(diào)用的就是ext3_writeback_writepage()。
struct address_space_operations ext3_writeback_aops = {
.readpage = ext3_readpage,
.writepage = ext3_writeback_writepage,
.releasepage = ext3_releasepage,
...
}
readpage()會阻塞直到內(nèi)核往用戶buffer里填充滿了請求的字節(jié)數(shù),如果遇到page cache miss,那要等的時間就比較長了(取決于磁盤I/O的速度)。既然訪問一次磁盤那么不容易,那干嘛不一次多預(yù)讀幾個page大小的內(nèi)容過來呢?是否采用預(yù)讀(readahead)要看對文件的訪問是連續(xù)的還是隨機的,如果是連續(xù)訪問,自然會對性能帶來提升,如果是隨機訪問,預(yù)讀則是既浪費磁盤I/O帶寬,又浪費物理內(nèi)存。
那內(nèi)核怎么能預(yù)知進(jìn)程接下來對文件的訪問是不是連續(xù)的呢?看起來只有進(jìn)程主動告知了,可以采用的方法有madvise()和posix_favise(),前者主要配合基于文件的mmap映射使用。advise如果是NORMAL,那內(nèi)核會做適量的預(yù)讀;如果是RANDOM,那內(nèi)核就不做預(yù)讀;如果是SEQUENTIAL,那內(nèi)核會做大量的預(yù)讀。
預(yù)讀的page數(shù)被稱作預(yù)讀窗口(有點像TCP里的滑動窗口),其大小直接影響預(yù)讀的優(yōu)化效果。進(jìn)程的advise畢竟只是建議,內(nèi)核在運行過程中會動態(tài)地調(diào)節(jié)預(yù)讀窗口的大小,如果內(nèi)核發(fā)現(xiàn)一個進(jìn)程一直使用預(yù)讀的數(shù)據(jù),它就會增加預(yù)讀窗口,它的目標(biāo)(或者說KPI吧)就是保證在預(yù)讀窗口中盡可能高的命中率(也就是預(yù)讀的內(nèi)容后續(xù)會被實際使用到)。
Page cache緩存最近使用的磁盤數(shù)據(jù),利用的是“時間局部性”原理,依據(jù)是最近訪問到的數(shù)據(jù)很可能接下來再訪問到,而預(yù)讀磁盤的數(shù)據(jù)放入page cache,利用的是“空間局部性”原理,依據(jù)是數(shù)據(jù)往往是連續(xù)訪問的。
Page cache這種內(nèi)核提供的緩存機制并不是強制使用的,如果進(jìn)程在open()一個文件的時候指定flags為O_DIRECT,那進(jìn)程和這個文件的數(shù)據(jù)交互就直接在用戶提供的buffer和磁盤之間進(jìn)行,page cache就被bypass了,借用硬件cache的術(shù)語就是uncachable,這種文件訪問方式被稱為direct I/O,適用于用戶使用自己設(shè)備提供的緩存機制的場景,比如某些數(shù)據(jù)庫應(yīng)用。
回寫與同步
Page cache畢竟是為了提高性能占用的物理內(nèi)存,隨著越來越多的磁盤數(shù)據(jù)被緩存到內(nèi)存中,page cache也變得越來越大,如果一些重要的任務(wù)需要被page cache占用的內(nèi)存,內(nèi)核將回收page cache以支持這些需求。
以elf文件為例,一個elf鏡像文件通常由text(code)和data組成,這兩部分的屬性是不同的,text是只讀的,調(diào)入內(nèi)存后不會被修改,page cache里的內(nèi)容和磁盤上的文件內(nèi)容始終是一致的,回收的時候只要將對應(yīng)的所有PTEs的P位和PFN清0,直接丟棄就可以了, 不需要和磁盤文件同步,這種page cache被稱為discardable的。
而data是可讀寫的,當(dāng)data對應(yīng)的page被修改后,硬件會將PTE中的Dirty位置1(參考這篇文章),Linux通過SetPageDirty(page)設(shè)置這個page對應(yīng)的struct page的flags為PG_Dirty(參考這篇文章),而后將PTE中的Dirty位清0。
在之后的某個時間點,這些修改過的page里的內(nèi)容需要同步到外部的磁盤文件,這一過程就是page write back,和硬件cache的write back原理是一樣的,區(qū)別僅在于CPU的cache是由硬件維護(hù)一致性,而page cache需要由軟件來維護(hù)一致性,這種page cache被稱為syncable的。
那什么時候才會觸發(fā)page的write back呢?分下面幾種情況:
- 從空間的層面,當(dāng)系統(tǒng)中"dirty"的內(nèi)存大于某個閾值時。該閾值以在總共的“可用內(nèi)存”中的占比"dirty_background_ratio"(默認(rèn)為10%)或者絕對的字節(jié)數(shù)"dirty_background_bytes"給出,誰最后被寫入就以誰為準(zhǔn),另一個的值隨即變?yōu)?(代表失效)。這里所謂的“可用內(nèi)存”,包括了free pages和reclaimable pages。
此外,還有"dirty_ratio"(默認(rèn)為20%)和"dirty_bytes",它們的意思是當(dāng)"dirty"的內(nèi)存達(dá)到這個數(shù)量(屋里太臟),進(jìn)程自己都看不過去了,寧愿停下手頭的write操作(被阻塞),先去把這些"dirty"的writeback了(把屋里打掃干凈)。而如果"dirty"的程度介于這個值和"background"的值之間(10% - 20%),就交給后面要介紹的專門負(fù)責(zé)writeback的background線程去做就好了(專職的清潔工)。
從時間的層面,即周期性的掃描(掃描間隔用dirty_writeback_ interval表示,以毫秒為單位),發(fā)現(xiàn)存在最近一次更新時間超過某個閾值的pages(該閾值用dirty_expire_interval表示, 以毫秒為單位)。
用戶主動發(fā)起sync()/msync()/fsync()調(diào)用時。
可通過/proc/sys/vm文件夾查看或修改以上提到的幾個參數(shù):

centisecs是0.01s,因此上圖所示系統(tǒng)的的dirty_background_ratio是10%,dirty_writeback_ interval是5s,dirty_expire_interval是30s。
來對比下硬件cache的write back機制。對于硬件cache,write back會在兩種情況觸發(fā):
- 內(nèi)存有新的內(nèi)容需要換入cache時,替換掉一個老的cache line。你說為什么page cache不也這樣操作,而是要周期性的掃描呢?
替換掉一個cache line對CPU來說是很容易的,直接靠硬件電路完成,而替換page cache的操作本身也是需要消耗內(nèi)存的(比如函數(shù)調(diào)用的堆棧開銷),如果這個外部backing store是個網(wǎng)絡(luò)上的設(shè)備,那么還需要先建立socket之類的,才能通過網(wǎng)絡(luò)傳輸完成write back,那這內(nèi)存開銷就更大了。所以啊,對于page cache,必須未雨綢繆,不能等內(nèi)存都快耗光了才來write back。
執(zhí)行線程
2.4內(nèi)核中用的是一個叫bdflush的線程來專門負(fù)責(zé)writeback操作,因為磁盤I/O操作很慢,而現(xiàn)代系統(tǒng)通常具備多個塊設(shè)備(比如多個disk spindles),如果bdflush在其中一個塊設(shè)備上等待I/O操作的完成,可能會需要很長的時間,此時其他塊設(shè)備還閑著呢,這時單線程模式的bdflush就成為了影響性能的瓶頸。而且,bdflush是沒有周期掃描功能的,因此它需要配合kupdated線程一起使用。

于是在2.6內(nèi)核中,bdflush和它的好搭檔kupdated一起被pdflush(page dirty flush)取代了。 pdflush是一組線程,根據(jù)塊設(shè)備的I/O負(fù)載情況,數(shù)量從最少2個到最多8個不等。如果1秒內(nèi)都沒有空閑的pdflush線程可用,內(nèi)核將創(chuàng)建一個新的pdflush線程,反之,如果某個pdflush線程的空閑時間已經(jīng)超過1秒,則該線程將被銷毀。一個塊設(shè)備可能有多個可以傳輸數(shù)據(jù)的隊列,為了避免在隊列上的擁塞(congestion),pdflush線程會動態(tài)的選擇系統(tǒng)中相對空閑的隊列。

這種方法在理論上是很優(yōu)秀的,然而現(xiàn)實的情況是外部I/O和CPU的速度差異巨大,但I(xiàn)/O系統(tǒng)的其他部分并沒有都使用擁塞控制,因此pdflush單獨使用復(fù)雜的擁塞算法的效果并不明顯,可以說是“獨木難支”。于是在更后來的內(nèi)核實現(xiàn)中(2.6.32版本),干脆化繁為簡,直接一個塊設(shè)備對應(yīng)一個thread,這種內(nèi)核線程被稱為flusher threads。

無論是內(nèi)核周期性掃描,還是用戶手動觸發(fā),flusher threads的write back都是間隔一段時間才進(jìn)行的,如果在這段時間內(nèi)系統(tǒng)掉電了(power failure),那還沒來得及write back的數(shù)據(jù)修改就面臨丟失的風(fēng)險,這是page cache機制存在的一個缺點。
前面介紹的O_DIRECT設(shè)置并不能解決這個問題,O_DIRECT只是繞過了page cache,但它并不等待數(shù)據(jù)真正寫到了磁盤上。open()中flags參數(shù)使用O_SYNC才能保證writepage()會等到數(shù)據(jù)可靠的寫入磁盤后再返回,適用于某些不容許數(shù)據(jù)丟失的關(guān)鍵應(yīng)用。O_SYNC模式下可以使用或者不使用page cache.如果使用page cache,則相當(dāng)于硬件cache的write through機制。