轉自:https://blog.csdn.net/h674174380/article/details/75453750
前一段時間看了《深入理解Linux內核》對其中的內存管理部分花了不少時間,但是還是有很多問題不是很清楚,最近又花了一些時間復習了一下,在這里記錄下自己的理解和對Linux中內存管理的一些看法和認識。
我比較喜歡搞清楚一個技術本身的發展歷程,簡而言之就是這個技術是怎么發展而來的,在這個技術之前存在哪些技術,這些技術有哪些特點,為什么會被目前的技術所取代,而目前的技術又解決了之前的技術所存在的哪些問題。弄清楚了這些,我們才能比較清晰的把握某一項技術。有些資料在介紹某個概念的時候直接就介紹這個概念的意義,原理,而對其發展過程和背后的原理絲毫不提,仿佛這個技術從天上掉下來的一樣。介於此,還是以內存管理的發展歷程來講述今天的主題。
首先,我必須要闡述一下這篇文章的主題是Linux內存管理中的分段和分頁技術。
讓我們來回顧一下歷史,在早期的計算機中,程序是直接運行在物理內存上的。換句話說,就是程序在運行的過程中訪問的都是物理地址。如果這個系統只運行一個程序,那么只要這個程序所需的內存不要超過該機器的物理內存就不會出現問題,我們也就不需要考慮內存管理這個麻煩事了,反正就你一個程序,就這么點內存,吃不吃得飽那是你的事情了。然而現在的系統都是支持多任務,多進程的,這樣CPU以及其他硬件的利用率會更高,這個時候我們就要考慮到將系統內有限的物理內存如何及時有效的分配給多個程序了,這個事情本身我們就稱之為內存管理。
下面舉一個早期的計算機系統中,內存分配管理的例子,以便於大家理解。
假如我們有三個程序,程序A,B,C,程序A運行的過程中需要10M內存,程序B運行的過程中需要100M內存,而程序C運行的過程中需要20M內存。如果系統同時需要運行程序A和B,那么早期的內存管理過程大概是這樣的,將物理內存的前10M分配給A,接下來的10M-110M分配給B。這種內存管理的方法比較直接,好了,假設我們這個時候想讓程序C也運行,同時假設我們系統的內存只有128M,顯然按照這種方法程序C由於內存不夠是不能夠運行的。大家知道可以使用虛擬內存的技術,內存空間不夠的時候可以將程序不需要用到的數據交換到磁盤空間上去,已達到擴展內存空間的目的。下面我們來看看這種內存管理方式存在的幾個比較明顯的問題。就像文章一開始提到的,要很深層次的把握某個技術最好搞清楚其發展歷程。
1.進程地址空間不能隔離
由於程序直接訪問的是物理內存,這個時候程序所使用的內存空間不是隔離的。舉個例子,就像上面說的A的地址空間是0-10M這個范圍內,但是如果A中有一段代碼是操作10M-128M這段地址空間內的數據,那么程序B和程序C就很可能會崩潰(每個程序都可以訪問系統的整個地址空間)。這樣很多惡意程序或者是木馬程序可以輕而易舉地破快其他的程序,系統的安全性也就得不到保障了,這對用戶來說也是不能容忍的。
2. 內存使用的效率低
如上面提到的,如果我們要像讓程序A、B、C同時運行,那么唯一的方法就是使用虛擬內存技術將一些程序暫時不用的數據寫到磁盤上,在需要的時候再從磁盤讀回內存。這里程序C要運行,將A交換到磁盤上去顯然是不行的,因為程序是需要連續的地址空間的,程序C需要20M的內存,而A只有10M的空間,所以需要將程序B交換到磁盤上去,而B足足有100M,可以看到為了運行程序C我們需要將100M的數據從內存寫到磁盤,然后在程序B需要運行的時候再從磁盤讀到內存,我們知道IO操作比較耗時,所以這個過程效率將會十分低下。
3. 程序運行的地址不能確定
程序每次需要運行時,都需要在內存中分配一塊足夠大的空閑區域,而問題是這個空閑的位置是不能確定的,這會帶來一些重定位的問題,重定位的問題確定就是程序中引用的變量和函數的地址,如果有不明白童鞋可以去查查編譯願意方面的資料。
內存管理無非就是想辦法解決上面三個問題,如何使進程的地址空間隔離,如何提高內存的使用效率,如何解決程序運行時的重定位問題?
這里引用計算機界一句無從考證的名言:“計算機系統里的任何問題都可以靠引入一個中間層來解決。”
現在的內存管理方法就是在程序和物理內存之間引入了虛擬內存這個概念。虛擬內存位於程序和物理內存之間,程序只能看見虛擬內存,再也不能直接訪問物理內存。每個程序都有自己獨立的進程地址空間,這樣就做到了進程隔離。這里的進程地址空間是指虛擬地址。顧名思義,既然是虛擬地址,也就是虛的,不是現實存在的地址空間。
既然我們在程序和物理地址空間之間增加了虛擬地址,那么就要解決怎么從虛擬地址映射到物理地址,因為程序最終肯定是運行在物理內存中的,主要有分段和分頁兩種技術。
分段(Segmentation):這種方法是人們最開始使用的一種方法,基本思路是將程序所需要的內存地址空間大小的虛擬空間映射到某個物理地址空間。
段映射機制
每個程序都有其獨立的虛擬的獨立的進程地址空間,可以看到程序A和B的虛擬地址空間都是從0x00000000開始的。我們將兩塊大小相同的虛擬地址空間和實際物理地址空間一一映射,即虛擬地址空間中的每個字節對應於實際地址空間中的每個字節,這個映射過程由軟件來設置映射的機制,實際的轉換由硬件來完成。
這種分段的機制解決了文章一開始提到的3個問題中的進程地址空間隔離和程序地址重定位的問題。程序A和程序B有自己獨立的虛擬地址空間,而且該虛擬地址空間被映射到了互相不重疊的物理地址空間,如果程序A訪問虛擬地址空間的地址不在0x00000000-0x00A00000這個范圍內,那么內核就會拒絕這個請求,所以它解決了隔離地址空間的問題。我們應用程序A只需要關心其虛擬地址空間0x00000000-0x00A00000,而其被映射到哪個物理地址我們無需關心,所以程序永遠按照這個虛擬地址空間來放置變量,代碼,不需要重新定位。
無論如何分段機制解決了上面兩個問題,是一個很大的進步,但是對於內存效率問題仍然無能為力。因為這種內存映射機制仍然是以程序為單位,當內存不足時仍然需要將整個程序交換到磁盤,這樣內存使用的效率仍然很低。那么,怎么才算高效率的內存使用呢。事實上,根據程序的局部性運行原理,一個程序在運行的過程當中,在某個時間段內,只有一小部分數據會被經常用到。所以我們需要更加小粒度的內存分割和映射方法,此時是否會想到Linux中的Buddy算法和slab內存分配機制呢,哈哈。另一種將虛擬地址轉換為物理地址的方法分頁機制應運而生了。
分頁機制:
分頁機制就是把內存地址空間分為若干個很小的固定大小的頁,每一頁的大小由內存決定,就像Linux中ext文件系統將磁盤分成若干個Block一樣,這樣做是分別是為了提高內存和磁盤的利用率。試想一下,如果將磁盤空間分成N等份,每一份的大小(一個Block)是1M,如果我想存儲在磁盤上的文件是1K字節,那么其余的999字節是不是浪費了。所以需要更加細粒度的磁盤分割方式,我們可以將Block設置得小一點,這當然是根據所存放文件的大小來綜合考慮的,好像有點跑題了,我只是想說,內存中的分頁機制跟ext文件系統中的磁盤分割機制非常相似。
Linux中一般頁的大小是4KB,我們把進程的地址空間按頁分割,把常用的數據和代碼頁裝載到內存中,不常用的代碼和數據保存在磁盤中,我們還是以一個例子來說明,如下圖:
進程虛擬地址空間、物理地址空間和磁盤之間的頁映射關系
我們可以看到進程1和進程2的虛擬地址空間都被映射到了不連續的物理地址空間內(這個意義很大,如果有一天我們的連續物理地址空間不夠,但是不連續的地址空間很多,如果沒有這種技術,我們的程序就沒有辦法運行),甚至他們共用了一部分物理地址空間,這就是共享內存。
進程1的虛擬頁VP2和VP3被交換到了磁盤中,在程序需要這兩頁的時候,Linux內核會產生一個缺頁異常,然后異常管理程序會將其讀到內存中。
這就是分頁機制的原理,當然Linux中的分頁機制的實現還是比較復雜的,通過了頁全局目錄,頁上級目錄,頁中級目錄,頁表等幾級的分頁機制來實現的,但是基本的工作原理是不會變的。
分頁機制的實現需要硬件的實現,這個硬件名字叫做MMU(Memory Management Unit),他就是專門負責從虛擬地址到物理地址轉換的,也就是從虛擬頁找到物理頁。
該博文參考國嵌視頻和http://www.cnblogs.com/image-eye/archive/2011/07/13/2105765.html,在此感謝作者。
一、地址類型
物理地址:CPU通過地址總線的尋址,找到真實的物理內存對應地址。
邏輯地址:程序代碼經過編譯后出現在 匯編程序中地址。
線性地址(虛擬地址):在32位CPU架構下,可以表示4G的地址空間,用16進制表示就是0x00000000---0Xffff ffff
他們之間關系?
二、段式管理、頁式管理
2.1段式管理
2.2.1段式管理(16位CPU)
16位CPU內部擁有20位的地址線,它的尋址范圍2^20即1M的內存空間,但是16位CPU用於存放地址的寄存器(IP,sp)只有16位,即只能訪問64K內存空間。
如何使用16地址寄存器訪問1M內存空間??
為了能夠訪問1M的內存空間,CPU采用內存分段的管理模式,並在CPU內部加入了段寄存器。16位CPU將1M內存空間分為若干邏輯段,每個邏輯段的要求如下:
1、 邏輯段的起始地址(段地址)必須是16倍數,即最后4個二進制必須全部為0
2、 邏輯段的最大容量為64K(why?因為16位CPU存放地址寄存器只有16位)
物理地址的形成方式:
由於段地址必須是16倍數,所以值一般形式為XXXX0H,即前16位二進制是變化的,后四位是固定的0,鑒於段地址的這種特性,可以只保存前16位二進制位來保存整個段基地址,所以每次使用時要段寄存器乘以16得到實際段地址。
邏輯地址=段基地址:偏移地址
其中:段基地址保存到段寄存器中,偏移地址保存另個寄存器中
線性地址= 段基地址*16+偏移地址
2.2.2段式管理(32位CPU)
在32位CPU兩種工作方式:由實模式和保護模式組成。
1、 實模式:內存管理與 16位CPU是一致的。
2、 保護模式:(一般X86運行模式)
段基地址長達32位,每個段的最大容量可達4G,段寄存器的值時段地址的“選擇器”(selecor),用該“選擇器”從內存中得到一個32位的段地址,存儲單元的
物理地址=該段基地址(Base)+段內偏移地址(offset)
2.2頁式管理(分頁管理)
概念
1、 線性地址頁:從管理和效率的角度出發,線性地址被分為固定長度的組,稱為頁(page)。例如32位機器,線性地址最大可為4G,如果用4KB為頁容量,這樣將線性地址划分為2^20個頁。
2、 物理頁:另一類“頁”,稱為“物理頁”,或者是“頁框、頁幀”。分頁單元把所有的物理內存也划分為固定長度的管理單位,它的長度一般與線性地址頁是相同。
如何將兩者之間的映射?通過頁式管理實現映射。
頁式管理具體流程:
說明:
1、 分頁單元中,頁目錄的地址放在CPU的CR3寄存器中,是進行地址轉換的起始點。
2、 每個進程,都有其獨立的虛擬地址空間,運行一個進程,首先需要將它的頁目錄地址放到CR3寄存器中,將其他進程保存下來。
3、 每一個32位的線性地址被划分三部分:頁目錄索引(10位):頁表索引(10位):偏移(12位)
下面是地址轉換的步驟:
第一步:裝入進程的頁目錄地址(操作系統在調度進程時,把這個地址裝入CR3)
第二步:根據線性地址前十位,在頁目錄中,找到對應的索引項 即頁表地址。
第三步:根據線性地址中間十位,在頁表中,找到對應的索引項 即頁的起始地址。
第四步:將頁的起始地址與線性地址最后12位相加,等到物理地址。
1、 這樣的二級模式是否支持尋址4G的物理地址空間?為什么?
支持。因為頁目錄支持尋址2^10個頁表,每個頁表支持尋址2^10個頁,每個頁由2^12=4Kbyte組成,即2^10* 2^10*2^12=4Gbyte滿足。
2、 由上圖物理地址的頁容量多大?有什么決定的?
在二級模式下,頁容量由線性地址[bit11:0]決定,頁容量=2^12=4Kbyte。
根據上面的分段管理和分頁管理,得出下圖
該圖源於/www.cnblogs.com/image-eye/archive/2011/07/13/2105765.html博客
三、linux內存管理
Linux內核的設計並沒有全部采用intel所提供的段機制,僅僅是有限度使用了分段機制。這不僅簡化了linux內核的設計,而且為了把linux移植到其他平台創造了條件,因為很多RISC處理器並不支持段機制。
為什么是linux內核設計有限度使用分段機制?
因為linux內核中內存管理中:所有的段的基地址均為0,即每個段的邏輯地址與線性地址保持一致(即邏輯地址的偏移量值與線性線性的地址值相同),而完成利用了分頁機制。
前面介紹的i386的二級頁管理架構,有些CPU使用三級或四級架構。在linux2.6.29內核為每種CPU提供統一界面,四級頁管理架構,來兼容二級三級 四級管理架構的CPU。四級架構詳見下面:
其中:
1、頁全局目錄(page global directory):多級頁表的抽象最高層
2、頁上級目錄(page upper directory):即pud
3、頁中間目錄( page middle directory):即pmd 頁表的中間層
4、頁表(page table entry):pte
5、頁:即具體物理地址
1. 虛擬地址、物理地址、邏輯地址、線性地址
虛擬地址又叫線性地址。linux沒有采用分段機制,所以邏輯地址和虛擬地址(線性地址)(在用戶態,內核態邏輯地址專指下文說的線性偏移前的地址)是一個概念。物理地址自不必提。內核的虛擬地址和物理地址,大部分只差一個線性偏移量。用戶空間的虛擬地址和物理地址則采用了多級頁表進行映射,但仍稱之為線性地址。
2. DMA/HIGH_MEM/NROMAL 分區
在x86結構中,Linux內核虛擬地址空間划分0~3G為用戶空間,3~4G為內核空間(注意,內核可以使用的線性地址只有1G)。內核虛擬空間(3G~4G)又划分為三種類型的區:
ZONE_DMA 3G之后起始的16MB
ZONE_NORMAL 16MB~896MB
ZONE_HIGHMEM 896MB ~1G
由於內核的虛擬和物理地址只差一個偏移量:物理地址 = 邏輯地址 – 0xC0000000。所以如果1G內核空間完全用來線性映射,顯然物理內存也只能訪問到1G區間,這顯然是不合理的。HIGHMEM就是為了解決這個問題,專門開辟的一塊不必線性映射,可以靈活定制映射,以便訪問1G以上物理內存的區域。從網上扣來一圖,
高端內存的划分,又如下圖,
內核直接映射空間 PAGE_OFFSET~VMALLOC_START,kmalloc和__get_free_page()分配的是這里的頁面。二者是借助slab分配器,直接分配物理頁再轉換為邏輯地址(物理地址連續)。適合分配小段內存。此區域 包含了內核鏡像、物理頁框表mem_map等資源。
內核動態映射空間 VMALLOC_START~VMALLOC_END,被vmalloc用到,可表示的空間大。
內核永久映射空間 PKMAP_BASE ~ FIXADDR_START,kmap
內核臨時映射空間 FIXADDR_START~FIXADDR_TOP,kmap_atomic
3.伙伴算法和slab分配器
伙伴Buddy算法解決了外部碎片問題.內核在每個zone區管理着可用的頁面,按2的冪級(order)大小排成鏈表隊列,存放在free_area數組。
具體buddy管理基於位圖,其分配回收頁面的算法描述如下,
buddy算法舉例描述:
假設我們的系統內存只有16個頁面RAM。因為RAM只有16個頁面,我們只需用四個級別(orders)的伙伴位圖(因為最大連續內存大小為16個頁面),如下圖所示。
order(0)bimap有8個bit位(頁面最多16個頁面,所以16/2)
order(1)bimap有4個bit位(order(0)bimap有8個bit位,所以8/2);
也就是order(1)第一塊由兩個頁框page1 與page2組成與order(1)第2塊由兩個頁框page3 與page4組成,這兩個塊之間有一個bit位
order(2)bimap有2個bit位(order(1)bimap有4個bit位,所以4/2)
order(3)bimap有1個bit位(order(2)bimap有4個bit位,所以2/2)
在order(0),第一個bit表示開始的2個頁面,第二個bit表示接下來的2個頁面,以此類推。因為頁面4已分配,而頁面5空閑,故第三個bit為1。
同樣在order(1)中,bit3是1的原因是一個伙伴完全空閑(頁面8和9),和它對應的伙伴(頁面10和11)卻並非如此,故以后回收頁面時,可以合並。
分配過程
當我們需要order(1)的空閑頁面塊時,則執行以下步驟:
1、初始空閑鏈表為:
order(0): 5, 10
order(1): 8 [8,9]
order(2): 12 [12,13,14,15]
order(3):
2、從上面空閑鏈表中,我們可以看出,order(1)鏈表上,有一個空閑的頁面塊,把它分配給用戶,並從該鏈表中刪除。
3、當我們再需要一個order(1)的塊時,同樣我們從order(1)空閑鏈表上開始掃描。
4、若在order(1)上沒有空閑頁面塊,那么我們就到更高的級別(order)上找,order(2)。
5、此時(order(1)上沒有空閑頁面塊)有一個空閑頁面塊,該塊是從頁面12開始。該頁面塊被分割成兩個稍微小一些order(1)的頁面塊,[12,13]和[14,15]。[14,15]頁面塊加到order(1)空閑鏈表中,同時[12,13]頁面塊返回給用戶。
6、最終空閑鏈表為:
order(0): 5, 10
order(1): 14 [14,15]
order(2):
order(3):
回收過程
當我們回收頁面11(order 0)時,則執行以下步驟:
1、找到在order(0)伙伴位圖中代表頁面11的位,計算使用下面公示:
index = page_idx >> (order + 1)
= 11 >> (0 + 1)
= 5
2、檢查上面一步計算位圖中相應bit的值。若該bit值為1,則和我們臨近的,有一個空閑伙伴。Bit5的值為1(注意是從bit0開始的,Bit5即為第6bit),因為它的伙伴頁面10是空閑的。
3、現在我們重新設置該bit的值為0,因為此時兩個伙伴(頁面10和頁面11)完全空閑。
4、我們將頁面10,從order(0)空閑鏈表中摘除。
5、此時,我們對2個空閑頁面(頁面10和11,order(1))進行進一步操作。
6、新的空閑頁面是從頁面10開始的,於是我們在order(1)的伙伴位圖中找到它的索引,看是否有空閑的伙伴,以進一步進行合並操作。使用第一步中的計算公司,我們得到bit 2(第3位)。
7、Bit 2(order(1)位圖)同樣也是1,因為它的伙伴頁面塊(頁面8和9)是空閑的。
8、重新設置bit2(order(1)位圖)的值,然后在order(1)鏈表中刪除該空閑頁面塊。
9、現在我們合並成了4頁面大小(從頁面8開始)的空閑塊,從而進入另外的級別。在order(2)中找到伙伴位圖對應的bit值,是bit1,且值為1,需進一步合並(原因同上)。
10、從oder(2)鏈表中摘除空閑頁面塊(從頁面12開始),進而將該頁面塊和前面合並得到的頁面塊進一步合並。現在我們得到從頁面8開始,大小為8個頁面的空閑頁面塊。
11、我們進入另外一個級別,order(3)。它的位索引為0,它的值同樣為0。這意味着對應的伙伴不是全部空閑的,所以沒有再進一步合並的可能。我們僅設置該bit為1,然后將合並得到的空閑頁面塊放入order(3)空閑鏈表中。
12、最終我們得到大小為8個頁面的空閑塊,
buddy避免內部碎片的努力
物理內存的碎片化一直是Linux操作系統的弱點之一,盡管已經有人提出了很多解決方法,但是沒有哪個方法能夠徹底的解決,memory buddy分配就是解決方法之一。 我們知道磁盤文件也有碎片化問題,但是磁盤文件的碎片化只會減慢系統的讀寫速度,並不會導致功能性錯誤,而且我們還可以在不影響磁盤功能的前提的下,進行磁盤碎片整理。而物理內存碎片則截然不同,物理內存和操作系統結合的太過於緊密,以至於我們很難在運行時,進行物理內存的搬移(這一點上,磁盤碎片要容易的多;實際上mel gorman已經提交了內存緊縮的patch,只是還沒有被主線內核接收)。 因此解決的方向主要放在預防碎片上。在2.6.24內核開發期間,防止碎片的內核功能加入了主線內核。在了解反碎片的基本原理前,先對內存頁面做個歸類:
1. 不可移動頁面 unmoveable:在內存中位置必須固定,無法移動到其他地方,核心內核分配的大部分頁面都屬於這一類。
2. 可回收頁面 reclaimable:不能直接移動,但是可以回收,因為還可以從某些源重建頁面,比如映射文件的數據屬於這種類別,kswapd會按照一定的規則,周期性的回收這類頁面。
3. 可移動頁面 movable:可以隨意的移動。屬於用戶空間應用程序的頁屬於此類頁面,它們是通過頁表映射的,因此我們只需要更新頁表項,並把數據復制到新位置就可以了,當然要注意,一個頁面可能被多個進程共享,對應着多個頁表項。
防止碎片的方法就是把這三類page放在不同的鏈表上,避免不同類型頁面相互干擾。考慮這樣的情形,一個不可移動的頁面位於可移動頁面中間,那么我們移動或者回收這些頁面后,這個不可移動的頁面阻礙着我們獲得更大的連續物理空閑空間。
另外,每個zone區都有一個自己的失活凈頁面隊列,與此對應的是兩個跨zone的全局隊列,失活臟頁隊列 和 活躍隊列。這些隊列都是通過page結構的lru指針鏈入的。
思考:失活隊列的意義是什么(見<linux內核源代碼情景分析>)?
slab分配器:解決內部碎片問題
4.頁面回收/側重機制
關於頁面的使用
在之前的一些文章中,我們了解到linux內核會在很多情況下分配頁面。
1、內核代碼可能調用alloc_pages之類的函數,從管理物理頁面的伙伴系統(管理區zone上的free_area空閑鏈表)上直接分配頁面(見《linux內核內存管理淺析》)。比如:驅動程序可能用這種方式來分配緩存;創建進程時,內核也是通過這種方式分配連續的兩個頁面,作為進程的thread_info結構和內核棧;等等。從伙伴系統分配頁面是最基本的頁面分配方式,其他的內存分配都是基於這種方式的;
2、內核中的很多對象都是用slab機制來管理的(見《linux slub分配器淺析》)。slab就相當於對象池,它將頁面“格式化”成“對象”,存放在池中供人使用。當slab中的對象不足時,slab機制會自動從伙伴系統中分配頁面,並“格式化”成新的對象;
3、磁盤高速緩存(見《linux內核文件讀寫淺析》)。讀寫文件時,頁面被從伙伴系統分配並用於磁盤高速緩存,然后磁盤上的文件數據被載入到對應的磁盤高速緩存頁面中;
4、內存映射。這里所謂的內存映射實際上是指將內存頁面映射到用戶空間,供用戶進程使用。進程的task_struct->mm結構中的每一個vma就代表着一個映射,而映射的真正實現則是在用戶程序訪問到對應的內存地址之后,由缺頁異常引起的頁面被分配和頁表被更新(見《linux內核內存管理淺析》);
頁面回收簡述
有頁面分配,就會有頁面回收。頁面回收的方法大體上可分為兩種:
一是主動釋放。就像用戶程序通過free函數釋放曾經通過malloc函數分配的內存一樣,頁面的使用者明確知道頁面什么時候要被使用,什么時候又不再需要了。
上面提到的前兩種分配方式,一般都是由內核程序主動釋放的。對於直接從伙伴系統分配的頁面,這是由使用者使用free_pages之類的函數主動釋放的,頁面釋放后被直接放歸伙伴系統;從slab中分配的對象(使用kmem_cache_alloc函數),也是由使用者主動釋放的(使用kmem_cache_free函數)。
另一種頁面回收方式是通過linux內核提供的頁框回收算法(PFRA)進行回收。頁面的使用者一般將頁面當作某種緩存,以提高系統的運行效率。緩存一直存在固然好,但是如果緩存沒有了也不會造成什么錯誤,僅僅是效率受影響而已。頁面的使用者不明確知道這些緩存頁面什么時候最好被保留,什么時候最好被回收,這些都交由PFRA來關心。
簡單來說,PFRA要做的事就是回收這些可以被回收的頁面。為了避免系統陷入頁面緊缺的困境,PFRA會在內核線程中周期性地被調用運行。或者由於系統已經頁面緊缺,試圖分配頁面的內核執行流程因為得不到需要的頁面,而同步地調用PFRA。
上面提到的后兩種分配方式,一般是由PFRA來進行回收的(或者由類似刪除文件、進程退出、這樣的過程來同步回收)。
PFRA回收一般頁面
而對於上面提到的前兩種頁面分配方式(直接分配頁面和通過slab分配對象),也有可能需要通過PFRA來回收。
頁面的使用者可以向PFRA注冊回調函數(使用register_shrink函數)。然后由PFRA在適當的時機來調用這些回調函數,以觸發對相應頁面或對象的回收。
其中較為典型的是對dentry的回收。dentry是由slab分配的,用於表示虛擬文件系統目錄結構的對象。在dentry的引用記數被減為0的時候,dentry並不是直接被釋放,而是被放到一個LRU鏈表中緩存起來,便於后續的使用。(見《linux內核虛擬文件系統淺析》。)
而這個LRU鏈表中的dentry最終是需要被回收的,於是虛擬文件系統在初始化時,調用register_shrinker注冊了回收函數shrink_dcache_memory。
系統中所有文件系統的超級塊對象被存放在一個鏈表中,shrink_dcache_memory函數掃描這個鏈表,獲取每個超級塊的未被使用dentry的LRU,然后從中回收一些最老的dentry。隨着dentry的釋放,對應的inode將被減引用,也可能引起inode被釋放。
inode被釋放后也是放在一個未使用鏈表中,虛擬文件系統在初始化時還調用register_shrinker注冊了回調函數shrink_icache_memory,用來回收這些未使用的inode,從而inode中關聯的磁盤高速緩存也將被釋放。
另外,隨着系統的運行,slab中可能會存在很多的空閑對象(比如在對某一對象的使用高峰過后)。PFRA中的cache_reap函數就用於回收這些多余的空閑對象,如果某些空閑的對象正好能夠還原成一個頁面,則這個頁面可以被釋放回伙伴系統;
cache_reap函數要做的事情說起來很簡單。系統中所有存放對象池的kmem_cache結構連成一個鏈表,cache_reap函數掃描其中的每一個對象池,然后尋找可以回收的頁面,並將其回收。(當然,實際的過程要更復雜一點。)
關於內存映射
前面說到,磁盤高速緩存和內存映射一般由PFRA來進行回收。PFRA對這兩者的回收是很類似的,實際上,磁盤高速緩存很可能就被映射到了用戶空間。下面簡單對內存映射做一些介紹:
內存映射分為文件映射和匿名映射。
文件映射是指代表這個映射的vma對應到一個文件中的某個區域。這種映射方式相對較少被用戶態程序顯式地使用,用戶態程序一般習慣於open一個文件、然后read/write去讀寫文件。
而實際上,用戶程序也可以使用mmap系統調用將一個文件的某個部分映射到內存上(對應到一個vma),然后以訪存的方式去讀寫文件。盡管用戶程序較少這樣使用,但是用戶進程中卻充斥着這樣的映射:進程正在執行的可執行代碼(包括可執行文件、lib庫文件)就是以這樣的方式被映射的。
在《linux內核文件讀寫淺析》一文中,我們並沒有討論關於文件映射的實現。實際上,文件映射是將文件的磁盤高速緩存中的頁面直接映射到了用戶空間(可見,文件映射的頁面是磁盤高速緩存頁面的子集),用戶可以0拷貝地對其進行讀寫。而使用read/write的話,則會在用戶空間的內存和磁盤高速緩存間發生一次拷貝。
匿名映射相對於文件映射,代表這個映射的vma沒有對應到文件。對於用戶空間普通的內存分配(堆空間、棧空間),都屬於匿名映射。
顯然,多個進程可能通過各自的文件映射來映射到同一個文件上(比如大多數進程都映射了libc庫的so文件);那匿名映射呢?實際上,多個進程也可能通過各自的匿名映射來映射到同一段物理內存上,這種情況是由於fork之后父子進程共享原來的物理內存(copy-on-write)而引起的。
文件映射又分為共享映射和私有映射。私有映射時,如果進程對映射的地址空間進行寫操作,則映射對應的磁盤高速緩存並不會直接被寫。而是將原有內容復制一份,然后再寫這個復制品,並且當前進程的對應頁面映射將切換到這個復制品上去(寫時復制)。也就是說,寫操作是只有自己可見的。而對於共享映射,寫操作則會影響到磁盤高速緩存,是大家都可見的。
哪些頁面該回收
至於回收,磁盤高速緩存的頁面(包括文件映射的頁面)都是可以被丟棄並回收的。但是如果頁面是臟頁面,則丟棄之前必須將其寫回磁盤。
而匿名映射的頁面則都是不可以丟棄的,因為頁面里面存有用戶程序正在使用的數據,丟棄之后數據就沒法還原了。相比之下,磁盤高速緩存頁面中的數據本身是保存在磁盤上的,可以復現。
於是,要想回收匿名映射的頁面,只好先把頁面上的數據轉儲到磁盤,這就是頁面交換(swap)。顯然,頁面交換的代價相對更高一些。
匿名映射的頁面可以被交換到磁盤上的交換文件或交換分區上(分區即是設備,設備即也是文件。所以下文統稱為交換文件)。
於是,除非頁面被保留或被上鎖(頁面標記PG_reserved/PG_locked被置位。某些情況下,內核需要暫時性地將頁面保留,避免被回收),所有的磁盤高速緩存頁面都可回收,所有的匿名映射頁面都可交換。
盡管可以回收的頁面很多,但是顯然PFRA應當盡可能少地去回收/交換(因為這些頁面要從磁盤恢復,需要很大的代價)。所以,PFRA僅當必要時才回收/交換一部分很少被使用的頁面,每次回收的頁面數是一個經驗值:32。
於是,所有這些磁盤高速緩存頁面和匿名映射頁面都被放到了一組LRU里面。(實際上,每個zone就有一組這樣的LRU,頁面都被放到自己對應的zone的LRU中。)
一組LRU由幾對鏈表組成,有磁盤高速緩存頁面(包括文件映射頁面)的鏈表、匿名映射頁面的鏈表、等。一對鏈表實際上是active和inactive兩個鏈表,前者是最近使用過的頁面、后者是最近未使用的頁面。
進行頁面回收的時候,PFRA要做兩件事情,一是將active鏈表中最近最少使用的頁面移動到inactive鏈表、二是嘗試將inactive鏈表中最近最少使用的頁面回收。
確定最近最少使用
現在就有一個問題了,怎么確定active/inactive鏈表中哪些頁面是最近最少使用的呢?
一種方法是排序,當頁面被訪問時,將其移動到鏈表的尾部(假設回收從頭部開始)。但是這就意味着頁面在鏈表中的位置可能頻繁移動,並且移動之前還必須先上鎖(可能有多個CPU在同時訪問),這樣做對效率影響很大。
linux內核采用的是標記加順序的辦法。當頁面在active和inactive兩個鏈表之間移動時,總是將其放到鏈表的尾部(同上,假設回收從頭部開始)。
頁面沒有在鏈表間移動時,並不會調整它們的順序。而是通過訪問標記來表示頁面是否剛被訪問過。如果inactive鏈表中已設置訪問標記的頁面再被訪問,則將其移動到active鏈表中,並且清除訪問標記。(實際上,為了避免訪問沖突,頁面並不會直接從inactive鏈表移動到active鏈表,而是有一個pagevec中間結構用作緩沖,以避免鎖鏈表。)
頁面的訪問標記有兩種情況,一是放在page->flags中的PG_referenced標記,在頁面被訪問時該標記置位。對於磁盤高速緩存中(未被映射)的頁面,用戶進程通過read、write之類的系統調用去訪問它們,系統調用代碼中會將對應頁面的PG_referenced標記置位。
而對於內存映射的頁面,用戶進程可以直接訪問它們(不經過內核),所以這種情況下的訪問標記不是由內核來設置的,而是由mmu。在將虛擬地址映射成物理地址后,mmu會在對應的頁表項上置一個accessed標志位,表示頁面被訪問。(同樣的道理,mmu會在被寫的頁面所對應的頁表項上置一個dirty標志,表示頁面是臟頁面。)
頁面的訪問標記(包括上面兩種標記)將在PFRA處理頁面回收的過程中被清除,因為訪問標記顯然是應該有有效期的,而PFRA的運行周期就代表這個有效期。page->flags中的PG_referenced標記可以直接清除,而頁表項中的accessed位則需要通過頁面找到其對應的頁表項后才能清除(見下文的“反向映射”)。
那么,回收過程又是怎樣掃描LRU鏈表的呢?
由於存在多組LRU(系統中有多個zone,每個zone又有多組LRU),如果PFRA每次回收都掃描所有的LRU找出其中最值得回收的若干個頁面的話,回收算法的效率顯然不夠理想。
linux內核PFRA使用的掃描方法是:定義一個掃描優先級,通過這個優先級換算出在每個LRU上應該掃描的頁面數。整個回收算法以最低的優先級開始,先掃描每個LRU中最近最少使用的幾個頁面,然后試圖回收它們。如果一遍掃描下來,已經回收了足夠數量的頁面,則本次回收過程結束。否則,增大優先級,再重新掃描,直到足夠數量的頁面被回收。而如果始終不能回收足夠數量的頁面,則優先級將增加到最大,也就是所有頁面將被掃描。這時,就算回收的頁面數量還是不足,回收過程都會結束。
每次掃描一個LRU時,都從active鏈表和inactive鏈表獲取當前優先級對應數目的頁面,然后再對這些頁面做處理:如果頁面不能被回收(如被保留或被上鎖),則放回對應鏈表頭部(同上,假設回收從頭部開始);否則如果頁面的訪問標記置位,則清除該標記,並將頁面放回對應鏈表尾部(同上,假設回收從頭部開始);否則頁面將從active鏈表被移動到inactive鏈表、或從inactive鏈表被回收。
被掃描到的頁面根據訪問標記是否置位來決定其去留。那么這個訪問標記是如何設置的呢?有兩個途徑,一是用戶通過read/write之類的系統調用訪問文件時,內核操作磁盤高速緩存中的頁面,會設置這些頁面的訪問標記(設置在page結構中);二是進程直接訪問已映射的頁面時,mmu會自動給對應的頁表項加上訪問標記(設置在頁表的pte中)。關於訪問標記的判斷就基於這兩個信息。(給定一個頁面,可能有多個pte引用到它。如何知道這些pte是否被設置了訪問標記呢?那就需要通過反向映射找到這些pte。下面會講到。)
PFRA不傾向於從active鏈表回收匿名映射的頁面,因為用戶進程使用的內存一般相對較少,且回收的話需要進行交換,代價較大。所以在內存剩余較多、匿名映射所占比例較少的情況下,都不會去回收匿名映射對應的active鏈表中的頁面。(而如果頁面已經被放到inactive鏈表中,就不再去管那么多了。)
反向映射
像這樣,在PFRA處理頁面回收的過程中,LRU的inactive鏈表中的某些頁面可能就要被回收了。
如果頁面沒有被映射,直接回收到伙伴系統即可(對於臟頁,先寫回、再回收)。否則,還有一件麻煩的事情要處理。因為用戶進程的某個頁表項正引用着這個頁面呢,在回收頁面之前,還必須給引用它的頁表項一個交待。
於是,問題就來了,內核怎么知道這個頁面被哪些頁表項所引用呢?為了做到這一點,內核建立了從頁面到頁表項的反向映射。
通過反向映射可以找到一個被映射的頁面對應的vma,通過vma->vm_mm->pgd就能找到對應的頁表。然后通過page->index得到頁面的虛擬地址。再通過虛擬地址從頁表中找到對應的頁表項。(前面說到的獲取頁表項中的accessed標記,就是通過反向映射實現的。)
頁面對應的page結構中,page->mapping如果最低位置位,則這是一個匿名映射頁面,page->mapping指向一個anon_vma結構;否則是文件映射頁面,page->mapping文件對應的address_space結構。(顯然,anon_vma結構和address_space結構在分配時,地址必須要對齊,至少保證最低位為0。)
對於匿名映射的頁面,anon_vma結構作為一個鏈表頭,將映射這個頁面的所有vma通過vma->anon_vma_node鏈表指針連接起來。每當一個頁面被(匿名)映射到一個用戶空間時,對應的vma就被加入這個鏈表。
對於文件映射的頁面,address_space結構除了維護了一棵用於存放磁盤高速緩存頁面的radix樹,還為該文件映射到的所有vma維護了一棵優先搜索樹。因為這些被文件映射到的vma並不一定都是映射整個文件,很可能只映射了文件的一部分。所以,這棵優先搜索樹除了索引到所有被映射的vma,還要能知道文件的哪些區域是映射到哪些vma上的。每當一個頁面被(文件)映射到一個用戶空間時,對應的vma就被加入這個優先搜索樹。於是,給定磁盤高速緩存上的一個頁面,就能通過page->index得到頁面在文件中的位置,就能通過優先搜索樹找出這個頁面映射到的所有vma。
上面兩步中,神奇的page->index做了兩件事,得到頁面的虛擬地址、得到頁面在文件磁盤高速緩存中的位置。
vma->vm_start記錄了vma的首虛擬地址,vma->vm_pgoff記錄了該vma在對應的映射文件(或共享內存)中的偏移,而page->index記錄了頁面在文件(或共享內存)中的偏移。
通過vma->vm_pgoff和page->index能得到頁面在vma中的偏移,加上vma->vm_start就能得到頁面的虛擬地址;而通過page->index就能得到頁面在文件磁盤高速緩存中的位置。
頁面換入換出
在找到了引用待回收頁面的頁表項后,對於文件映射,可以直接把引用該頁面的頁表項清空。等用戶再訪問這個地址的時候觸發缺頁異常,異常處理代碼再重新分配一個頁面,並去磁盤里面把對應的數據讀出來就行了(說不定,頁面在對應的磁盤高速緩存里面已經有了,因為其他進程先訪問過)。這就跟頁面映射以后,第一次被訪問的情形一樣;
對於匿名映射,先將頁面寫回到交換文件,然后還得在頁表項中記錄該頁面在交換文件中的index。
頁表項中有一個present位,如果該位被清除,則mmu認為頁表項無效。在頁表項無效的情況下,其他位不被mmu關心,可以用來存儲其他信息。這里就用它們來存儲頁面在交換文件中的index了(實際上是交換文件號+交換文件內的索引號)。
將匿名映射的頁面交換到交換文件的過程(換出過程)與將磁盤高速緩存中的臟頁寫回文件的過程很相似。
交換文件也有其對應的address_space結構,匿名映射的頁面在換出時先被放到這個address_space對應磁盤高速緩存中,然后跟臟頁寫回一樣,被寫回到交換文件中。寫回完成后,這個頁面才被釋放(記住,我們的目的是要釋放這個頁面)。
那么為什么不直接把頁面寫回到交換文件,而要經過磁盤高速緩存呢?因為,這個頁面可能被映射了多次,不可能一次性把所有用戶進程的頁表中對應的頁表項都修改好(修改成頁面在交換文件中的索引),所以在頁面被釋放的過程中,頁面被暫時放在磁盤高速緩存上。
而並不是所有頁表項的修改過程都是能成功的(比如在修改之前頁面又被訪問了,於是現在又不需要回收這個頁面了),所以頁面放到磁盤高速緩存的時間也可能會很長。
同樣,將匿名映射的頁面從交換文件讀出的過程(換入過程)也與將文件數據讀出的過程很相似。
先去對應的磁盤高速緩存上看看頁面在不在,不在的話再去交換文件里面讀。文件里的數據也是被讀到磁盤高速緩存中的,然后用戶進程的頁表中對應的頁表項將被改寫,直接指向這個頁面。
這個頁面可能不會馬上從磁盤高速緩存中拿下來,因為如果還有其他用戶進程也映射到這個頁面(它們的對應頁表項已經被修改成了交換文件的索引),他們也可以引用到這里。直到沒有其他的頁表項再引用這個交換文件索引時,頁面才可以從磁盤高速緩存中被取下來。
最后的必殺
前面說到,PFRA可能掃描了所有的LRU還沒辦法回收需要的頁面。同樣,在slab、dentry cache、inode cache、等地方,可能也無法回收到頁面。
這時,如果某段內核代碼一定要獲得頁面呢(沒有頁面,系統可能就要崩潰了)?PFRA只好使出最后的必殺技——OOM(out of memory)。所謂的OOM就是尋找一個最不重要的進程,然后將其殺死。通過釋放這個進程所占有的內存頁面,以緩解系統壓力。
5.內存管理架構
針對上圖,說幾句,
[地址映射](圖:左中)
linux內核使用頁式內存管理,應用程序給出的內存地址是虛擬地址,它需要經過若干級頁表一級一級的變換,才變成真正的物理地址。
想一下,地址映射還是一件很恐怖的事情。當訪問一個由虛擬地址表示的內存空間時,需要先經過若干次的內存訪問,得到每一級頁表中用於轉換的頁表項(頁表是存放在內存里面的),才能完成映射。也就是說,要實現一次內存訪問,實際上內存被訪問了N+1次(N=頁表級數),並且還需要做N次加法運算。
所以,地址映射必須要有硬件支持,mmu(內存管理單元)就是這個硬件。並且需要有cache來保存頁表,這個cache就是TLB(Translation lookaside buffer)。
盡管如此,地址映射還是有着不小的開銷。假設cache的訪存速度是內存的10倍,命中率是40%,頁表有三級,那么平均一次虛擬地址訪問大概就消耗了兩次物理內存訪問的時間。
於是,一些嵌入式硬件上可能會放棄使用mmu,這樣的硬件能夠運行VxWorks(一個很高效的嵌入式實時操作系統)、linux(linux也有禁用mmu的編譯選項)、等系統。
但是使用mmu的優勢也是很大的,最主要的是出於安全性考慮。各個進程都是相互獨立的虛擬地址空間,互不干擾。而放棄地址映射之后,所有程序將運行在同一個地址空間。於是,在沒有mmu的機器上,一個進程越界訪存,可能引起其他進程莫名其妙的錯誤,甚至導致內核崩潰。
在地址映射這個問題上,內核只提供頁表,實際的轉換是由硬件去完成的。那么內核如何生成這些頁表呢?這就有兩方面的內容,虛擬地址空間的管理和物理內存的管理。(實際上只有用戶態的地址映射才需要管理,內核態的地址映射是寫死的。)
[虛擬地址管理](圖:左下)
每個進程對應一個task結構,它指向一個mm結構,這就是該進程的內存管理器。(對於線程來說,每個線程也都有一個task結構,但是它們都指向同一個mm,所以地址空間是共享的。)
mm->pgd指向容納頁表的內存,每個進程有自已的mm,每個mm有自己的頁表。於是,進程調度時,頁表被切換(一般會有一個CPU寄存器來保存頁表的地址,比如X86下的CR3,頁表切換就是改變該寄存器的值)。所以,各個進程的地址空間互不影響(因為頁表都不一樣了,當然無法訪問到別人的地址空間上。但是共享內存除外,這是故意讓不同的頁表能夠訪問到相同的物理地址上)。
用戶程序對內存的操作(分配、回收、映射、等)都是對mm的操作,具體來說是對mm上的vma(虛擬內存空間)的操作。這些vma代表着進程空間的各個區域,比如堆、棧、代碼區、數據區、各種映射區、等等。
用戶程序對內存的操作並不會直接影響到頁表,更不會直接影響到物理內存的分配。比如malloc成功,僅僅是改變了某個vma,頁表不會變,物理內存的分配也不會變。
假設用戶分配了內存,然后訪問這塊內存。由於頁表里面並沒有記錄相關的映射,CPU產生一次缺頁異常。內核捕捉異常,檢查產生異常的地址是不是存在於一個合法的vma中。如果不是,則給進程一個"段錯誤",讓其崩潰;如果是,則分配一個物理頁,並為之建立映射。
[物理內存管理](圖:右上)
那么物理內存是如何分配的呢?
首先,linux支持NUMA(非均質存儲結構),物理內存管理的第一個層次就是介質的管理。pg_data_t結構就描述了介質。一般而言,我們的內存管理介質只有內存,並且它是均勻的,所以可以簡單地認為系統中只有一個pg_data_t對象。
每一種介質下面有若干個zone。一般是三個,DMA、NORMAL和HIGH。
DMA:因為有些硬件系統的DMA總線比系統總線窄,所以只有一部分地址空間能夠用作DMA,這部分地址被管理在DMA區域(這屬於是高級貨了);
HIGH:高端內存。在32位系統中,地址空間是4G,其中內核規定3~4G的范圍是內核空間,0~3G是用戶空間(每個用戶進程都有這么大的虛擬空間)(圖:中下)。前面提到過內核的地址映射是寫死的,就是指這3~4G的對應的頁表是寫死的,它映射到了物理地址的0~1G上。(實際上沒有映射1G,只映射了896M。剩下的空間留下來映射大於1G的物理地址,而這一部分顯然不是寫死的)。所以,大於896M的物理地址是沒有寫死的頁表來對應的,內核不能直接訪問它們(必須要建立映射),稱它們為高端內存(當然,如果機器內存不足896M,就不存在高端內存。如果是64位機器,也不存在高端內存,因為地址空間很大很大,屬於內核的空間也不止1G了);
NORMAL:不屬於DMA或HIGH的內存就叫NORMAL。
在zone之上的zone_list代表了分配策略,即內存分配時的zone優先級。一種內存分配往往不是只能在一個zone里進行分配的,比如分配一個頁給內核使用時,最優先是從NORMAL里面分配,不行的話就分配DMA里面的好了(HIGH就不行,因為還沒建立映射),這就是一種分配策略。
每個內存介質維護了一個mem_map,為介質中的每一個物理頁面建立了一個page結構與之對應,以便管理物理內存。
每個zone記錄着它在mem_map上的起始位置。並且通過free_area串連着這個zone上空閑的page。物理內存的分配就是從這里來的,從 free_area上把page摘下,就算是分配了。(內核的內存分配與用戶進程不同,用戶使用內存會被內核監督,使用不當就"段錯誤";而內核則無人監督,只能靠自覺,不是自己從free_area摘下的page就不要亂用。)
[建立地址映射]
內核需要物理內存時,很多情況是整頁分配的,這在上面的mem_map中摘一個page下來就好了。比如前面說到的內核捕捉缺頁異常,然后需要分配一個page以建立映射。
說到這里,會有一個疑問,內核在分配page、建立地址映射的過程中,使用的是虛擬地址還是物理地址呢?首先,內核代碼所訪問的地址都是虛擬地址,因為CPU指令接收的就是虛擬地址(地址映射對於CPU指令是透明的)。但是,建立地址映射時,內核在頁表里面填寫的內容卻是物理地址,因為地址映射的目標就是要得到物理地址。
那么,內核怎么得到這個物理地址呢?其實,上面也提到了,mem_map中的page就是根據物理內存來建立的,每一個page就對應了一個物理頁。
於是我們可以說,虛擬地址的映射是靠這里page結構來完成的,是它們給出了最終的物理地址。然而,page結構顯然是通過虛擬地址來管理的(前面已經說過,CPU指令接收的就是虛擬地址)。那么,page結構實現了別人的虛擬地址映射,誰又來實現page結構自己的虛擬地址映射呢?沒人能夠實現。
這就引出了前面提到的一個問題,內核空間的頁表項是寫死的。在內核初始化時,內核的地址空間就已經把地址映射寫死了。page結構顯然存在於內核空間,所以它的地址映射問題已經通過“寫死”解決了。
由於內核空間的頁表項是寫死的,又引出另一個問題,NORMAL(或DMA)區域的內存可能被同時映射到內核空間和用戶空間。被映射到內核空間是顯然的,因為這個映射已經寫死了。而這些頁面也可能被映射到用戶空間的,在前面提到的缺頁異常的場景里面就有這樣的可能。映射到用戶空間的頁面應該優先從HIGH區域獲取,因為這些內存被內核訪問起來很不方便,拿給用戶空間再合適不過了。但是HIGH區域可能會耗盡,或者可能因為設備上物理內存不足導致系統里面根本就沒有HIGH區域,所以,將NORMAL區域映射給用戶空間是必然存在的。
但是NORMAL區域的內存被同時映射到內核空間和用戶空間並沒有問題,因為如果某個頁面正在被內核使用,對應的page應該已經從free_area被摘下,於是缺頁異常處理代碼中不會再將該頁映射到用戶空間。反過來也一樣,被映射到用戶空間的page自然已經從free_area被摘下,內核不會再去使用這個頁面。
[內核空間管理](圖:右下)
除了對內存整頁的使用,有些時候,內核也需要像用戶程序使用malloc一樣,分配一塊任意大小的空間。這個功能是由slab系統來實現的。
slab相當於為內核中常用的一些結構體對象建立了對象池,比如對應task結構的池、對應mm結構的池、等等。
而slab也維護有通用的對象池,比如"32字節大小"的對象池、"64字節大小"的對象池、等等。內核中常用的kmalloc函數(類似於用戶態的malloc)就是在這些通用的對象池中實現分配的。
slab除了對象實際使用的內存空間外,還有其對應的控制結構。有兩種組織方式,如果對象較大,則控制結構使用專門的頁面來保存;如果對象較小,控制結構與對象空間使用相同的頁面。
除了slab,linux 2.6還引入了mempool(內存池)。其意圖是:某些對象我們不希望它會因為內存不足而分配失敗,於是我們預先分配若干個,放在mempool中存起來。正常情況下,分配對象時是不會去動mempool里面的資源的,照常通過slab去分配。到系統內存緊缺,已經無法通過slab分配內存時,才會使用 mempool中的內容。
[頁面換入換出](圖:左上)(圖:右上)
頁面換入換出又是一個很復雜的系統。內存頁面被換出到磁盤,與磁盤文件被映射到內存,是很相似的兩個過程(內存頁被換出到磁盤的動機,就是今后還要從磁盤將其載回內存)。所以swap復用了文件子系統的一些機制。
頁面換入換出是一件很費CPU和IO的事情,但是由於內存昂貴這一歷史原因,我們只好拿磁盤來擴展內存。但是現在內存越來越便宜了,我們可以輕松安裝數G的內存,然后將swap系統關閉。於是swap的實現實在讓人難有探索的欲望,在這里就不贅述了。(另見:《linux內核頁面回收淺析》)
[用戶空間內存管理]
malloc是libc的庫函數,用戶程序一般通過它(或類似函數)來分配內存空間。
libc對內存的分配有兩種途徑,一是調整堆的大小,二是mmap一個新的虛擬內存區域(堆也是一個vma)。
在內核中,堆是一個一端固定、一端可伸縮的vma(圖:左中)。可伸縮的一端通過系統調用brk來調整。libc管理着堆的空間,用戶調用malloc分配內存時,libc盡量從現有的堆中去分配。如果堆空間不夠,則通過brk增大堆空間。
當用戶將已分配的空間free時,libc可能會通過brk減小堆空間。但是堆空間增大容易減小卻難,考慮這樣一種情況,用戶空間連續分配了10塊內存,前9塊已經free。這時,未free的第10塊哪怕只有1字節大,libc也不能夠去減小堆的大小。因為堆只有一端可伸縮,並且中間不能掏空。而第10塊內存就死死地占據着堆可伸縮的那一端,堆的大小沒法減小,相關資源也沒法歸還內核。
當用戶malloc一塊很大的內存時,libc會通過mmap系統調用映射一個新的vma。因為對於堆的大小調整和空間管理還是比較麻煩的,重新建一個vma會更方便(上面提到的free的問題也是原因之一)。
那么為什么不總是在malloc的時候去mmap一個新的vma呢?第一,對於小空間的分配與回收,被libc管理的堆空間已經能夠滿足需要,不必每次都去進行系統調用。並且vma是以page為單位的,最小就是分配一個頁;第二,太多的vma會降低系統性能。缺頁異常、vma的新建與銷毀、堆空間的大小調整、等等情況下,都需要對vma進行操作,需要在當前進程的所有vma中找到需要被操作的那個(或那些)vma。vma數目太多,必然導致性能下降。(在進程的vma較少時,內核采用鏈表來管理vma;vma較多時,改用紅黑樹來管理。)
[用戶的棧]
與堆一樣,棧也是一個vma(圖:左中),這個vma是一端固定、一端可伸(注意,不能縮)的。這個vma比較特殊,沒有類似brk的系統調用讓這個vma伸展,它是自動伸展的。
當用戶訪問的虛擬地址越過這個vma時,內核會在處理缺頁異常的時候將自動將這個vma增大。內核會檢查當時的棧寄存器(如:ESP),訪問的虛擬地址不能超過ESP加n(n為CPU壓棧指令一次性壓棧的最大字節數)。也就是說,內核是以ESP為基准來檢查訪問是否越界。
但是,ESP的值是可以由用戶態程序自由讀寫的,用戶程序如果調整ESP,將棧划得很大很大怎么辦呢?內核中有一套關於進程限制的配置,其中就有棧大小的配置,棧只能這么大,再大就出錯。
對於一個進程來說,棧一般是可以被伸展得比較大(如:8MB)。然而對於線程呢?
首先線程的棧是怎么回事?前面說過,線程的mm是共享其父進程的。雖然棧是mm中的一個vma,但是線程不能與其父進程共用這個vma(兩個運行實體顯然不用共用一個棧)。於是,在線程創建時,線程庫通過mmap新建了一個vma,以此作為線程的棧(大於一般為:2M)。
可見,線程的棧在某種意義上並不是真正棧,它是一個固定的區域,並且容量很有限。