1. 程式人生 > >核心是如何管理記憶體的&&頁面快取-記憶體與檔案的那些事

核心是如何管理記憶體的&&頁面快取-記憶體與檔案的那些事

轉: 核心是如何管理記憶體的

原文標題:How The Kernel Manages Your Memory

原文地址:http://duartes.org/gustavo/blog/

   [注:本人水平有限,只好挑一些國外高手的精彩文章翻譯一下。一來自己複習,二來與大家分享。]

       在仔細審視了程序的虛擬地址佈局之後,讓我們把目光轉向核心以及其管理使用者記憶體的機制。再次從gonzo圖示開始:

    Linux程序在核心中是由task_struct的例項來表示的,即程序描述符。task_struct的mm欄位指向記憶體描述符(memory descriptor),即mm_struct,一個程式的記憶體的執行期摘要。它儲存了上圖所示的記憶體段的起止位置,程序所使用的實體記憶體頁的

數量rss表示Resident Set Size),虛擬記憶體空間的使用量,以及其他資訊。我們還可以在記憶體描述符中找到用於管理程式記憶體的兩個重要結構:虛擬記憶體區域集合(the set of virtual memory areas)及頁表(page table)。Gonzo的記憶體區域如下圖所示:

    每一個虛擬記憶體區域(簡稱VMA)是一個連續的虛擬地址範圍;這些區域不會交疊。一個vm_area_struct的例項完備的描述了一個記憶體區域,包括它的起止地址,決定訪問許可權和行為的標誌位,還有vm_file欄位,用於指出被對映的檔案(如果有的話)。一個VMA如果沒有對映到檔案,則是匿名的

(anonymous)。除memory mapping段以外,上圖中的每一個記憶體段(如:堆,棧)都對應於一個單獨的VMA。這並不是強制要求,但在x86機器上經常如此。VMA並不關心它在哪一個段。

       一個程式的VMA同時以兩種形式儲存在它的記憶體描述符中:一個是按起始虛擬地址排列的連結串列,儲存在mmap欄位;另一個是紅黑樹,根節點儲存在mm_rb欄位。紅黑樹使得核心可以快速的查找出給定虛擬地址所屬的記憶體區域。當你讀取檔案/proc/pid_of_process/maps時,核心只須簡單的遍歷指定程序的VMA連結串列,並打印出每一項來即可。

    在Windows中,EPROCESS塊可以粗略的看成是task_struct和mm_struct的組合。VMA在Windows中的對應物時虛擬地址描述符(Virtual Address Descriptor),或簡稱VAD;它們儲存在平衡樹中(AVL tree)。你知道Windows和Linux最有趣的地方是什麼嗎?就是這些細小的不同點。

           4GB虛擬地址空間被分割為許多(page)。x86處理器在32位模式下所支援的頁面大小為4KB,2MB和4MB。Linux和Windows都使用4KB大小的頁面來對映使用者部分的虛擬地址空間。第0-4095位元組在第0頁,第4096-8191位元組在第1頁,以此類推。VMA的大小必須是頁面大小的整數倍。下圖是以4KB分頁的3GB使用者空間:

       處理器會依照頁表(page table)來將虛擬地址轉換到實體記憶體地址。每個程序都有屬於自己的一套頁表;一旦程序發生了切換,使用者空間的頁表也會隨之切換。Linux在記憶體描述符的pgd欄位儲存了一個指向程序頁表的指標。每一個虛擬記憶體頁在頁表中都有一個與之對應的頁表項(page table entry),簡稱PTE。它在普通的x86分頁機制下,是一個簡單的4位元組記錄,如下圖所示:

        Linux有一些函式可以用於讀取設定PTE中的每一個標誌。P位告訴處理器虛擬頁面是否存在於(present)實體記憶體中。如果是0,訪問這個頁將觸發頁故障(page fault)。記住,當這個位是0時,核心可以根據喜好,隨意的使用其餘的欄位。R/W標誌表示讀/寫;如果是0,頁面就是隻讀的。U/S標誌表示使用者/管理員;如果是0,則這個頁面只能被核心訪問。這些標誌用於實現只讀記憶體和保護核心空間。

        D位和A位表示資料髒(dirty)和訪問過(accessed)。髒表示頁面被執行過寫操作,訪問過表示頁面被讀或被寫過。這兩個標誌都是粘滯的:處理器只會將它們置位,之後必須由核心來清除。最後,PTE還儲存了對應該頁的起始實體記憶體地址,對齊於4KB邊界。PTE中的其他欄位我們改日再談,比如實體地址擴充套件(Physical Address Extension)。

    虛擬頁面是記憶體保護的最小單元,因為頁內的所有位元組都共享U/S和R/W標誌。然而,同樣的實體記憶體可以被對映到不同的頁面,甚至可以擁有不同的保護標誌。值得注意的是,在PTE中沒有對執行許可(execute permission)的設定。這就是為什麼經典的x86分頁可以執行位於stack上的程式碼,從而為黑客利用堆疊溢位提供了便利(使用return-to-libc和其他技術,甚至可以利用不可執行的堆疊)。PTE缺少不可執行(no-execute)標誌引出了一個影響更廣泛的事實:VMA中的各種許可標誌可能會也可能不會被明確的轉換為硬體保護。對此,核心可以盡力而為,但始終受到架構的限制。

       虛擬記憶體並不儲存任何東西,它只是將程式地址空間對映到底層的實體記憶體上,後者被處理器視為一整塊來訪問,稱作實體地址空間(physical address space)。對實體記憶體的操作還與匯流排有點聯絡,好在我們可以暫且忽略這些並假設實體地址範圍以位元組為單位遞增,從0到最大可用記憶體數。這個實體地址空間被核心分割為一個個頁幀(page frame)。處理器並不知道也不關心這些幀,然而它們對核心至關重要,因為頁幀是實體記憶體管理的最小單元。Linux和Windows在32位模式下,都使用4KB大小的頁幀;以一個擁有2GB RAM的機器為例:

    在Linux中,每一個頁幀都由一個描述符一些標誌所跟蹤。這些描述符合在一起,記錄了計算機內的全部實體記憶體;可以隨時知道每一個頁幀的準確狀態。實體記憶體是用buddy memory allocation技術來管理的,因此如果一個頁幀可被buddy 系統分配,則它就是可用的(free)。一個被分配了的頁幀可能是匿名的(anonymous),儲存著程式資料;也可能是頁緩衝的(page cache),儲存著一個檔案或塊裝置的資料。還有其他一些古怪的頁幀使用形式,但現在先不必考慮它們。Windows使用一個類似的頁幀編號(Page Frame Number簡稱PFN)資料庫來跟蹤實體記憶體。

       讓我們把虛擬地址區域,頁表項,頁幀放到一起,看看它們到底是怎麼工作的。下圖是一個使用者堆的例子:

       藍色矩形表示VMA範圍內的頁,箭頭表示頁表項將頁對映到頁幀上。一些虛擬頁並沒有箭頭;這意味著它們對應的PTE的存在位(Present flag)為0。形成這種情況的原因可能是這些頁還沒有被訪問過,或者它們的內容被系統換出了(swap out)。無論那種情況,對這些頁的訪問都會導致頁故障(page fault),即使它們處在VMA之內。VMA和頁表的不一致看起來令人奇怪,但實際經常如此。

       一個VMA就像是你的程式和核心之間的契約。你請求去做一些事情(如:記憶體分配,檔案對映等),核心說"行",並建立或更新適當的VMA。但它並非立刻就去完成請求,而是一直等到出現了頁故障才會真正去做。核心就是一個懶惰,騙人的敗類;這是虛擬記憶體管理的基本原則。它對大多數情況都適用,有些比較熟悉,有些令人驚訝,但這個規則就是這樣:VMA記錄了雙方商定做什麼,而PTE反映出懶惰的核心實際做了什麼。這兩個資料結構共同管理程式的記憶體;都扮演著解決頁故障,釋放記憶體,換出記憶體(swapping memory out)等等角色。讓我們看一個簡單的記憶體分配的例子:

       當程式通過brk()系統呼叫請求更多的記憶體時,核心只是簡單的更新堆的VMA,然後說搞好啦。其實此時並沒有頁幀被分配,新的頁也並沒有出現於物理記憶體中。一旦程式試圖訪問這些頁,處理器就會報告頁故障,並呼叫do_page_fault()。它會通過呼叫find_vma()去搜尋哪一個VMA含蓋了產生故障的虛擬地址。如果找到了,還會根據VMA上的訪問許可來比對檢查訪問請求(讀或寫)。如果沒有合適的VMA,也就是說記憶體訪問請求沒有與之對應的合同,程序就會被處以段錯誤(Segmentation Fault)的罰單。

    當一個VMA被找到後,核心必須處理這個故障,方式是察看PTE的內容以及VMA的型別。在我們的例子中,PTE顯示了該頁並不存在。事實上,我們的PTE是完全空白的(全為0),在Linux中意味著虛擬頁還沒有被對映。既然這是一個匿名的VMA,我們面對的就是一個純粹的RAM事務,必須由do_anonymous_page()處理,它會分配一個頁幀並生成一個PTE,將出故障的虛擬頁對映到那個剛剛分配的頁幀上。

       事情還可能有些不同。被換出的頁所對應的PTE,例如,它的Present標誌是0但並不是空白的。相反,它記錄了頁面內容在交換系統中的位置,這些內容必須從磁碟讀取出來並通過do_swap_page()載入到一個頁幀當中,這就是所謂的major fault。

       至此我們走完了"核心的使用者記憶體管理"之旅的前半程。在下一篇文章中,我們將把檔案的概念也混進來,從而建立一個記憶體基礎知識的完成畫面,並瞭解其對系統性能的影響。

參考:

轉: 頁面快取-記憶體與檔案的那些事

原文標題:Page Cache, the Affair Between Memory and Files

原文地址:http://duartes.org/gustavo/blog/

   [注:本人水平有限,只好挑一些國外高手的精彩文章翻譯一下。一來自己複習,二來與大家分享。]

   上次我們考察了核心如何為一個使用者程序管理虛擬記憶體,但是沒有涉及檔案及I/O。這次我們的討論將涵蓋非常重要且常被誤解的檔案與記憶體間關係的問題,以及它對系統性能的影響。

提到檔案,作業系統必須解決兩個重要的問題。首先是硬碟驅動器的存取速度緩慢得令人頭疼(相對於記憶體而言),尤其是磁碟的尋道效能。第二個是要滿足'一次性載入檔案內容到實體記憶體並在程式間共享'的需求。如果你使用程序瀏覽器翻看Windows程序,就會發現大約15MB的共享DLL被載入進了每一個程序。我目前的Windows系統就運行了100個程序,如果沒有共享機制,那將消耗大約1.5GB的實體記憶體僅僅用於存放公用DLL。這可不怎麼好。同樣的,幾乎所有的Linux程式都需要ld.so和libc,以及其它的公用函式庫。

令人愉快的是,這兩個問題可以被一石二鳥的解決:頁面快取(page cache),核心用它儲存與頁面同等大小的檔案資料塊。為了展示頁面快取,我需要祭出一個名叫render的Linux程式,它會開啟一個scene.dat檔案,每次讀取其中的512位元組,並將這些內容儲存到一個建立在堆上的記憶體塊中。首次的讀取是這樣的:

在讀取了12KB以後,render的堆以及相關的頁幀情況如下:

這看起來很簡單,但還有很多事情會發生。首先,即使這個程式只調用了常規的read函式,此時也會有三個 4KB的頁幀儲存在頁面快取當中,它們持有scene.dat的一部分資料。儘管有時這令人驚訝,但的確所有的常規檔案I/O都是通過頁面快取來進行的。在x86 Linux裡,核心將檔案看作是4KB大小的資料塊的序列。即使你只從檔案讀取一個位元組,包含此位元組的整個4KB資料塊都會被讀取,並放入到頁面快取當中。這樣做是有道理的,因為磁碟的持續性資料吞吐量很不錯,而且一般說來,程式對於檔案中某區域的讀取都不止幾個位元組。頁面快取知道每一個4KB資料塊在檔案中的對應位置,如上圖所示的#0, #1等等。與Linux的頁面快取類似,Windows使用256KB的views。

不幸的是,在一個普通的檔案讀取操作中,核心必須複製頁面快取的內容到一個使用者緩衝區中,這不僅消耗CPU時間,傷害了CPU cache的效能,還因為儲存了重複資訊而浪費實體記憶體。如上面每張圖所示,scene.dat的內容被儲存了兩遍,而且程式的每個例項都會儲存一份。至此,我們緩和了磁碟延遲的問題,但卻在其餘的每個問題上慘敗。記憶體對映檔案(memory-mapped files)將引領我們走出混亂:

當你使用檔案對映的時候,核心將你的程式的虛擬記憶體頁直接對映到頁面快取上。這將導致一個顯著的效能提升:Windows系統程式設計》指出常規的檔案讀取操作執行時效能改善30%以上;Unix環境高階程式設計》指出類似的情況也發生在Linux和Solaris系統上。你還可能因此而節省下大量的實體記憶體,這依賴於你的程式的具體情況。

和以前一樣,提到效能,實際測量才是王道,但是記憶體對映的確值得被程式設計師們放入工具箱。相關的API也很漂亮,它提供了像訪問記憶體中的位元組一樣的方式來訪問一個檔案,不需要你多操心,也不犧牲程式碼的可讀性。回憶一下地址空間、還有那個在Unix類系統上關於mmap的實驗,Windows下的CreateFileMapping及其在高階語言中的各種可用封裝。當你對映一個檔案時,它的內容並不是立刻就被全部放入記憶體的,而是依賴頁故障(page fault)按需讀取。在獲取了一個包含所需的檔案資料的頁幀後,對應的故障處理函式會將你的虛擬記憶體頁對映到頁面快取上。如果所需內容不在快取當中,此過程還將包含磁碟I/O操作。

現在給你出一個流行的測試題。想象一下,在最後一個render程式的例項退出之時,那些儲存了scene.dat的頁面快取會被立刻清理嗎?人們通常會這樣認為,但這是個壞主意。如果你仔細想想,我們經常會在一個程式中建立一個檔案,退出,緊接著在第二個程式中使用這個檔案。頁面快取必須能處理此類情況。如果你再多想想,核心何必總是要捨棄頁面快取中的內容呢?記住,磁碟比RAM慢5個數量級,因此一個頁面快取的命中(hit)就意味著巨大的勝利。只要還有足夠的空閒實體記憶體,快取就應該儘可能保持滿狀態。所以它與特定的程序並不相關,而是一個系統級的資源。如果你一週前執行過render,而此時scene.dat還在快取當中,那真令人高興。這就是為什麼核心快取的大小會穩步增加,直到快取上限。這並非因為作業系統是破爛貨,吞噬你的RAM,事實上這是種好的行為,反而釋放實體記憶體才是一種浪費。快取要利用得越充分越好。

由於使用了頁面快取體系結構,當一個程式呼叫write()時,相關的位元組被簡單的複製到頁面快取中,並且將頁面標記為髒的(dirty)。磁碟I/O一般不會立刻發生,因此你的程式的執行不會被打斷去等待磁碟裝置。這樣做的缺點是,如果此時計算機宕機,那麼你寫入的資料將不會被記錄下來。因此重要的檔案,比如資料庫事務記錄必須被fsync() (但是還要小心磁碟控制器的快取)。另一方面,讀取操作一般會打斷你的程式直到準備好所需的資料。核心通常採用積極載入(eager loading)的方式來緩解這個問題。以提前讀取(read ahead)為例,核心會預先載入一些頁到頁面快取,並期待你的讀取操作。通過提示系統即將對檔案進行的是順序還是隨機讀取操作(參看madvise(), readahead()Windows快取提示),你可以幫助核心調整它的積極載入行為。Linux的確會對記憶體對映檔案進行預取,但我不太確定Windows是否也如此。最後需要一提的是,你還可以通過在Linux中使用O_DIRECT或在Windows中使用NO_BUFFERING來繞過頁面快取,有些資料庫軟體就是這麼做的。

   一個檔案對映可以是私有的(private)或共享的(shared)。這裡的區別只有在更改(update)記憶體中的內容時才會顯現出來:在私有對映中,更改並不會被提交到磁碟或對其他程序可見,而這在共享的對映中就會發生。核心使用寫時拷貝(copy on write)技術,通過頁表項(page table entries),實現私有對映。在下面的例子中,render和另一個叫render3d的程式(我是不是很有創意?)同時私有映射了scene.dat。隨後render改寫了對映到此檔案的虛擬記憶體區域:

上圖所示的只讀的頁表項並不意 味著對映是隻讀的,它們只是核心耍的小把戲,用於共享實體記憶體直到可能的最後一刻。你會發現'私有'一詞是多麼的不恰當,你只需記住它只在資料發生更改時 起作用。此設計所帶來的一個結果就是,一個以私有方式對映檔案的虛擬記憶體頁可以觀察到其他程序對此檔案的改動,只要之前對這個記憶體頁進行的都是讀取操作。 一旦發生過寫時拷貝,就不會再觀察到其他程序對此檔案的改動了。此行為不是核心提供的,而是在x86系統上就會如此。另外,從API的角度來說,這也是合理的。與此相反,共享對映只是簡單的對映到頁面快取,僅此而已。對頁面的所有更改操作對其他程序都可見,而且最終會執行磁碟操作。最後,如果此共享對映是隻讀的,那麼頁故障將觸發段錯誤(segmentation fault)而不是寫時拷貝。

   被動態載入的函式庫通過檔案對映機制放入到你的程式的地址空間中。這裡沒有任何特別之處,同樣是採用私有檔案對映,跟提供給你呼叫的常規API別無二致。下面的例子展示了兩個執行中的render程式的一部分地址空間,還有實體記憶體。它將我們之前看到的概念都聯絡在了一起。

至此我們完成了記憶體基礎知識的三部曲系列。我希望這個系列對您有用,並在您頭腦中建立一個好的作業系統模型。

參考: