引言:前面連續幾章講述的文件系統是存儲系統的外存管理的一種抽象,而虛擬內存則是存儲系統的內存管理的一種抽象。其實這兩種原理有相似地地方,當然也就有不同的地方。同時這兩者也屬於操作系統內核的范疇。
1、虛擬內存的概念
虛擬內存又叫虛擬存儲器(Virtual Memory),虛擬內存是計算機系統內存管理的一種技術。
我們都知道,進程運行前必須將程序加載到內存中,而根據Parkinson定律“存儲有多大,程序就會有多長”,所以如何有效的管理內存一直是計算機需要解決的問題,也因此提出了很多簡單高效的方案,例如虛擬內存是其中之一。所以虛擬內存是因為內存不足而提出來的,而目前這種技術也已經普遍應用在大多數操作系統中,具體實現起來可能稍有不同。
虛擬內存簡單的定義就是把進程在內存和磁盤之間換進換出。如何換?頁面置換。當然這只是比較常見的方法,也還有其他方法或者幾種方法的組合。
2、交換技術
在虛擬內存技術提出之前,其實已有另一種更簡單更直接的技術:交換技術。
交換技術,就是把各個進程完整地調入內存,運行一段時間后,然后再放回到磁盤上。
而虛擬內存,只需把進程的一部分內容存放在內存中,而且也能保證進程的正常運行。
這兩種方式雖然加載的對象大小不相同,但是都需要進程的換進換出。同時,進程的堆棧是實時變化的,那么該如何管理內存空間呢?
其中,操作系統的內核進程是常駐在內存,一般固定在內存空間地址最低端,如上圖。32位系統中,一般情況下固定大小1GB為系統區,而其他3GB為用戶區。
2.1 空閑塊的管理
內存是動態分配的,那么如何記錄當前內存的使用情況?有以下兩種方式。
(1)位圖法
在位圖法中,內存被划分為很多個單元,每個單元對應於位圖中的某個數據位,0表示空閑,1表示占用。如下圖顯示了部分的內存和相應的位圖。
分配單元的大小是一個很重要的設計問題,分配單元越小,位圖越大。位圖法,簡單;但是查找一串K個連續的0,比較復雜和緩慢。
(2)鏈表法
對內存的管理建立一個鏈表來管理已分配和空閑的內存空間。如上圖的(c),P 代表進程Process,H表示空閑Hole,接下來兩個字段依次表示起始地址和長度,最后一個字段是指向下一個節點的指針。在這個例子中,鏈表是按照地址從低到高排序的。這樣做的好處是當一個進程運行結束或被置換出去時,可以很方便地來更新鏈表。
2.2 空閑塊的查找
當一個新的程序加載進來的時候又該如何找到空閑的地址空間?通常的分配算法有最先匹配法、下次匹配法、最佳匹配法、最壞匹配法和快速匹配法。這些算法特別是前面幾個都有些類似,我們簡單介紹下。
最先匹配法的基本思路是:假如進程的大小M,從鏈表的首節點開始查找,每個空閑塊的長和M比較,是否大於或等於它,直到找到第一個符合要求的節點。
下次匹配法是在上次查找的結果基礎下繼續查找直到匹配成功。
最佳匹配法需要遍歷所有節點,找到能裝得下的最小空閑塊。而最壞匹配法則相反,找能裝得下的最大的空閑塊。事實上,這兩種都有比較而且遍歷所有鏈表,效果不佳。
快速匹配法和其他不太一樣,基本思路是:對於一些常用的請求大小例如2K、4K、8K等,為它們分別設置鏈表。這樣查找匹配非常快,但是如果程序結束時或者被置換后,可能需要合並左鄰右舍等操作操作復雜,如果不能合並則可能形成小空洞碎片。
對於這些碎片或者說一些不連續的小黑洞,可以采用內存緊縮(memory compation)技術:把所有的進程都盡可能地往內存地址的低端移動,相應地,那些空閑的小分區就會往高端移動,從而在地址高端形成一個較大的空閑區。
3、虛擬內存
上面的交換技術是把程序作為一個整體放入內存。但如果程序太大了,超出了空閑內存的容量,沒辦法裝入,該怎么辦?事實上大部分可能是這種情況。
當時人們通常采用的解決方案是覆蓋技術,即:把程序划分成若干個部分,每個部分叫做覆蓋塊(overlay),然后把那些當前需要用到的指令和數據的覆蓋塊保存在內存中,其他放外存,運行一段時候后,在內外存之間再交換所需的覆蓋塊。
雖然覆蓋塊的交換是由操作系統完成,但是覆蓋塊的划分最開始由程序員來手工完成,這是一項非常復雜的工作,費時費力。后不久人們又想到了一種辦法,可以把工作交給計算機完成。
這種方法叫做虛擬存儲器(Virtual Memory)(Fotheringham,1961),我們一般稱作虛擬內存。它的基本思路是:程序的代碼、數據和棧的總大小可以超過實際可用的物理內存的大小。操作系統把當前需要的那些部分保留在內存中,而把其余部分保持在磁盤上。然后在再需要的時候,再把各個程序片段在內存和磁盤之間來回交換。
3.1 分頁
大部分虛擬存儲系統采用的是一種稱為分頁(paging)的技術。這種方式叫做虛擬頁式存儲管理。
由程序產生的地址稱為虛擬地址(virtual address),它們構成了一個虛擬地址空間(virtual address space)。虛擬地址也叫做線性地址(linear address)。
如果計算機沒有使用虛擬存儲機制,那么虛擬地址就是最終的物理地址,它被直接放在地址總線上,從而可以對相應地址的內存單元進行讀寫操作。如果計算機使用了虛擬存儲機制,那么虛擬地址不是直接放在地址總線上,而是被送到存儲管理單元(Memory Management Unit,MMU),由它負責把虛擬地址映射為物理地址。MMU一般集成在CPU芯片內部,從邏輯上講,它可以是單獨的一個芯片。
物理內存空間划分為固定大小的內存塊,稱為物理頁面,或者是頁框(page frame)。
虛擬地址空間也划分成大小相同的塊,稱為虛擬頁面,或者簡稱頁面(page)。
頁面和頁框的大小通常是一樣的,要求是2的整數次冪,一般在512字節到1G字節之間。程序在換入換出的時候是以頁面為單位。
MMU可以完成虛擬地址到物理地址的映射,但是我們知道,虛擬地址空間是遠遠大於物理地址空間即內存空間的,所以也就不能保證所有虛擬地址能找到對應的物理地址,即無法完成映射。此時,MMU會引發一個缺頁中斷(page fault),把這個問題交給操作系統處理。操作系統從內存中挑選一個使用不多的物理頁面,把它的內容寫回到磁盤,從而騰出了一個空閑頁面,然后把引發缺頁中斷的那個虛擬頁面裝入該空閑頁面中,並對地址映射進行更新。最后回到被中斷的指令重新開始。
下面我們來看看MMU的內部結構,了解一下它的工作原理。舉例:頁面大小4KB、虛擬地址空間是64KB、物理內存是32KB,因此可得到16個虛擬頁面和8個物理頁面。如下圖所示,虛擬地址8196(二進制是0010 0000 0000 0100),輸入的16位虛擬地址被划分為兩部分:4位的頁號和12位的偏移量。4位的頁號可以表示16個頁面,12位的偏移量可以尋址4096個字節。
在進行地址映射時,使用虛擬頁面號作為索引去訪問頁表(page table),從而得到相應的物理地址。如果有效位(頁表最低位)為0,則產生缺頁中斷,陷入操作系統中;否則將頁表查到的物理頁面號加上偏移量,就得到了15位的物理地址。
3.2 頁表
如上所述,虛擬地址被分為虛擬頁面號(高位)和偏移量(低位)兩部分。高4位指定虛擬頁面號,也可以是3位、5位或其他,不同的划分表示不同的頁面大小。
頁表的用途是將虛擬頁面映射為相應的物理頁面。
上述例子中只是16位的虛擬地址空間,那么32位的虛擬地址空間為4GB,64位的地址空間可達16EB(雖然我們普遍不用這么多位),如果將頁面映射放在一個頁表中,那么頁表項將非常龐大。此外由於每個進程都有自己的虛擬地址空間,因此每個進程也有自己的頁表。這樣,頁表的數量和規模將更加龐大。有沒有一種大而快速的頁面映射解決方案呢?分級。
(1)多級頁表
多級頁表的基本思路是:雖然進程的虛擬地址空間很大,但是當進程在運行時,並不會用到所有的虛擬地址,所以沒必要把所有的頁表項都保存在內存中。
如下圖,一個典型的二級頁表的例子,虛擬地址為32位,頁面大小4KB。虛擬頁面號為20位,分成兩級,最高10位表示頁目錄,中間10位為頁表,從而形成10+10+12的二級頁表。 二級頁表也可以擴展為三級、四級或更多級。64位處理器典型地划分為9+9+9+9+12的四級頁表。更多的級別帶來了更多的靈活性,但算法的復雜性也會更高。
(2)頁表項
頁目錄項和頁表項具有相同的結構,但不同的CPU對頁表項的具體安排會有所不同,我們討論一些共性。如下圖,給出了一個頁表項的示例。頁表項的長度因機器而異,一般使用的32位即4字節。
物理頁面號------最重要的就是物理頁面號,頁映射的目的就是找到這個值。
有效位------1表示該表項是有效的,可以使用;0則表示該表項對應的虛擬頁面現在不在內存中,訪問該頁面會引起一個缺頁中斷。
保護位------指出一個頁允許什么樣的方式訪問,最簡單的形式是只有一位,0表示讀/寫,1表示只讀;更先進的方式是使用三位,各位分別表示是否啟用讀、寫、執行該頁面。
修改位------記錄頁面的使用情況,在寫入一個頁時自動設置修改位。如果一個頁面已經被修改過(稱為“臟頁面”),則必須把它寫會磁盤。如果沒有被修改過(稱為“干凈頁面”),可以直接被覆蓋,因為它在磁盤上有備份。修改位也稱為臟位,反映了頁面的當前狀態。
訪問位------不論是讀還是寫,系統都會在該頁面被訪問時設置訪問位。用於頁面置換算法中,未被訪問的通常認為是不經常使用的而被置換出去。頁面置換我們稍后介紹。
禁止緩存位------禁止該頁面被高速緩存,對於映射到設備寄存器而不是常規內存的頁面很重要。具有獨立的I/O空間而不使用內存映射I/O的機器不需要這一位。高速緩存在下一章介紹。
3.3 關聯存儲器TLB
從上面我們可以看出,每一次內存訪問,都需要兩次訪問頁表,而隨着頁表的增多,整體性能是會受很大影響的。后面人們發現絕大多數程序運行時,在任意一個階段都只會訪問一小部分的頁面,而非所有頁面。這就是訪問的局部性原理,或者說程序局部性原理。
人們利用這個特性為計算機設計和增加了一種快速查找的硬件,即TLB(Translation lookaside Buffer)或者稱為關聯存儲器(associative memory),用來存放最常用的頁表項。這種硬件設備可以直接把虛擬地址映射到物理地址,而不必訪問內存,所以簡稱為快表。TLB通常位於MMU中,只包含了少量的表項,書上說不超過64,網上有的說不超過256,總之非常少。
工作過程:當一個虛擬地址到來時,MMU首先會到TLB中查找,這個查找非常快,因為它是並行的方式,即同時與所有的頁表項進行比較。如果找到了,直接取出物理頁面號。否則如果權限夠的話將再去內存中查找所需的物理頁面號;然后,再將找到的物理頁面號所在的頁表項添加到TLB中,同時驅逐TLB中某一個頁表項;最后將被驅逐的頁表項的修改位復制到對應的內存中的頁表項。
軟件TLB管理
上面我們描述的硬件TLB,TLB的管理和TLB未命中時的處理都交由MMU硬件完成,只有當頁面不在內存中時才會陷入到操作系統。
而在現代有些機器中,幾乎所有的頁面管理工作都是有軟件來完成,TLB表項也由操作系統負責載入。如果發生TLB未命中,MMU會產生一個TLB中斷,把問題交給操作系統。操作系統來對TLB進行頁面置換,但是這項工作必須用很少的命令完成,因為TLB未命中的頻率遠遠高於缺頁中斷的頻率。為此,人們也設計出了一些方法來提高未命中的概率,例如在內存固定位置設置一個較大的緩沖區,存放最近常用的TLB表項;或者預測常用的頁面預先裝入TLB中。
3.4 反置頁表
前面我們講述的頁表方案是通過進程的虛擬頁面號來組織的,用虛擬頁表號來作為訪問頁表的索引。如果頁面大小4KB,32位尋址時,一個進程的頁表項個數是100萬。如果再按每個頁表項長度是4字節,一個進程的頁表需要4MB的內存空間。這是32位,如果變成64位尋址,需要的內存顯然是個天文數字。
一種解決方案就是反置頁表(inverted page table),也稱作倒排頁表,根據內存的物理頁面號來組織頁表,用物理頁面號作為訪問頁表的索引。有多少個物理頁面,就在頁表中設置多少個頁表項。而一般情況下物理頁面遠遠小於虛擬頁面,所以這種方法節省了大量的內存空間。但同時也帶來一個問題,即從虛擬頁面號到物理頁面號的轉換變得復雜。必須搜索整個頁表。
擺脫這種困境就是使用TLB。因為TLB存放了我們經常訪問的頁面。不過如果TLB未命中,則仍然需要對整個反置頁表進行搜索。為了加快加快這個過程,人們又想到了一個辦法,使用虛擬地址建立一個哈希表。如果兩個虛擬頁面具有相同的哈希值,那么它們就用鏈表連起來。(這種方法是不是似曾相識呢:Redis+MySQL,后面提到LRU方法亦是。)
4、其他
以上對虛擬內存的頁式存儲管理的基本原理已經介紹完成,我們再深入了解幾個知識點。最后補充一下其他的存儲管理方式。
4.1 頁面置換的算法和策略
虛擬內存的核心就是進程的換入換出,也就是缺頁中斷進行頁面置換。問題來了,如何選擇被置換的頁面?頁面的換進換出是需要開銷的,所以一個好的頁面算法就是盡量減少頁面換進換出的次數。
最優算法------易於描述但無法實現,一般用作目標或者說算法性能評價的依據。思路是:對於每一個虛擬頁面,都計算出下一次訪問的時間,用指令數來計算,然后選擇等待時間最長的那個頁面。明顯的比較理想,虛擬頁面多而且下一次訪問也是不確定的。
最近未使用算法------Not Recently Used,NRU。按頁表項中的訪問位和修改位的值對頁面進行分成四類,對應四個值,值越小,越沒被使用過。然后在值最小的那類再隨機抽取一個頁面。
先進先出算法------First In First Out,FIFO。把最先訪問的頁面放在鏈表的首部,后面訪問的再依次排隊在鏈表的首部,最先的變成了尾部,選擇的就是鏈尾頁面。該算法可能會淘汰一些不常用的頁面,但是也存在淘汰一些常用只是暫時沒用的頁面。
第二次機會算法------針對FIFO進行了改進,根據FIFO得到一個頁面時不是直接淘汰,而是再給一次機會,把它放在鏈表的首部。
時鍾算法------第二次機會需要移動鏈表節點,時鍾算法將鏈表變成環形,首尾相連。
最近最久未使用------Least Recently Used,LRU。選擇最久未被使用的頁面。最優算法的一個近似,它的理論依據是程序的局部性原理。如果某個頁面被訪問了,它很有可能馬上被訪問;同樣如果它很久沒被訪問,那么將來可能很長時間也不會被訪問。在LRU基礎上,利用頁表項中的訪問位和修改位這兩個值和二進制移位,得到改進的算法,叫老化算法。減少了LRU鏈表的操作。
上面討論的算法是在一個進程內部,如果在相互競爭的進程之間如何分配呢?有兩種策略。
局部分配策略------為每個進程分配固定大小的內存空間。
全局分配策略------所有進程可以動態分配內存空間。通常情況下比局部策略更好,置換頁面的時候可以考慮整個內存空間,減少缺頁發送的次數。
4.2 工作集模型
頁式存儲管理中,進程啟動之初,所有頁面都在外存,所以CPU去取第一個頁面時,會引發缺頁中斷。隨着是一系列的缺頁中斷,一段時間后,中斷次數會減少。這種策略就叫做請求調頁(demand paging),根據需要隨要隨調。
而前面我們介紹過局部性原理,絕大多數進程實際訪問的頁面只是很小的一部分,我們把一個進程當前正在使用的頁面集合叫做它的工作集(working set)。如果我們在程序運行前就預先裝入它的頁面,這種技術叫做預先調頁(prepaging)。而裝入的頁面就是進程運行所需的工作集,這種方法叫做工作集模型。為了實現工作集模型,操作系統必須知道哪些頁面屬於工作集,一種方法就是前面討論的老化算法。當然,隨着時間的變化,進程的工作集也會發生變化。一般工作集具有漸進式、緩慢變化的特點。
但是進程的工作集有時會發送劇烈的變動,它的運行可能進入一個調整期。如果分配給一個進程的物理頁面數太少,不能包含整個工作集,這是進程會造成很多的缺頁中斷,需要頻繁的在內存和外存之間置換頁面,從而使得進程的運行速度變慢,我們把這種狀態稱作抖動(Denning)。同時我們也應該要優化我們的代碼盡量減少和避免這種情況的發生!
4.3 頁面大小
頁面大小在頁式存儲管理系統中是一個非常重要的參數,也是一個可以自定義的參數。查看系統中頁的大小:
[root@localhost mysql]# getconf PAGESIZE
4096
內核是以頁面作為內存管理的單位,內存分配時,每次分配都是頁面大小的整數倍。頁面越小,內碎片就會越少。分配的內存一般不會是頁面的整數倍,最后一個頁面剩下的空間叫做內碎片。頁面越小,同時頁表就越龐大,進程運行時系統開銷也會越大。
另外,在內存和磁盤之間傳送數據也是以頁面為單位的。所以文件系統的邏輯塊大小最好和頁面大小保持一致。
大多數計算機使用的頁面大小在512字節到1M之間,典型的值是1KB、4KB和8KB。現在隨着內存容量越來越大,頁面大小也越來越大。
4.4 段式和段頁式存儲管理
前面介紹都是跟分頁方式相關的虛擬內存。事實上,虛擬內存的調度方式有分頁式、段式、段頁式3種。只是現在大部分的操作系統使用了分頁式,而且理解了分頁式,再來看其他兩種就非常容易了。
(1)段式存儲管理
分頁式存儲管理是一維的,而段式則是二維的。即分頁式存儲的虛擬地址從0到某一個最大地址,一個接一個。而段式存儲提供多個相互獨立的地址空間,稱為段(segment);每個段的內部都是從0到某一個最大值這樣一個線性地址,段的長度可以動態變化。
分段有助於在幾個進程之間共享函數和數據,一個典型的例子就是共享庫(shared library)。頁式存儲管理也可以實現共享庫,但是要復雜得多,實際上它們都是通過模擬分段來實現的。
在具體實現上,段式和頁式存儲系統是完全不同的:頁面是定長的而段不是。所以,隨着程序的運行,段式存儲很容易形成外碎片(external fragmentation)或者叫做跳棋盤(checkerboarding)。外碎片通常比較小,無法再裝入新的段,容易造成浪費。當然這個問題也可以通過前面2.2節講過的緊縮技術來解決。
(2)段頁式存儲管理
Intel Pentinum支持16K個段,每個段最多可以容納4GB的虛擬地址空間。操作系統可以對其進行設置,使他支持純頁式、純段式或者段頁式存儲管理。大多數操作系統如Linux和Windows都采用純頁式存儲管理。
Pentinum虛擬存儲器的核心是兩張表,局部描述符(Local Descriptor Table,LDT)和全局描述符(Global Descriptor Table,GDT)。每個進程都有自己的LDT,但是GDT只有一個,為計算機上所有進程共享。LDT描述的是每個程序自己的段,包括代碼段、數據段、棧段等,而GDT描述的是系統的段,包括操作系統本身。
上圖是Pentinum代碼段描述符的結構,數據段略有不同。本文不再鋪開描述了,有興趣可以參考:分段機制與GDT|LDT。
推薦閱讀:Linux內核之 內存管理
參考資料:
《操作系統設計與實現》第三版 上冊。
《深入理解LINUX內核》第三版。