操作系統中的 CPU 和主內存(Main memory)都是稀缺資源,所有運行在當前操作系統的進程會共享系統中的 CPU 和內存資源,操作系統會使用 CPU 調度器分配 CPU 時間[^1]並引入虛擬內存系統以管理物理內存,本文會分析操作系統為什麼需要虛擬內存。
在回答虛擬內存存在的必要性之前,我們需要理解操作系統中的虛擬內存是什麼,它在操作系統中起到什麼樣的作用。正如軟件工程中的其他抽象, 虛擬內存是操作系統物理內存和進程之間的中間層,它為進程隱藏了物理內存這一概念,為進程提供了更加簡潔和易用的接口以及更加複雜的功能。
virtual-memory-layer
圖 1 - 進程和操作系統的中間層
如果需要我們從頭設計一個操作系統,讓系統中的進程直接訪問主內存中的物理地址應該是非常自然的決定,早期的操作系統確實也都是這麼實現的,進程會使用目標內存的物理地址(Physical Address)直接訪問內存中的內容,然而現代的操作系統都引入了虛擬內存,進程持有的虛擬地址(Virtual Address)會經過內存管理單元(Memory Mangament Unit)的轉換變成物理地址[^2],然後再通過物理地址訪問內存:
virtual-memory-system
圖 2 - 虛擬內存系統
主存儲是相對比較稀缺的資源,雖然順序讀取只比磁盤快 1 個數量級,但是它能提供極快的隨機訪問速度,從內存上隨機讀取數據是磁盤的 100,000 倍[^3],充分利用內存的隨機訪問速度是改善程序執行效率的有效方式。
操作系統以頁為單位管理內存,當進程發現需要訪問的數據不在內存時,操作系統可能會將數據以頁的方式加載到內存中,這個過程是由上圖中的內存管理單元(MMU)完成的。操作系統的虛擬內存作為一個抽象層,起到了以下三個非常關鍵的作用:虛擬內存可以利用磁盤起到緩存的作用,提高進程訪問指定內存的速度;虛擬內存可以為進程提供獨立的內存空間,簡化程序的鏈接、加載過程並通過動態庫共享內存;虛擬內存可以控制進程對物理內存的訪問,隔離不同進程的訪問權限,提高系統的安全性;緩存
我們可以將虛擬內存看作是在磁盤上一片空間,當這片空間中的一部分訪問比較頻繁時, 該部分數據會以頁為單位被緩存到主存中以加速 CPU 訪問數據的性能,虛擬內存利用空間較大的磁盤存儲作為『內存』並使用主存儲緩存進行加速, 讓上層認為操作系統的內存很大而且很快,然而區域很大的磁盤並不快,而很快的內存也並不大。
virtual-memory-cache
圖 3 - 虛擬內存、主存和磁盤
虛擬內存中的虛擬頁(Virtual Page,PP)可能處於以下的三種狀態 — 未分配(Unallocated)、未緩存(Uncached)和已緩存(Cached),其中未分配的內存頁是沒有被進程申請使用的,也就是空閒的虛擬內存,不佔用虛擬內存磁盤的任何空間,未緩存和已緩存的內存頁分別表示已經加載到主存中的內存頁和僅加載到磁盤中的內存頁。如上圖所示,圖中綠色的虛擬內存頁由主存中的物理內存頁(Physical Page,PP)支撐,所以它是已經緩存過的,而黃色的虛擬內存頁僅在磁盤中,所以沒有被物理內存緩存。
當用户程序訪問未被緩存的虛擬頁時,硬件就會觸發缺頁中斷(Page Fault,PF),在部分情況下,被訪問的頁面已經加載到了物理內存中,但是用户程序的頁表(Page Table)並不存在該對應關係,這時我們只需要在頁表中建立虛擬內存到物理內存的關係;在其他情況下,操作系統需要將磁盤上未被緩存的虛擬頁加載到物理內存中[^4]。
page-fault
圖 4 - 虛擬內存的缺頁中斷
因為主內存的空間是有限的,當主內存中不包含可以使用的空間時,操作系統會從選擇合適的物理內存頁驅逐回磁盤,為新的內存頁讓出位置,選擇待驅逐頁的過程在操作系統中叫做頁面替換(Page Replacement)。缺頁中斷和頁面替換技術都是操作系統調頁算法(Paging)的一部分,該算法的目的就是充分利用內存資源作為磁盤的緩存以提高程序的運行效率。
內存管理
虛擬內存可以為正在運行的進程提供獨立的內存空間,製造一種每個進程的內存都是獨立的假象,在 64 位的操作系統上,每個進程都會擁有 256 TiB 的內存空間,內核空間和用户空間分別佔 128 TiB[^5],部分操作系統使用 57 位虛擬地址以提供 128 PiB 的尋址空間[^6]。因為每個進程的虛擬內存空間是完全獨立的,所以它們都可以完整的使用 0x0000000000000000 到 0x00007FFFFFFFFFFFF 的全部內存。
virtual-memory-space
圖 5 - 操作系統的虛擬內存空間
虛擬內存空間只是操作系統中的邏輯結構,就像我們上面説的,應用程序最終還是需要訪問物理內存或者磁盤上的內容。因為操作系統加了一個虛擬內存的中間層,所以我們也需要為進程實現地址翻譯器,實現從虛擬地址到物理地址的轉換,頁表是虛擬內存系統中的重要數據結構,每一個進程的頁表中都存儲了從虛擬內存到物理內存頁的映射關係,為了存儲 64 位操作系統中 128 TiB 虛擬內存的映射數據,Linux 在 2.6.10 中引入了四層的頁表輔助虛擬地址的轉換[^7],在 4.11 中引入了五層的頁表結構[^8],在未來還可能會引入更多層的頁表結構以支持 64 位的虛擬地址。
four-level-page-tables
圖 6 - 四層頁表結構
在如上圖所示的四層頁表結構中,操作系統會使用最低的 12 位作為頁面的偏移量,剩下的 32 位會分四組分別表示當前層級在上一層中的索引,所有的虛擬地址都可以用上述的多層頁表查找到對應的物理地址。
因為有多層的頁表結構可以用來轉換虛擬地址,所以多個進程可以通過虛擬內存共享物理內存。我們在 為什麼 Redis 快照使用子進程 一文中介紹的寫時複製就利用了虛擬內存的這個特性,當我們在 Linux 中調用 fork 創建子進程時,實際上 只複製了父進程的頁表。如下圖所示,父子進程會通過不同的頁表指向相同的物理內存:
process-shared-memory
圖 7 - 進程間共享內存
虛擬內存不僅可以在 fork 時用於共享進程的物理內存,提供寫時複製的機制,還能共享一些常見的動態庫減少物理內存的佔用,所有的進程都可能調用相同的操作系統內核代碼,而 C 語言程序也會調用相同的標準庫。
除了能夠共享內存之外,獨立的虛擬內存空間也會簡化內存的分配過程,當用户程序向操作系統申請堆內存時,操作系統可以分配幾個連續的虛擬頁,但是這些虛擬頁可以對應到物理內存中不連續的頁中。
內存保護
操作系統中的用户程序不應該修改只讀的代碼段,也不應該讀取或者修改內核中的代碼和數據結構或者訪問私有的以及其他的進程的內存,如果無法對用户進程的內存訪問進行限制,攻擊者就可以訪問和修改其他進程的內存影響系統的安全。
如果每一個進程都持有獨立的虛擬內存空間,那麼虛擬內存中頁表可以理解成進程和物理頁的『連接表』,其中可以存儲進程和物理頁之間的訪問關係,包括讀權限、寫權限和執行權限:
virtual-memory-permission
圖 8 - 讀權限、寫權限和執行權限
內存管理單元可以決定當前進程是否有權限訪問目標的物理內存,這樣我們就最終將權限管理的功能全部收斂到虛擬內存系統中,減少了可能出現風險的代碼路徑。
總結
虛擬內存的設計方法可以説是軟件工程中的常見手段,通過結合磁盤和內存各自的優勢, 利用中間層對資源進行更合理地調度充分提高資源的利用率並提供和諧以及統一的抽象,而在實際的業務場景中,類似的緩存邏輯也比較常見。
操作系統的虛擬內存是非常複雜的組件,沒有工程師能夠了解其中的全部細節,不過了解虛擬內存的整體設計也很有價值,我們能夠從中找到很多軟件設計的方法。我們重新回到今天的問題 — Linux 操作系統中為什麼需要虛擬內存:虛擬內存可以結合磁盤和物理內存的優勢為進程提供看起來速度足夠快並且容量足夠大的存儲;虛擬內存可以為進程提供獨立的內存空間並引入多層的頁表結構將虛擬內存翻譯成物理內存,進程之間可以共享物理內存減少開銷,也能簡化程序的鏈接、裝載以及內存分配過程;虛擬內存可以控制進程對物理內存的訪問,隔離不同進程的訪問權限,提高系統的安全性;
到最後,我們還是來看一些比較開放的相關問題,有興趣的讀者可以仔細思考一下下面的問題:為什麼每層的頁表結構只能夠負責 8 位虛擬地址的尋址?