主存(RAM)
是一件非常重要的資源,必須要小心對待內存。雖然目前大多數內存的增長速度要比 IBM 7094 要快的多,但是,程序大小的增長要比內存的增長還快很多。正如帕金森定律說的那樣:不管存儲器有多大,但是程序大小的增長速度比內存容量的增長速度要快的多
。下面我們就來探討一下操作系統是如何創建內存並管理他們的。
經過多年的探討,人們提出了一種 分層存儲器體系(memory hierarchy)
,下面是分層體系的分類
頂層的存儲器速度最高,但是容量最小,成本非常高,層級結構越向下,其訪問效率越慢,容量越大,但是造價也就越便宜。
操作系統中管理內存層次結構的部分稱為內存管理器(memory manager)
,它的主要工作是有效的管理內存,記錄哪些內存是正在使用的,在進程需要時分配內存以及在進程完成時回收內存。
下面我們會對不同的內存管理模型進行探討,從簡單到復雜,由於最低級別的緩存是由硬件進行管理的,所以我們主要探討主存模型和如何對主存進行管理。
無存儲器抽象
最簡單的存儲器抽象是沒有存儲。早期大型計算機(20 世紀 60 年代之前),小型計算機(20 世紀 70 年代之前)和個人計算機(20 世紀 80 年代之前)都沒有存儲器抽象。每一個程序都直接訪問物理內存。當一個程序執行如下命令:
MOV REGISTER1, 1000
計算機會把位置為 1000 的物理內存中的內容移到 REGISTER1
中。因此,那時呈現給程序員的內存模型就是物理內存,內存地址從 0 開始到內存地址的最大值中,每個地址中都會包含一個 8 位位數的單元。
所以這種情況下的計算機不可能會有兩個應用程序同時
在內存中。如果第一個程序向內存地址 2000 的這個位置寫入了一個值,那么此值將會替換第二個程序在該位置上的值,所以,同時運行兩個應用程序是行不通的,兩個程序會立刻崩潰。
不過即使存儲器模型就是物理內存,還是存在一些可選項的。下面展示了三種變體
在上圖 a 中,操作系統位於 RAM(Random Access Memory)
的底部,或像是圖 b 一樣位於 ROM(Read-Only Memory)
頂部;而在圖 c 中,設備驅動程序位於頂端的 ROM 中,而操作系統位於底部的 RAM 中。圖 a 的模型以前用在大型機和小型機上,但現在已經很少使用了;圖 b 中的模型一般用於掌上電腦或者是嵌入式系統中。第三種模型就應用在早期個人計算機中了。ROM 系統中的一部分成為 BIOS (Basic Input Output System)
。模型 a 和 c 的缺點是用戶程序中的錯誤可能會破壞操作系統,可能會導致災難性的后果。
按照這種方式組織系統時,通常同一個時刻只能有一個線程正在運行。一旦用戶鍵入了一個命令,操作系統就把需要的程序從磁盤復制到內存中並執行;當進程運行結束后,操作系統在用戶終端顯示提示符並等待新的命令。收到新的命令后,它把新的程序裝入內存,覆蓋前一個程序。
在沒有存儲器抽象的系統中實現並行性的一種方式是使用多線程來編程。由於同一進程中的多線程內部共享同一內存映像,那么實現並行也就不是問題了。
運行多個程序
但是,即便沒有存儲器抽象,同時運行多個程序也是有可能的。操作系統只需要把當前內存中所有內容保存到磁盤文件中,然后再把程序讀入內存即可。只要某一時間只有一個程序,那么就不會產生沖突。
在額外特殊硬件的幫助下,即使沒有交換功能,也可以並行的運行多個程序。IBM 360 的早期模型就是這樣解決的
System/360是 IBM 在1964年4月7日,推出的划時代的大型電腦,這一系列是世界上首個指令集可兼容計算機。
在 IBM 360 中,內存被划分為 2KB 的區域塊,每塊區域被分配一個 4 位的保護鍵,保護鍵存儲在 CPU 的特殊寄存器中。一個內存為 1 MB 的機器只需要 512 個這樣的 4 位寄存器,容量總共為 256 字節 (這個會算吧。) PSW(Program Status Word, 程序狀態字)
中有一個 4 位碼。一個運行中的進程如果訪問鍵與其 PSW 碼不同的內存,360 硬件會發現這種情況,因為只有操作系統可以修改保護鍵,這樣就可以防止進程之間、用戶進程和操作系統之間的干擾。
這種解決方式是有一個缺陷。如下所示,假設有兩個程序,每個大小各為 16 KB
從圖上可以看出,這是兩個不同的 16KB 程序的裝載過程,a 程序首先會跳轉到地址 24,那里是一條 MOV
指令,然而 b 程序會首先跳轉到地址 28,地址 28 是一條 CMP
指令。這是兩個程序被先后加載到內存中的情形,假如這兩個程序被同時加載到內存中從 0 地址處開始執行,內存的狀態就如上面 c 圖所示,程序裝載完畢開始運行,第一個程序首先從 0 地址處開始運行,執行 JMP 24 指令,然后依次執行后面的指令(許多指令沒有畫出),一段時間后第一個程序執行完畢,然后開始執行第二個程序。第二個程序的第一條指令是 28,這條指令會使程序跳轉到第一個程序的 ADD
處,而不是事先設定的跳轉指令 CMP,由於內存地址的不正確訪問,這個程序可能在 1秒內就崩潰了。
上面兩個程序同時執行最核心的問題是都引用了絕對物理地址。這不是我們想要看到的。我們想要的是每一個程序都會引用一個私有的本地地址。IBM 360 在第二個程序裝載到內存中的時候會使用一種稱為 靜態重定位(static relocation)
的技術來修改它。它的工作流程如下:當一個程序被加載到 16384 地址時,常數 16384 被加到每一個程序地址上(所以 JMP 28
會變為JMP 16412
)。雖然這個機制在不出錯誤的情況下是可行的,但這不是一種通用的解決辦法,同時會減慢裝載速度。更近一步來講,它需要所有可執行程序中的額外信息,以指示哪些包含(可重定位)地址,哪些不包含(可重定位)地址。畢竟,上圖 b 中的 JMP 28 可以被重定向(被修改),而類似 MOV REGISTER1,28
會把數字 28 移到 REGISTER 中則不會重定向。所以,裝載器(loader)
需要一定的能力來辨別地址和常數。
一種存儲器抽象:地址空間
把物理內存暴露給進程會有幾個主要的缺點:第一個問題是,如果用戶程序可以尋址內存的每個字節,它們就可以很容易的破壞操作系統,從而使系統停止運行(除非使用 IBM 360 那種 lock-and-key 模式或者特殊的硬件進行保護)。即使在只有一個用戶進程運行的情況下,這個問題也存在。
第二點是,這種模型想要運行多個程序是很困難的(如果只有一個 CPU 那就是順序執行),在個人計算機上,一般會打開很多應用程序,比如輸入法、電子郵件、瀏覽器,這些進程在不同時刻會有一個進程正在運行,其他應用程序可以通過鼠標來喚醒。在系統中沒有物理內存的情況下很難實現。
地址空間的概念
如果要使多個應用程序同時運行在內存中,必須要解決兩個問題:保護
和 重定位
。我們來看 IBM 360 是如何解決的:第一種解決方式是用保護密鑰標記內存塊,並將執行過程的密鑰與提取的每個存儲字的密鑰進行比較。這種方式只能解決第一種問題,但是還是不能解決多進程在內存中同時運行的問題。
還有一種更好的方式是創造一個存儲器抽象:地址空間(the address space)
。就像進程的概念創建了一種抽象的 CPU 來運行程序,地址空間也創建了一種抽象內存供程序使用。地址空間是進程可以用來尋址內存的地址集。每個進程都有它自己的地址空間,獨立於其他進程的地址空間,但是某些進程會希望可以共享地址空間。
基址寄存器和變址寄存器
最簡單的辦法是使用動態重定位(dynamic relocation)
,它就是通過一種簡單的方式將每個進程的地址空間映射到物理內存的不同區域。從 CDC 6600(世界上最早的超級計算機)
到 Intel 8088(原始 IBM PC 的核心)
所使用的經典辦法是給每個 CPU 配置兩個特殊硬件寄存器,通常叫做基址寄存器(basic register)
和變址寄存器(limit register)
。當使用基址寄存器和變址寄存器使,程序會裝載到內存中連續的空間位置並且在裝載期間無需重定位。當一個進程運行時,程序的起始物理地址裝載到基址寄存器中,程序的長度則裝載到變址寄存器中。在上圖 c 中,當一個程序運行時,裝載到這些硬件寄存器中的基址和變址寄存器的值分別是 0 和 16384。當第二個程序運行時,這些值分別是 16384 和 32768。如果第三個 16 KB 的程序直接裝載到第二個程序的地址之上並且運行,這時基址寄存器和變址寄存器的值會是 32768 和 16384。那么我們可以總結下
- 基址寄存器:存儲數據內存的起始位置
- 變址寄存器:存儲應用程序的長度。
每當進程引用內存以獲取指令或讀取或寫入數據字時,CPU 硬件都會自動將基址值
添加到進程生成的地址中,然后再將其發送到內存總線上。同時,它檢查程序提供的地址是否等於或大於變址寄存器
中的值。如果程序提供的地址要超過變址寄存器的范圍,那么會產生錯誤並中止訪問。這樣,對上圖 c 中執行 JMP 28
這條指令后,硬件會把它解釋為 JMP 16412
,所以程序能夠跳到 CMP 指令,過程如下
使用基址寄存器和變址寄存器是給每個進程提供私有地址空間的一種非常好的方法,因為每個內存地址在送到內存之前,都會先加上基址寄存器的內容。在很多實際系統中,對基址寄存器和變址寄存器都會以一定的方式加以保護,使得只有操作系統可以修改它們。在 CDC 6600
中就提供了對這些寄存器的保護,但在 Intel 8088
中則沒有,甚至沒有變址寄存器。但是,Intel 8088 提供了許多基址寄存器,使程序的代碼和數據可以被獨立的重定位,但是對於超出范圍的內存引用沒有提供保護。
所以你可以知道使用基址寄存器和變址寄存器的缺點,在每次訪問內存時,都會進行 ADD
和 CMP
運算。比較可以執行的很快,但是加法就會相對慢一些,除非使用特殊的加法電路,否則加法因進位傳播時間而變慢。
交換技術
如果計算機的物理內存足夠大來容納所有的進程,那么之前提及的方案或多或少是可行的。但是實際上,所有進程需要的 RAM 總容量要遠遠高於內存的容量。在 Windows、OS X、或者 Linux 系統中,在計算機完成啟動(Boot)后,大約有 50 - 100 個進程隨之啟動。例如,當一個 Windows 應用程序被安裝后,它通常會發出命令,以便在后續系統啟動時,將啟動一個進程,這個進程除了檢查應用程序的更新外不做任何操作。一個簡單的應用程序可能會占用 5 - 10MB
的內存。其他后台進程會檢查電子郵件、網絡連接以及許多其他諸如此類的任務。這一切都會發生在第一個用戶
啟動之前。如今,像是 Photoshop
這樣的重要用戶應用程序僅僅需要 500 MB 來啟動,但是一旦它們開始處理數據就需要許多 GB 來處理。從結果上來看,將所有進程始終保持在內存中需要大量內存,如果內存不足,則無法完成。
所以針對上面內存不足的問題,提出了兩種處理方式:最簡單的一種方式就是交換(swapping)
技術,即把一個進程完整的調入內存,然后再內存中運行一段時間,再把它放回磁盤。空閑進程會存儲在磁盤中,所以這些進程在沒有運行時不會占用太多內存。另外一種策略叫做虛擬內存(virtual memory)
,虛擬內存技術能夠允許應用程序部分的運行在內存中。下面我們首先先探討一下交換
交換過程
下面是一個交換過程
剛開始的時候,只有進程 A 在內存中,然后從創建進程 B 和進程 C 或者從磁盤中把它們換入內存,然后在圖 d 中,A 被換出內存到磁盤中,最后 A 重新進來。因為圖 g 中的進程 A 現在到了不同的位置,所以在裝載過程中需要被重新定位,或者在交換程序時通過軟件來執行;或者在程序執行期間通過硬件來重定位。基址寄存器和變址寄存器就適用於這種情況。
交換在內存創建了多個 空閑區(hole)
,內存會把所有的空閑區盡可能向下移動合並成為一個大的空閑區。這項技術稱為內存緊縮(memory compaction)
。但是這項技術通常不會使用,因為這項技術回消耗很多 CPU 時間。例如,在一個 16GB 內存的機器上每 8ns 復制 8 字節,它緊縮全部的內存大約要花費 16s。
有一個值得注意的問題是,當進程被創建或者換入內存時應該為它分配多大的內存。如果進程被創建后它的大小是固定的並且不再改變,那么分配策略就比較簡單:操作系統會准確的按其需要的大小進行分配。
但是如果進程的 data segment
能夠自動增長,例如,通過動態分配堆中的內存,肯定會出現問題。這里還是再提一下什么是 data segment
吧。從邏輯層面操作系統把數據分成不同的段(不同的區域)
來存儲:
- 代碼段(codesegment/textsegment):
又稱文本段,用來存放指令,運行代碼的一塊內存空間
此空間大小在代碼運行前就已經確定
內存空間一般屬於只讀,某些架構的代碼也允許可寫
在代碼段中,也有可能包含一些只讀的常數變量,例如字符串常量等。
- 數據段(datasegment):
可讀可寫
存儲初始化的全局變量和初始化的 static 變量
數據段中數據的生存期是隨程序持續性(隨進程持續性)
隨進程持續性:進程創建就存在,進程死亡就消失
- bss段(bsssegment):
可讀可寫
存儲未初始化的全局變量和未初始化的 static 變量
bss 段中數據的生存期隨進程持續性
bss 段中的數據一般默認為0
- rodata段:
只讀數據
比如 printf 語句中的格式字符串和開關語句的跳轉表。也就是常量區。例如,全局作用域中的 const int ival = 10,ival 存放在 .rodata 段;再如,函數局部作用域中的 printf("Hello world %d\n", c); 語句中的格式字符串 "Hello world %d\n",也存放在 .rodata 段。
- 棧(stack):
可讀可寫
存儲的是函數或代碼中的局部變量(非 static 變量)
棧的生存期隨代碼塊持續性,代碼塊運行就給你分配空間,代碼塊結束,就自動回收空間
- 堆(heap):
可讀可寫
存儲的是程序運行期間動態分配的 malloc/realloc 的空間
堆的生存期隨進程持續性,從 malloc/realloc 到 free 一直存在
下面是我們用 Borland C++ 編譯過后的結果
_TEXT segment dword public use32 'CODE'
_TEXT ends
_DATA segment dword public use32 'DATA'
_DATA ends
_BSS segment dword public use32 'BSS'
_BSS ends
段定義( segment ) 是用來區分或者划分范圍區域的意思。匯編語言的 segment 偽指令表示段定義的起始,ends 偽指令表示段定義的結束。段定義是一段連續的內存空間
所以內存針對自動增長的區域,會有三種處理方式
-
如果一個進程與空閑區相鄰,那么可把該空閑區分配給進程以供其增大。
-
如果進程相鄰的是另一個進程,就會有兩種處理方式:要么把需要增長的進程移動到一個內存中空閑區足夠大的區域,要么把一個或多個進程交換出去,已變成生成一個大的空閑區。
-
如果一個進程在內存中不能增長,而且磁盤上的交換區也滿了,那么這個進程只有掛起一些空閑空間(或者可以結束該進程)
上面只針對單個或者一小部分需要增長的進程采用的方式,如果大部分進程都要在運行時增長,為了減少因內存區域不夠而引起的進程交換和移動所產生的開銷,一種可用的方法是,在換入或移動進程時為它分配一些額外的內存。然而,當進程被換出到磁盤上時,應該只交換實際上使用的內存,將額外的內存交換也是一種浪費,下面是一種為兩個進程分配了增長空間的內存配置。
如果進程有兩個可增長的段,例如,供變量動態分配和釋放的作為堆(全局變量)
使用的一個數據段(data segment)
,以及存放局部變量與返回地址的一個堆棧段(stack segment)
,就如圖 b 所示。在圖中可以看到所示進程的堆棧段在進程所占內存的頂端向下增長,緊接着在程序段后的數據段向上增長。當增長預留的內存區域不夠了,處理方式就如上面的流程圖(data segment 自動增長的三種處理方式)
一樣了。
空閑內存管理
在進行內存動態分配時,操作系統必須對其進行管理。大致上說,有兩種監控內存使用的方式
位圖(bitmap)
空閑列表(free lists)
下面我們就來探討一下這兩種使用方式
使用位圖的存儲管理
使用位圖方法時,內存可能被划分為小到幾個字或大到幾千字節的分配單元。每個分配單元對應於位圖中的一位,0 表示空閑, 1 表示占用(或者相反)。一塊內存區域和其對應的位圖如下
圖 a 表示一段有 5 個進程和 3 個空閑區的內存,刻度為內存分配單元,陰影區表示空閑(在位圖中用 0 表示);圖 b 表示對應的位圖;圖 c 表示用鏈表表示同樣的信息
分配單元的大小是一個重要的設計因素,分配單位越小,位圖越大。然而,即使只有 4 字節的分配單元,32 位的內存也僅僅只需要位圖中的 1 位。32n
位的內存需要 n 位的位圖,所以1 個位圖只占用了 1/32 的內存。如果選擇更大的內存單元,位圖應該要更小。如果進程的大小不是分配單元的整數倍,那么在最后一個分配單元中會有大量的內存被浪費。
位圖
提供了一種簡單的方法在固定大小的內存中跟蹤內存的使用情況,因為位圖的大小取決於內存和分配單元的大小。這種方法有一個問題是,當決定為把具有 k 個分配單元的進程放入內存時,內容管理器(memory manager)
必須搜索位圖,在位圖中找出能夠運行 k 個連續 0 位的串。在位圖中找出制定長度的連續 0 串是一個很耗時的操作,這是位圖的缺點。(可以簡單理解為在雜亂無章的數組中,找出具有一大長串空閑的數組單元)
使用鏈表進行管理
另一種記錄內存使用情況的方法是,維護一個記錄已分配內存段和空閑內存段的鏈表,段會包含進程或者是兩個進程的空閑區域。可用上面的圖 c 來表示內存的使用情況。鏈表中的每一項都可以代表一個 空閑區(H)
或者是進程(P)
的起始標志,長度和下一個鏈表項的位置。
在這個例子中,段鏈表(segment list)
是按照地址排序的。這種方式的優點是,當進程終止或被交換時,更新列表很簡單。一個終止進程通常有兩個鄰居(除了內存的頂部和底部外)。相鄰的可能是進程也可能是空閑區,它們有四種組合方式。
當按照地址順序在鏈表中存放進程和空閑區時,有幾種算法可以為創建的進程(或者從磁盤中換入的進程)分配內存。我們先假設內存管理器知道應該分配多少內存,最簡單的算法是使用 首次適配(first fit)
。內存管理器會沿着段列表進行掃描,直到找個一個足夠大的空閑區為止。除非空閑區大小和要分配的空間大小一樣,否則將空閑區分為兩部分,一部分供進程使用;一部分生成新的空閑區。首次適配算法是一種速度很快的算法,因為它會盡可能的搜索鏈表。
首次適配的一個小的變體是 下次適配(next fit)
。它和首次匹配的工作方式相同,只有一個不同之處那就是下次適配在每次找到合適的空閑區時就會記錄當時的位置,以便下次尋找空閑區時從上次結束的地方開始搜索,而不是像首次匹配算法那樣每次都會從頭開始搜索。Bays(1997)
證明了下次算法的性能略低於首次匹配算法。
另外一個著名的並且廣泛使用的算法是 最佳適配(best fit)
。最佳適配會從頭到尾尋找整個鏈表,找出能夠容納進程的最小空閑區。最佳適配算法會試圖找出最接近實際需要的空閑區,以最好的匹配請求和可用空閑區,而不是先一次拆分一個以后可能會用到的大的空閑區。比如現在我們需要一個大小為 2 的塊,那么首次匹配算法會把這個塊分配在位置 5 的空閑區,而最佳適配算法會把該塊分配在位置為 18 的空閑區,如下
那么最佳適配算法的性能如何呢?最佳適配會遍歷整個鏈表,所以最佳適配算法的性能要比首次匹配算法差。但是令人想不到的是,最佳適配算法要比首次匹配和下次匹配算法浪費更多的內存,因為它會產生大量無用的小緩沖區,首次匹配算法生成的空閑區會更大一些。
最佳適配的空閑區會分裂出很多非常小的緩沖區,為了避免這一問題,可以考慮使用 最差適配(worst fit)
算法。即總是分配最大的內存區域(所以你現在明白為什么最佳適配算法會分裂出很多小緩沖區了吧),使新分配的空閑區比較大從而可以繼續使用。仿真程序表明最差適配算法也不是一個好主意。
如果為進程和空閑區維護各自獨立的鏈表,那么這四個算法的速度都能得到提高。這樣,這四種算法的目標都是為了檢查空閑區而不是進程。但這種分配速度的提高的一個不可避免的代價是增加復雜度和減慢內存釋放速度,因為必須將一個回收的段從進程鏈表中刪除並插入空閑鏈表區。
如果進程和空閑區使用不同的鏈表,那么可以按照大小對空閑區鏈表排序,以便提高最佳適配算法的速度。在使用最佳適配算法搜索由小到大排列的空閑區鏈表時,只要找到一個合適的空閑區,則這個空閑區就是能容納這個作業的最小空閑區,因此是最佳匹配。因為空閑區鏈表以單鏈表形式組織,所以不需要進一步搜索。空閑區鏈表按大小排序時,首次適配算法與最佳適配算法一樣快,而下次適配算法在這里毫無意義。
另一種分配算法是 快速適配(quick fit)
算法,它為那些常用大小的空閑區維護單獨的鏈表。例如,有一個 n 項的表,該表的第一項是指向大小為 4 KB 的空閑區鏈表表頭指針,第二項是指向大小為 8 KB 的空閑區鏈表表頭指針,第三項是指向大小為 12 KB 的空閑區鏈表表頭指針,以此類推。比如 21 KB 這樣的空閑區既可以放在 20 KB 的鏈表中,也可以放在一個專門存放大小比較特別的空閑區鏈表中。
快速匹配算法尋找一個指定代銷的空閑區也是十分快速的,但它和所有將空閑區按大小排序的方案一樣,都有一個共同的缺點,即在一個進程終止或被換出時,尋找它的相鄰塊並查看是否可以合並的過程都是非常耗時的。如果不進行合並,內存將會很快分裂出大量進程無法利用的小空閑區。