頁面緩存:內存和文件之間的那些事
上一篇文章中我們學習了內核怎麼為一個用戶進程 管理虛擬內存,而沒有提及文件和 I/O。這一篇文章我們將專門去講這個重要的主題 —— 頁面緩存。文件和內存之間的關係常常很不好去理解,而它們對系統性能的影響卻是非常大的。
在面對文件時,有兩個很重要的問題需要操作系統去解決。第一個是相對內存而言,慢的讓人發狂的硬碟驅動器,尤其是磁碟尋道。第二個是需要將文件內容一次性地載入到物理內存中,以便程序間共享文件內容。如果你在 Windows 中使用 進程瀏覽器 去查看它的進程,你將會看到每個進程中載入了大約 ~15MB 的公共 DLL。我的 Windows 機器上現在大約運行著 100 個進程,因此,如果不共享的話,僅這些公共的 DLL 就要使用高達 ~1.5 GB 的物理內存。如果是那樣的話,那就太糟糕了。同樣的,幾乎所有的 Linux 進程都需要 ld.so 和 libc,加上其它的公共庫,它們佔用的內存數量也不是一個小數目。
幸運的是,這兩個問題都用一個辦法解決了:頁面緩存 —— 保存在內存中的頁面大小的文件塊。為了用圖去說明頁面緩存,我捏造出一個名為 render
的 Linux 程序,它打開了文件 scene.dat
,並且一次讀取 512 位元組,並將文件內容存儲到一個分配到堆中的塊上。第一次讀取的過程如下:
render
請求scene.dat
從位移 0 開始的 512 位元組。- 內核搜尋頁面緩存中
scene.dat
的 4kb 塊,以滿足該請求。假設該數據沒有緩存。 - 內核分配頁面幀,初始化 I/O 請求,將
scend.dat
從位移 0 開始的 4kb 複製到分配的頁面幀。 - 內核從頁面緩存複製請求的 512 位元組到用戶緩衝區,系統調用
read()
結束。
讀取完 12KB 的文件內容以後,render
程序的堆和相關的頁面幀如下圖所示:
它看起來很簡單,其實這一過程做了很多的事情。首先,雖然這個程序使用了普通的讀取(read
)調用,但是,已經有三個 4KB 的頁面幀將文件 scene.dat 的一部分內容保存在了頁面緩存中。雖然有時讓人覺得很驚奇,但是,普通的文件 I/O 就是這樣通過頁面緩存來進行的。在 x86 架構的 Linux 中,內核將文件認為是一系列的 4KB 大小的塊。如果你從文件中讀取單個位元組,包含這個位元組的整個 4KB 塊將被從磁碟中讀入到頁面緩存中。這是可以理解的,因為磁碟通常是持續吞吐的,並且程序一般也不會從磁碟區域僅僅讀取幾個位元組。頁面緩存知道文件中的每個 4KB 塊的位置,在上圖中用 #0
、#1
等等來描述。Windows 使用 256KB 大小的 視圖 ,類似於 Linux 的頁面緩存中的 頁面 。
不幸的是,在一個普通的文件讀取中,內核必須拷貝頁面緩存中的內容到用戶緩衝區中,它不僅花費 CPU 時間和影響 CPU 緩存,在複製數據時也浪費物理內存。如前面的圖示,scene.dat
的內存被存儲了兩次,並且,程序中的每個實例都用另外的時間去存儲內容。我們雖然解決了從磁碟中讀取文件緩慢的問題,但是在其它的方面帶來了更痛苦的問題。內存映射文件是解決這種痛苦的一個方法:
當你使用文件映射時,內核直接在頁面緩存上映射你的程序的虛擬頁面。這樣可以顯著提升性能:Windows 系統編程 報告指出,在相關的普通文件讀取上運行時性能提升多達 30% ,在 Unix 環境中的高級編程 的報告中,文件映射在 Linux 和 Solaris 也有類似的效果。這取決於你的應用程序類型的不同,通過使用文件映射,可以節約大量的物理內存。
對高性能的追求是永恆不變的目標,測量是很重要的事情,內存映射應該是程序員始終要使用的工具。這個 API 提供了非常好用的實現方式,它允許你在內存中按位元組去訪問一個文件,而不需要為了這種好處而犧牲代碼可讀性。在一個類 Unix 的系統中,可以使用 mmap 查看你的 地址空間,在 Windows 中,可以使用 CreateFileMapping,或者在高級編程語言中還有更多的可用封裝。當你映射一個文件內容時,它並不是一次性將全部內容都映射到內存中,而是通過 頁面故障 來按需映射的。在 獲取 需要的文件內容的頁面幀後,頁面故障句柄 映射你的虛擬頁面 到頁面緩存上。如果一開始文件內容沒有緩存,這還將涉及到磁碟 I/O。
現在出現一個突發的狀況,假設我們的 render
程序的最後一個實例退出了。在頁面緩存中保存著 scene.dat
內容的頁面要立刻釋放掉嗎?人們通常會如此考慮,但是,那樣做並不是個好主意。你應該想到,我們經常在一個程序中創建一個文件,退出程序,然後,在第二個程序去使用這個文件。頁面緩存正好可以處理這種情況。如果考慮更多的情況,內核為什麼要清除頁面緩存的內容?請記住,磁碟讀取的速度要慢於內存 5 個數量級,因此,命中一個頁面緩存是一件有非常大收益的事情。因此,只要有足夠大的物理內存,緩存就應該保持全滿。並且,這一原則適用於所有的進程。如果你現在運行 render
一周後, scene.dat
的內容還在緩存中,那麼應該恭喜你!這就是什麼內核緩存越來越大,直至達到最大限制的原因。它並不是因為操作系統設計的太「垃圾」而浪費你的內存,其實這是一個非常好的行為,因為,釋放物理內存才是一種「浪費」。(LCTT 譯註:釋放物理內存會導致頁面緩存被清除,下次運行程序需要的相關數據,需要再次從磁碟上進行讀取,會「浪費」 CPU 和 I/O 資源)最好的做法是儘可能多的使用緩存。
由於頁面緩存架構的原因,當程序調用 write() 時,位元組只是被簡單地拷貝到頁面緩存中,並將這個頁面標記為「臟」頁面。磁碟 I/O 通常並不會立即發生,因此,你的程序並不會被阻塞在等待磁碟寫入上。副作用是,如果這時候發生了電腦死機,你的寫入將不會完成,因此,對於至關重要的文件,像資料庫事務日誌,要求必須進行 fsync()(仍然還需要去擔心磁碟控制器的緩存失敗問題),另一方面,讀取將被你的程序阻塞,直到數據可用為止。內核採取預載入的方式來緩解這個矛盾,它一般提前預讀取幾個頁面並將它載入到頁面緩存中,以備你後來的讀取。在你計划進行一個順序或者隨機讀取時(請查看 madvise()、readahead()、Windows 緩存提示 ),你可以通過 提示 幫助內核去調整這個預載入行為。Linux 會對內存映射的文件進行 預讀取,但是我不確定 Windows 的行為。當然,在 Linux 中它可能會使用 O_DIRECT 跳過預讀取,或者,在 Windows 中使用 NO_BUFFERING 去跳過預讀,一些資料庫軟體就經常這麼做。
一個文件映射可以是私有的,也可以是共享的。當然,這只是針對內存中內容的更新而言:在一個私有的內存映射上,更新並不會提交到磁碟或者被其它進程可見,然而,共享的內存映射,則正好相反,它的任何更新都會提交到磁碟上,並且對其它的進程可見。內核使用 寫時複製 (CoW)機制,這是通過 頁面表條目 (PTE)來實現這種私有的映射。在下面的例子中,render
和另一個被稱為 render3d
的程序都私有映射到 scene.dat
上。然後 render
去寫入映射的文件的虛擬內存區域:
- 兩個程序私有地映射
scene.dat
,內核誤導它們並將它們映射到頁面緩存,但是使該頁面表條目只讀。 render
試圖寫入到映射scene.dat
的虛擬頁面,處理器發生頁面故障。- 內核分配頁面幀,複製
scene.dat
的第二塊內容到其中,並映射故障的頁面到新的頁面幀。 - 繼續執行。程序就當做什麼都沒發生。
上面展示的只讀頁面表條目並不意味著映射是只讀的,它只是內核的一個用於共享物理內存的技巧,直到儘可能的最後一刻之前。你可以認為「私有」一詞用的有點不太恰當,你只需要記住,這個「私有」僅用於更新的情況。這種設計的重要性在於,要想看到被映射的文件的變化,其它程序只能讀取它的虛擬頁面。一旦「寫時複製」發生,從其它地方是看不到這種變化的。但是,內核並不能保證這種行為,因為它是在 x86 中實現的,從 API 的角度來看,這是有意義的。相比之下,一個共享的映射只是將它簡單地映射到頁面緩存上。更新會被所有的進程看到並被寫入到磁碟上。最終,如果上面的映射是只讀的,頁面故障將觸發一個內存段失敗而不是寫到一個副本。
動態載入庫是通過文件映射融入到你的程序的地址空間中的。這沒有什麼可奇怪的,它通過普通的 API 為你提供與私有文件映射相同的效果。下面的示例展示了映射文件的 render
程序的兩個實例運行的地址空間的一部分,以及物理內存,嘗試將我們看到的許多概念綜合到一起。
這是內存架構系列的第三部分的結論。我希望這個系列文章對你有幫助,對理解操作系統的這些主題提供一個很好的思維模型。
via:https://manybutfinite.com/post/page-cache-the-affair-between-memory-and-files/
作者:Gustavo Duarte 譯者:qhwdw 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive