【操做系統—虛擬化】內存分段

介紹

利用基址和界限寄存器,操做系統很容易將不一樣進程重定位到不一樣的物理內存區域。可是,對於這些內存區域,棧和堆之間,有一大塊「空閒」空間。棧和堆之間的空間並無被進程使用,卻依然佔用了實際的物理內存。所以,簡單的經過基址寄存器和界限寄存器實現的虛擬內存很浪費。node

泛化的基址/界限

爲了解決這個問題,分段(segmentation)的概念應運而生。這個想法很簡單,在MMU中引入不止一個基址和界限寄存器對,而是給地址空間內的每一個邏輯段(segment)一對。一個段只是地址空間裏的一個連續定長的區域,在典型的地址空間裏有3個邏輯不一樣的段:代碼、棧和堆。分段的機制使得操做系統可以將不一樣的段放到不一樣的物理內存區域,從而避免了虛擬地址空間中的未使用部分佔用物理內存。算法

咱們來看一個例子,如圖所示,64KB的物理內存中放置了3個段。緩存

image.png

你會想到,須要MMU中的硬件結構來支持分段:在這種狀況下,須要一組3對基址和界限寄存器。下表展現了上面的例子中的寄存器值,每一個界限寄存器記錄了一個段的大小。數據結構

image.png

引用的是哪一個段

硬件在地址轉換時使用段寄存器。它如何知道段內的偏移量,以及地址引用了哪一個段?多線程

一種常見的方式,有時稱爲顯式(explicit)方式,就是用虛擬地址的開頭幾位來標識不一樣的段。在咱們以前的例子中,有3個段,所以須要兩位來標識。若是咱們用14位虛擬地址的前兩位來標識,那麼虛擬地址以下所示:函數

image.png

前兩位告訴硬件咱們引用哪一個段,剩下的12位是段內偏移。所以,硬件就用前兩位來決定使用哪一個段寄存器,而後用後12位做爲段內偏移。偏移量與基址寄存器相加,硬件就獲得了最終的物理地址。請注意,偏移量也簡化了對段邊界的判斷。咱們只要檢查偏移量是否小於界限,大於界限的爲非法地址。性能

上面使用兩位來區分段,但實際只有3個段(代碼、堆、棧),所以有一個段的地址空間被浪費。所以有些系統中會將堆和棧看成同一個段,所以只須要一位來作標識。優化

硬件還有其餘方法來決定特定地址在哪一個段。在隱式(implicit)方式中,硬件經過地址產生的方式來肯定段。例如,若是地址由程序計數器產生(即它是指令獲取),那麼地址在代碼段。若是基於棧或基址指針,它必定在棧段。其餘地址則在堆段。spa

棧的問題

棧和其餘的內存段有一點關鍵區別,就是它反向增加,所以地址轉換必須有所不一樣。首先,咱們須要一點硬件支持。除了基址和界限外,硬件還須要知道段的增加方向(用一位區分,好比1表明自小而大增加,0反之)。操作系統

支持共享

隨着分段機制的不斷改進,人們很快意識到,經過再多一點的硬件支持,就能實現新的效率提高。具體來講,要節省內存,有時候在地址空間之間共享(share)某些內存段是有用的。

爲了支持共享,須要一些額外的硬件支持,這就是保護位(protection bit)。基本上就是爲每一個段增長了幾個位,標識程序是否可以讀寫該段,或執行其中的代碼。經過將代碼段標記爲只讀,一樣的代碼能夠被多個進程共享,而不用擔憂破壞隔離。雖然每一個進程都認爲本身獨佔這塊內存,但操做系統祕密地共享了內存,進程不能修改這些內存,因此假象得以保持。

有了保護位,前面描述的硬件算法也必須改變。除了檢查虛擬地址是否越界,硬件還須要檢查特定訪問是否容許。若是用戶進程試圖寫入只讀段,或從非執行段執行指令,硬件會觸發異常,讓操做系統來處理出錯進程。

操做系統支持

分段也帶來了一些新的問題。第一個是老問題:操做系統在上下文切換時應該作什麼?答案顯而易見:各個段寄存器中的內容必須保存和恢復。每一個進程都有本身獨立的虛擬地址空間,操做系統必須在進程運行前,確保這些寄存器被正確地賦值。

第二個問題更重要,即管理物理內存的空閒空間。新的地址空間被建立時,操做系統須要在物理內存中爲它的段找到空間。以前,咱們假設全部的地址空間大小相同,物理內存能夠被認爲是一些槽塊,進程能夠放進去。如今,每一個進程都有一些段,每一個段的大小也可能不一樣。

通常會遇到的問題是,物理內存很快充滿了許多空閒空間的小洞,於是很難分配給新的段,或擴大已有的段。這種問題被稱爲外部碎片(external fragmentation)[R69],如圖所示。

image.png

該問題的一種解決方案是緊湊(compact)物理內存,從新安排原有的段。例如,操做系統先終止運行的進程,將它們的數據複製到連續的內存區域中去,改變它們的段寄存器中的值,指向新的物理地址,從而獲得了足夠大的連續空閒空間。可是,內存緊湊成本很高,由於拷貝段是內存密集型的,通常會佔用大量的處理器時間。

一種更簡單的作法是利用空閒列表管理算法,試圖保留大的內存塊用於分配。相關的算法可能有成百上千種,包括傳統的最優匹配(best-fit,從空閒鏈表中找最接近須要分配空間的空閒塊返回)、最壞匹配(worst-fit)、首次匹配(first-fit)以及像夥伴算法(buddy algorithm)這樣更復雜的算法。但遺憾的是,不管算法多麼精妙,都沒法徹底消除外部碎片,所以,好的算法只是試圖減少它。

空閒空間管理

咱們暫且將對虛擬內存的討論放在一邊,先來討論空閒空間管理(free-space management)的一些問題。在操做系統用分段(segmentation)的方式實現虛擬內存時,以及在用戶級的內存分配庫(如malloc()和free())中,須要管理的空閒空間由大小不一樣的單元構成。在這兩種狀況下,會出現外部碎片的問題,讓管理變得較爲困難。

假設

首先,咱們假定基本的接口就像malloc()和free()提供的那樣。具體來講,void * malloc(size t size)須要一個參數size,它是應用程序請求的字節數。函數返回一個指針,指向這樣大小的一塊空間。對應的函數void free(void *ptr)函數接受一個指針,釋放對應的內存塊。

進一步假設,咱們主要關心的是外部碎片的問題,先將內部碎片問題置之腦後。咱們還假設,內存一旦被分配給客戶,就不能夠被重定位到其餘位置。最後咱們假設,分配程序所管理的是連續的一塊字節區域。

底層機制

分隔與合併

空閒列表包含一組元素,記錄了堆中的哪些空間尚未分配。假設有下面的30字節的堆:

image.png

這個堆對應的空閒列表以下:

image.png

能夠看出,任何大於10字節的分配請求都會失敗(返回NULL),由於沒有足夠的連續可用空間。可是,若是申請小於10字節空間,會發生什麼?假設咱們只申請一個字節的內存,此時分配程序會執行所謂的分割(splitting)動做:它找到一塊能夠知足請求的空閒空間,將其分割,第一塊返回給用戶,第二塊留在空閒列表中。在咱們的例子中,假設這時遇到申請一個字節的請求,分配程序選擇使用第二塊空閒空間,對malloc()的調用會返回20(1字節分配區域的地址),空閒列表會變成這樣:

image.png

許多分配程序中也有一種機制,名爲合併(coalescing)。若是應用程序調用free(10),歸還堆中間的空間,會發生什麼?分配程序不僅是簡單地將這塊空閒空間加入空閒列表,還會在釋放一塊內存時合併可用空間。經過合併,最後空閒列表應該像這樣:

image.png

追蹤已分配空間的大小

能夠看到,free(void *ptr)接口沒有塊大小的參數。要完成這個任務,大多數分配程序都會在頭結構(header)中保存一點額外的信息,它在內存中的位置一般就在返回的內存塊以前。該頭塊中至少包含所分配空間的大小,它也可能包含一些額外的指針來加速空間釋放,包含一個幻數來提供完整性檢查,以及其餘信息。咱們假定,一個簡單的頭塊包含了分配空間的大小和一個幻數:

typedef struct  header_t {
    int size;
    int magic;
} header_t;

在內存中看起來像是這樣:

image.png

用戶調用free(ptr)時,庫會經過簡單的指針運算獲得頭塊的位置。得到頭塊的指針後,庫能夠很容易地肯定幻數是否符合預期的值,做爲正常性檢查(assert(hptr->magic == 1234567)),並簡單計算要釋放的空間大小(即頭塊的大小加區域長度)。所以,若是用戶請求N字節的內存,庫不是尋找大小爲N的空閒塊,而是尋找N加上頭塊大小的空閒塊。

讓堆增加

大多數傳統的分配程序會從很小的堆開始,當空間耗盡時,再向操做系統申請更大的空間。一般,這意味着它們進行了某種系統調用(例如,大多數UNIX系統中的sbrk),讓堆增加。操做系統在執行sbrk系統調用時,會找到空閒的物理內存頁,將它們映射到請求進程的地址空間中去,並返回新的堆的末尾地址。這時,就有了更大的堆,請求就能夠成功知足。

基本策略

理想的分配程序能夠同時保證快速和碎片最小化。遺憾的是,因爲分配及釋放的請求序列是任意的,任何策略在某些特定的輸入下都會變得很是差。

最優匹配

最優匹配(best fit)策略很是簡單:首先遍歷整個空閒列表,找到和請求大小同樣或更大的空閒塊,而後返回這組候選者中最小的一塊。最優匹配背後的想法很簡單:選擇最接近用戶請求大小的塊,從而儘可能避免空間浪費。然而,簡單的實如今遍歷查找正確的空閒塊時,要付出較高的性能代價。

最差匹配

最差匹配(worst fit)方法與最優匹配相反,它嘗試找最大的空閒塊,分割並知足用戶需求後,將剩餘的塊加入空閒列表。最差匹配嘗試在空閒列表中保留較大的塊,而不是像最優匹配那樣可能剩下不少難以利用的小塊。最差匹配一樣須要遍歷整個空閒列表,大多數研究代表它的表現很是差,致使過量的碎片,同時還有很高的開銷。

首次匹配

首次匹配(first fit)策略就是找到第一個足夠大的塊,將請求的空間返回給用戶。首次匹配有速度優點(不須要遍歷全部空閒塊),但有時會讓空閒列表開頭的部分有不少小塊。所以,分配程序如何管理空閒列表的順序就變得很重要。一種方式是基於地址排序(address-basedordering),經過保持空閒塊按內存地址有序,合併操做會很容易,從而減小了內存碎片。

下次匹配

不一樣於首次匹配每次都從列表的開始查找,下次匹配(next fit)算法多維護一個指針,指向上一次查找結束的位置。其想法是將對空閒空間的查找操做擴散到整個列表中去,避免對列表開頭頻繁的分割。這種策略的性能與首次匹配很接近,一樣避免了遍歷查找。

其餘方式

除了上述基本策略外,人們還提出了許多技術和算法,來改進內存分配。

分離空閒列表

它的基本想法很簡單:若是某個應用程序常常申請一種(或幾種)大小的內存空間,那就用一個獨立的列表,只管理這樣大小的對象。其餘大小的請求都交給更通用的內存分配程序。

這種方法的好處顯而易見。經過拿出一部份內存專門知足某種大小的請求,碎片就再也不是問題了。並且,因爲沒有複雜的列表查找過程,這種特定大小的內存分配和釋放都很快。

不過這種方式也爲系統引入了新的複雜性。例如,應該拿出多少內存來專門爲某種大小的請求服務,而將剩餘的用來知足通常請求?Solaris系統內核設計的厚塊分配程序(slab allocator),很優雅地處理了這個問題。

具體來講,在內核啓動時,它爲可能頻繁請求的內核對象建立一些對象緩存(object cache),如鎖和文件系統inode等。這些的對象緩存每一個分離了特定大小的空閒列表,所以可以很快地響應內存請求和釋放。若是某個緩存中的空閒空間快耗盡時,它就向通用內存分配程序申請一些內存厚塊(slab)(總量是頁大小和對象大小的公倍數)。相反,若是給定厚塊中對象的引用計數變爲0,通用的內存分配程序能夠從專門的分配程序中回收這些空間,這一般發生在虛擬內存系統須要更多的空間的時候。

厚塊分配程序比大多數分離空閒列表作得更多,它將列表中的空閒對象保持在預初始化的狀態。數據結構的初始化和銷燬的開銷很大,經過將空閒對象保持在初始化狀態,厚塊分配程序避免了頻繁的初始化和銷燬,從而顯著下降了開銷。

夥伴系統

由於合併對分配程序很關鍵,因此人們設計了一些方法,讓合併變得簡單,一個好例子就是二分夥伴分配程序(binary buddy allocator)。

在這種系統中,空閒空間首先從概念上被當作大小爲2ⁿ的大空間。當有一個內存分配請求時,空閒空間被遞歸地一分爲二,直到恰好能夠知足請求的大小。這時,請求的塊被返回給用戶。在下面的例子中,一個64KB大小的空閒空間被切分,以便提供7KB的塊:

image.png

請注意,這種分配策略只容許分配2的整數次冪大小的空閒塊,所以會有內部碎片(internal fragment)的麻煩。

夥伴系統的精髓在於內存被釋放的時候。若是將這個8KB的塊歸還給空閒列表,分配程序會檢查「夥伴」8KB是否空閒。若是是,就合二爲一,變成16KB的塊。而後會檢查這個16KB塊的夥伴是否空閒,若是是,就合併這兩塊。這個遞歸合併過程繼續上溯,直到合併整個內存區域,或者某一個塊的夥伴尚未被釋放。

夥伴系統運轉良好的緣由,在於很容易肯定某個塊的夥伴。仔細觀察的話,就會發現每對互爲夥伴的塊只有一位不一樣,正是這一位決定了它們在整個夥伴樹中的層次

其餘想法

上面提到的衆多方法都有一個重要的問題,缺少可擴展性(scaling)。具體來講,就是查找列表可能很慢。所以,更先進的分配程序採用更復雜的數據結構來優化這個開銷,犧牲簡單性來換取性能。例子包括平衡二叉樹、伸展樹和偏序樹。

考慮到現代操做系統一般會有多核,同時會運行多線程的程序,所以人們作了許多工做,提高分配程序在多核系統上的表現。感興趣的話能夠深刻閱讀glibc分配程序的工做原理。

相關文章
相關標籤/搜索