linux內存管理-反向映射

反向映射的需求

正向映射是通過虛擬地址根據頁表找到物理內存,反向映射就是通過物理地址找到哪些虛擬地址使用它。
什麼時候需要進行反向映射呢?在頁面回收的時候,在還沒有修改完所有引用該頁幀的頁表項之前是不可以將頁幀swap到硬盤上。沒有修改頁表項但是物理頁已經swap out了並且再次分配給其他申請者了,此時再次訪問那訪問的可能是其他上下文的數據,如果僅僅是髒數據也還好,但是如果訪問到的是內核的數據或者其他進程的數據,這個問題就有點嚴重了。所以在回收頁面的時候必須更改頁表項,再次訪問的時候能夠識別這種場景,按需重新加載數據。

下面根據LWN上相關的news來看一下反向映射的發展歷史

2.4內核上的反向映射

Virtual Memory I: the problem
https://lwn.net/Articles/75198/
32bit平臺上它的尋址決定了地址範圍0-4G,雖然可以擴展指令來尋址更多地址空間,但是那樣搞會極其影響性能和兼容性,所以基本沒人這樣搞。linux中有地址空間隔離的功能,也就是虛擬地址和物理地址,都是4G大小。虛擬地址空間一般是進行3:1劃分,其中0-3G給用戶空間使用,3-4G給內核空間使用,所有用戶進程共享同一個內核空間,這樣在內核中同一個進程不同的上下文不必切換頁表,也就是lazy TLB,這樣會減少很多的TLB miss。

開啓MMU之後,內核需要創建相關的頁表來訪問物理內存。默認的虛擬地址空間劃分3GB/1GB,其中一部分內核地址需要留給內核代碼段,數據段,vmalloc使用,所以內核最多能直接訪問的不超過1G。這也是爲什麼32平臺上連1G內核都不能直接使用的原因。在1999年,linus說:32位linux永遠不會,也不需要支持大於2GB的內存,沒啥好商量的。

但是整個行業的趨向仍然是支持越來越大的內存,芯片廠商在硬件上增加了尋址擴展功能能夠訪問超過4GB的內存,但是內核中的軟件限制仍然是沒有改變的。不過很快Linus認識到大內存是趨勢,所以在2.3內核上允許了大內存支持,不過這樣搞有額外的消耗和限制。

在32bit系統上,物理內存被分爲high和low(低端)兩部分。Low(低端)內存是可以直接映射到內核地址空間,並且是永久映射的,在任意時間都可以直接通過內核空間指針訪問。而高端內存沒有直接的映射,在需要使用的時候需要顯式的設置映射關係到內核地址空間,不再使用的時候解除映射。這個操作代價比較高,而且在同一時間使用的高端內存頁是有限制的。

內核自己的數據結構必須放在低端內存中,非永久映射的對象都不能掛到鏈表上,因爲它的虛擬地址是多變的。高端內存經常用來給進程或者一些內核的task使用,例如IO操作中使用的buffer,內核自身使用的必須是位於低端內存。

一些32bit平臺上可以尋址64GB的物理內存,但是linux內核處理這種場景仍然比較低效,之後的問題是在大內存系統上非常容易消耗光低端內存:內存變大,它需要更多的內核數據管理結構,尤其是struct page數組,每一個物理頁幀都需要一個page元數據,這玩意很容佔幾百M的低端內存。

在2.4版本中,內核通過掃描所有的進程頁表項,每次掃描一個進程找到引用該頁的頁表項,如果找到了所有的項就可以將頁幀刷入到磁盤中。

有一些用戶想要在32bit系統上支持32GB或者更多的內存,所以Linux企業發行版都會在這方面做出嘗試。一個是大佬Ingo Molnar寫的 4G/4G patch ,這個patch分開了內核和用戶地址空間,允許用戶進程訪問4G的虛擬地址空間,內核可以直接使用的低端內存擴展到了4G。不過這樣有個缺點,內核和用戶進程各自都有自己的頁表,所以每次系統調用進入和退出都需要flush TLB,性能下降的很厲害,達到了30%。這樣可以讓一些大內存機器work,Red Hat就發佈了這種企業版。
它雖然擴展了內核對大內存的兼容性,但是並不是很流行,被認爲是一個ugly的解決方案,沒人接受這種性能下降。所以需要其他方案來支持大內存,一直到2.6開發階段,內核開發者還是不願意爲了一部分土豪用戶的需求來強制要求32bit系統做他本來做不了的事情,Linus對此一錘定音,要想大內存等64bit系統吧。

第一代反向映射:直接反向映射

在2.5版本中第一次添加反向映射功能,用來解決找到所有引用某個物理頁幀的所有頁表項問題,最典型的就是swap out需要修改對應的所有頁表內容,在還沒有修改完所有引用該頁幀的頁表項之前是不可以將頁幀swap到硬盤上。

創建了一個新的數據結構來簡化這個過程,它相當直接,爲系統中每個頁幀(struct page)創建一個反向映射項數組鏈表,包括匿名頁和文件映射頁,裏面包含所有引用該頁的頁表映射項地址。

struct page {
    union {
        struct pte_chain *chain;/* Reverse pte mapping pointer */
        pte_addr_t direct;   //不共享的頁面直接在這保存pte地址,不需要遍歷pte_chain                                                                                                                  
    } pte;
}
/*  
 * next_and_idx encodes both the address of the next pte_chain and the
 * offset of the highest-index used pte in ptes[].
 */
這個結構佔據cache line大小,地址是cache line對齊的,所以低位可以用來標識ptes[]數組中的起始index,節省點循環時間
struct pte_chain {
    unsigned long next_and_idx;                                                                                                            
    pte_addr_t ptes[NRPTE];
} ____cacheline_aligned;

不共享的頁面可以使用direct直接指向pte entry就OK了。頁面共享時,chain則會指向一個struct pte_chain的鏈表,只需要遍歷pte_chain解除映射就OK了。

不過在整個過程中還做了一項不起眼但是非常重要的工作,刷TLB。當頁表改變之後如果正好是當前進程正在使用這個頁表,這時候需要flush TLB,無效這個頁表項,但是清空所有的TLB代價比較大,所以它檢查了一下修改的頁表項是否是當前正在使用的。

下圖展示了從pte到對應的vma的過程:
pte_chain

  1. pte_chain中存儲的引用該頁的pte地址
  2. 根據pte地址進行頁對齊,找到對應的頁幀號和page
  3. page的mapping指向對應進程mm_struct,index代表了虛擬地址(頁對齊)的偏移,尋找vma
  4. 比較當前進程的mm_struct和正在修改的mm_struct,如果是同一個則需要flush對應的tlb項,如果不是就什麼都不要做

文件頁的反向映射

https://lwn.net/Articles/23732/

第一代直接反向映射的方式很容易理解,但是它引入了一些其他的問題,反向映射項佔據了太多的內存,並且需要額外的消耗來維護這些關係。那些需要大量頁申請、釋放、拷貝的操作會被減慢,在fork()系統調用的時候,必須爲進程地址空間中每個頁幀添加反向映射項鍊表,這個速度太慢了。
Dave McCracken提出了一個新的patch來解決這個問題,稱之爲"object-based reverse mapping"(基於對象的反向映射),這裏的對象是“文件”,解決了文件映射頁的反向映射關係查找。

用戶空間使用的頁有兩種基本類型:匿名頁和文件頁,其中文件映射頁和文件系統中某個文件相關聯,一般包括進程的代碼段、通過mmap映射的文件頁,這部分頁可以不需要通過直接反向映射表項就能找到所有的頁表項。
file-backed page rmap
每個頁幀對應的struct page結構,它有一個成員mapping,當頁是通過文件映射的時候,它指向address_space,包含有文件對應的inode信息和其他的管理數據信息,其中的兩個雙向鏈表 (i_mmap和i_mmap_shared) 包含了映射該文件的所有的vm_area_struct,後者是共享映射方式的頁,例如mmap(MAP_SHARED)操作建立的頁。vm_area_struct描述了一個區間的進程地址空間的信息,通過/proc/pid/maps可以看到進程的所有的VMA。而通過VMA可以找到特定的頁映射到進程的地址空間,這樣就可以找到對應的頁表項。

page reverse mapping

  1. 文件映射頁的page->mapping指向address_space,其中的兩個鏈表i_mmap和i_mmap_shared掛有映射該頁的vma對象
  2. page->index代表在文件中的偏移,而vma->vm_pgoff是映射文件時的offset,即這塊vma區間的起始地址對應的文件偏移,通過mmap(...int fd, off_t offset)指定了文件的偏移,根據page->index計算出物理頁在這個進程中的虛擬地址
inline unsigned long
vma_address(struct page *page, struct vm_area_struct *vma)
{
    pgoff_t pgoff = page->index << (PAGE_CACHE_SHIFT - PAGE_SHIFT);
    unsigned long address;
    address = vma->vm_start + ((pgoff - vma->vm_pgoff) << PAGE_SHIFT);
    return address;
}
  1. 根據虛擬地址address找到對應的pte項

文件頁的反向映射就通過這條路徑來處理了,這條路徑比直接的頁表項指針要長,但是它肯定內存消耗會小很多,不需要維護額外的pte_chain信息。但是匿名頁沒有對應的address_space結構,所以object-based(基於文件對象)的反向映射處理不了匿名頁,所以這段事件是兩種方式共存:直接反向映射來處理匿名頁,基於對象的反向映射處理文件頁

匿名頁的反向映射

https://lwn.net/Articles/77106/

後來Andrea Arcangeli提交了另外的patch來處理匿名頁的反向映射問題,原來因爲匿名頁沒有文件對象,所以複用不了address_space的那套東西嘛,所以他發明了一個新的對象來代替address_space的作用,通過複用struct page->mapping來指向一個anon_vma,它上面掛了所有可能共享該區域的VMA。

一個進程通過malloc來申請內存,隨後分配物理頁,也就是匿名頁,在創建第一個區域的時候從來都不會是共享的,所以對於一個新的匿名頁不需要反向映射的鏈,此時它是進程私有的。Andrea的patch通過在struct page中添加一個union來共用mapping指針,稱之爲vma,指向一個單獨的VMA結構,如果一個進程中在同一個VMA中有幾個私有的匿名頁,他們的關係就像這樣(我沒見過這個版本的代碼哈):
anon_vma
通過這個結構,內核可以通過vma結構體找到給定頁幀對應的頁表。

當進程開始fork之後,事情會變得複雜,一旦fork之後,父子進程都有頁表指向相同的匿名頁,單個的VMA指針不再能滿足需求。所以Andread創建了一個新的anon_vma結構來管理VMA的鏈表關係。struct page的union的第三個對象就指向了anon_vma,裏面主要是一個雙鏈表,所有可能包含該頁幀的vma都在上面,現在看起來是下面這樣:
anon_vma如果內核需要unmap這樣的頁幀,它需要遍歷anon_vma的鏈表,測試它找到的每一個vma。一旦所有的頁表都已經unmap之後,頁幀就可以釋放了。

這種方案也需要一些額外的內存消耗:VMA結構需要增加一個新的list_head對象,當頁開始共享的時候需要分配一個新的anon_vma結構。一個VMA可以跨越幾千個頁幀,所以相對於每個page中的反向映射,VMA中的消耗也不算什麼了。
anon_vma數據結構
這種方式和文件頁的反向映射非常相似,page->index表示的虛擬地址(頁對齊),vma->vm_pgoff一般是0,不同的只有address_space換成了anon_vma,怎麼查找到頁表項的過程就不再重複了。

這種方法會增加很多的計算量。釋放一個頁幀需要掃描多個VMA,這些VMA可能包含也可能不包含對頁幀的引用。這些計算量會隨着一塊內存區域被更多進程共享而增加,這些問題在還沒有合併的時候就已經提出了,但是並不是如何嚴重,所以everything is OK。
anon_vma的問題

  1. 初始狀態,進程1通過malloc申請了一片區域,此時有VMA和一個anon_vma,世界如此美好哈
  2. 進程開始fork出了很多進程,此時COW機制的存在,所有進程都有自己的vma但是共享同一片物理內存,在父進程的anon_vma鏈接有所有子進程的vma。
    這裏他假設的是anon_vma和address_space是等同的,同一個文件的映射頁歸根結底都需要訪問同一個inode和address_space,它這裏將父進程初始的vma區域看做一個文件,子進程相同的vma映射的也是這個文件,即vma對應的物理頁是相同的。很可惜不是,在進行寫操作的時候就開始分裂了,大家只是虛擬地址區間看起來是一樣的,其實已經分道揚鑣了。
  3. 對於vma可以進行擴張、分裂,圖3中是經過一系列vma的變形之後,現在父進程和子進程的vma和初始的vma已經沒有交集了,但是他們還掛在父進程的anon_vma中。swap out父進程或子進程vma對應的物理頁時仍然會遍歷兩個進程的vma,這完全是冗餘的。

匿名頁反向映射的改進

The case of the overly anonymous anon_vma

隨着技術的發展,匿名頁反向映射的缺點暴露的越來越大,Rik van Riel對於匿名頁的反向映射提出新的改進patch,在2.6.34內核版本中合併改進後的patch。他描述了這樣一種場景中老版本的糟糕表現:

In a workload with 1000 child processes and a VMA with 1000 anonymous pages per process that get COWed, this leads to a system with a million anonymous pages in the same anon_vma, each of which is mapped in just one of the 1000 processes. However, the current rmap code needs to walk them all, leading to O(N) scanning complexity for each page. 

老版本的匿名頁反向映射,通過組織所有的匿名頁到同一個父進程的同一個anon_vma結構中,在需要知道反向映射一個頁的信息時需要每次都遍歷這個鏈表,這樣就會有一個問題,持有同樣一個鎖取掃描大量的VMA,特別是有些物理頁沒有被其中的一些VMA引用。所以新版的改進主要是AV和VMA的關係維護,減少冗餘VMA的掃描。

AVanon_vmaAVCanon_vma_chain結構,VMAvm_area_struct,下面的簡寫不再說明。

Rik的方法是爲每個進程創建一個AV結構,把他們鏈接到一起而不是VMA對象,這樣當COW之後分裂頁的時候將page->mapping指向自己進程的AV,而不是所有子進程新的頁再共享父進程的anon_vma。這個鏈接關係通過一個AVC的對象來完成,它充當anon_vma和vma的連接結構,裏面兩個鏈表能把人繞暈了:

    struct anon_vma_chain {
	struct vm_area_struct *vma;
	struct anon_vma *anon_vma;
	struct list_head same_vma;		//以vma爲鏈表頭,鏈表上所有的avc->anon_vma都共享同一個vma,當進程解除映射即刪除vma時,需要遍歷vma上所有的AVC,進而找到AVC->anon_vma對齊進行解引用操作,最後anon_vma裏的鏈表爲空時釋放AV對象
	struct list_head same_anon_vma;	//以anon_vma爲主,該鏈表上的所有anon_vma_chain->vma共享同一個anon_vma,在查找頁反向映射關係的時候遍歷anon_vma->head上所有的AVC,進而找到對應的vma和頁表項
    };

每個anon_vma_chain維護兩個鏈表:same_vma和same_anon_vma,下面簡要說明一下fork時和之後的頁面分裂這幾個對象的關係。

  1. 初始化狀態,通過execv啓動了一個全新的進程,程序通過malloc申請了一片內存,內核中爲其分配一個匿名的VMA區域。藍色的線代表same_anon_vma鏈表,紅色的箭頭代表same_vma鏈表,黑色的箭頭代表指針
    在這裏插入圖片描述
  2. 當進程fork時, 爲子進程分配新的vma,這個是fork必須的,每個進程都需要有自己的虛擬地址管理結構,只是和父子進程的VMA的vm_start,vm_end,vm_flags,vm_page_prot等屬性基本完全一致;之後會關聯父子進程的AVC,AV,VMA對象,主要是anon_vma_fork中完成
do_fork -> copy_mm ->dup_mm->dup_mmap->anon_vma_fork

在這裏插入圖片描述

創建一個新的AVC,連接父進程的AV和子進程的VMA,分別有指針指向父進程的AV和子進程的VMA,並且掛到父進程的AV中。此時如果有對應的匿名頁,它的page->mapping是指向父進程的AV,通過遍歷父進程AV中的鏈表,每個對象都是AVC,通過其中的指針vma找到所有引用它的VMA,之後的過程和老版的匿名頁的反向映射過程查找就完全一致了。

注意,新的AVC已經被添加到藍色的鏈表中,代表所有的VMA都引用同一個anon_vma結構。
在這裏插入圖片描述子進程的VMA需要它自己的AV,這樣當爲子進程分配新的頁時,新的頁page->mapping將指向它自己的AV,這樣在尋找page時就不再需要父進程的AV,也不會遍歷父進程中的VMA,子進程的AV的root指向父進程的AV。
爲子進程分配新的AVC,這樣當子進程fork時,子進程的子進程(簡稱孫子進程)的AVC就可以管理孫子進程的共享映射關係。現在有兩個AVC,一個是充當父子進程間AV和VMA的橋樑,另一個是管理本進程中AV和VMA的關係。
在這裏插入圖片描述
當子進程再fork時,看起來更加複雜了,不過它還是爲了解決兩種主要的場景:遍歷反向映射關係根據AV遍歷所有的VMA,解除VMA映射時更新對應的AV
在這裏插入圖片描述
上面描述的過程只有虛擬空間和VMA的關係,它又是如何解決老版的反向映射的問題呢?即如何減少VMA的冗餘掃描

通過malloc申請了虛擬地址,但是不一定有對應的物理內存分配,這時候分爲兩種情況:
1、父進程中沒有匿名頁
當父進程中也沒有分配頁的時候,寫操作會爲其真正分配物理頁,這時候很簡單,do_anonymous_page->page_add_new_anon_rmap->__page_set_anon_rmap,新的頁page->mapping指向父進程中的AV。此時掃描該頁反向映射關係仍然需要掃描它子進程,孫子進程的VMA,這點上面仍然沒有任何的改進。

2、父進程中已經分配匿名頁,在fork時COW使得他們共享只讀的匿名頁,當一方進行寫操作的時候觸發異常進行頁面分裂
父進程中已經分配匿名頁,當fork時,一個vma中所有的匿名頁都指向父進程的anon_vma對象。當父進程對頁數據進行寫操作時觸發COW,新的頁會指向父進程的AV,和上面的情況相同。
當子進程最早對頁數據進行寫操作時觸發COW,新的頁會指向子進程的AV,此時這個頁的反向映射關係只包含它和它衍生的子進程,孫子進程,不包含兄弟進程和父進程。

再來回頭看一下Rik提出的問題

In a workload with 1000 child processes and a VMA with 1000 anonymous pages per process that get COWed,
 this leads to a system with a million anonymous pages in the same anon_vma, each of which is mapped in just 
 one of the 1000 processes. However, the current rmap code needs to walk them all, leading to O(N) scanning 
 complexity for each page. 

目前改進的方案解決的問題是:一個進程fork了999個進程,其中的一個VMA包含1000個匿名頁,按照老版的,每個頁的反向映射都需要遍歷1000個VMA(父進程和它的子進程們),總共需要100萬次的遍歷,而且他們需要相同的AV鎖,造成的鎖競爭問題。

改進的反向映射patch下,頁的反向映射狀況

  1. 父子進程共享只讀匿名頁的情況仍然需要掃描所有進程的VMA,這種情況下是沒有問題的;
  2. 但是父進程新分配的匿名頁也需要掃描所有進程的VMA,這種情況下就有點沒有必要的,很明顯新的匿名頁子進程不再共享;
  3. 子進程新分配的匿名頁,它的反向映射關係不再需要掃描父進程和子進程的VMA,不過存在的問題和2相同。

從上面看,改進的patch確實解決了百萬匿名頁反向映射遍歷的問題,不過還存在一些冗餘的掃描。問題在於AV是和VMA對等的,它代表的是一段空間的共享映射問題,也就是多個page共享一個AV,這個前提如果不改變,這個問題可能就解決不了。相對於每個頁級別中pte_chain的精確反向映射關係,基於對象的反向映射節省了內存,不過犧牲了精度。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章