9.虛擬存儲器
為了更加有效地管理存儲器且少出錯,現代系統提供了對主存的抽象概念,叫做虛擬存儲器(VM)
。
-
虛擬存儲器
是硬件異常,硬件地址翻譯,主存,磁盤文件和內核軟件的完美交互。 -
為每個進程提供一個大的,一致的和 私有的地址空間。
-
提供了3個重要能力。
-
將主存看成磁盤地址空間的高速緩存。
- 只保留了活動區域,並根據需要在磁盤和主存間來回傳送數據,高效使用主存。
-
為每個進程提供一致的地址空間
- 簡化存儲器管理
-
保護了每個進程的地址空間不被其他進程破壞。
-
-
程序員為什么要理解它?
-
虛擬存儲器
是中心的。- 遍布在計算機系統所有層次,硬件異常,匯編器,連接器,加載器,共享對象,文件和進程中扮演重要角色。
-
虛擬存儲器
是強大的。- 可以創建和銷毀存儲器片(chunk)
- 將存儲器片映射到磁盤文件的某個部分。
- 其他進程共享存儲器。
- 例子
- 能讀寫存儲器位置來修改磁盤文件內容。
- 加載文件到存儲器不需要顯式的拷貝。
-
虛擬存儲器
是危險的- 引用變量,間接引用指正,調用
malloc
動態分配程序,就會和虛擬存儲器交互。 - 如果使用不當,將遇到復雜危險的與存儲器有關的錯誤。
- 例子
- 一個帶有錯誤指針的程序可以立即崩潰於段錯誤或者保護錯誤。
- 運行完成,卻不產生正確結果。
- 引用變量,間接引用指正,調用
-
-
本章從兩個角度分析。
虛擬存儲器
如何工作。- 應用程序如何使用和管理
虛擬存儲器
。
9.1 物理與虛擬尋址
-
物理地址(Physical Address,PA)
:計算機系統的主存被組織為M個連續的字節大小的單元組成的數組。每個字節的地址叫物理地址
. -
CPU訪問存儲器的最自然的方式使用
物理地址
,這種方式稱為物理尋址
。
- 早期的PC,數字信號處理器,嵌入式微控制器以及Cray超級計算機使用物理尋址
。 -
現代處理器使用的是
虛擬尋址(virtual addressing)
的尋址形式。-
CPU通過生成一個
虛擬地址(Virtual address,VA)
來訪問主存。- 將
虛擬地址
轉換為物理地址
叫做地址翻譯(address translation)
。
- 將
-
地址翻譯
也需要CPU硬件和操作系統之間的緊密結合。- CPU芯片上有叫做
存儲器管理單元(Memory Management Unit,MMU)
的專用硬件。- 利用存儲在主存中的查詢表來動態翻譯虛擬地址。
- 查詢表由操作系統管理。
- CPU芯片上有叫做
-
9.2 地址空間
地址空間(address space)
是一個非負整數地址
的有序集合。
-
如果地址空間中整數是連續的,我們說它是
線性地址空間(linear address space)
。- 為了簡化討論,我們總是假設使用線性地址空間。
-
在一個帶虛擬存儲器的系統中,CPU從一個有
N=2^n
個地址的地址空間
中生成虛擬地址,這個地址空間稱為虛擬地址空間(virtual address space)
。 -
一個
地址空間
大小是由表示最大地址所需要的位數來描述的。- 如
N=2^n
個地址的虛擬地址空間叫做n
位地址空間。 - 現在操作系統支持
32位
或64位
。
- 如
-
一個系統還有
物理地址空間
,它與系統中物理存儲器的M=2^m
(假設為2的冪)個字節相對應。
地址空間
的概念很重要,因為它區分了數據對象(字節)和 它們的屬性(地址)。
- 每個
字節(數據對象)
一般有多個 獨立的地址(屬性)
。每個地址都選自不同的地址空間。- 比如一般來說。
字節
有一個在虛擬地址空間
的虛擬地址
。- 還有一個在
物理地址空間
的物理地址
。 - 兩個地址都能訪問到這個
字節
。
- 類似現實世界的門牌號, 和經緯度。
- 比如一般來說。
9.3 虛擬存儲器作為緩存的工具
在講述這一小章之前,必須交代一下我對
虛擬存儲器
概念的存疑。
原本我以為虛擬存儲器
=虛擬內存
。
以下是虛擬內存
的定義
虛擬內存
是計算機系統內存管理的一種技術。它使得應用程序認為它擁有連續的可用的內存(一個連續完整的地址空間),而實際上,它通常是被分隔成多個物理內存碎片
,還有部分暫時存儲在外部磁盤存儲器
上,在需要時進行數據交換而在下面的定義我們可以看到
CSAPP
中認為虛擬存儲器
是存放在磁盤上的。
在此,我們姑且當做兩者是不同的東西,以后有更深刻的理解,再思考。
虛擬存儲器(VM)
被組織為一個存放在磁盤上的N個連續字節大小的單元組成的數組。
-
每個字節都有一個唯一的
虛擬地址
,這個虛擬地址作為到數組的索引。 -
磁盤
上數組的內容被緩存到主存
中。- 同存儲器層次結構其他緩存一樣,
磁盤
上的數據被分割稱塊
。- 這些
塊
作為磁盤和主存之間的傳輸單元。 虛擬頁(Virtual Page,VP)
就是這個塊
- 每個
虛擬頁
大小為P=2^p
字節。
- 每個
- 這些
- 物理存儲器被分割為
物理頁
,大小也為P
字節- 也被稱為
頁幀(page frame)
- 也被稱為
- 同存儲器層次結構其他緩存一樣,
-
任何時候,
虛擬頁
的集合都被分為3個不相交的子集。- 未分配的:VM系統還未分配(或者創建)的頁。未分配的
塊
沒有任何數據與之相關聯。- 不占用磁盤空間
- 通過
malloc
來分配
- 緩存的:當前緩存在物理存儲器的已分配頁。
- 未緩存的:沒有緩存在物理頁面存儲器中的已分配頁。
- 未分配的:VM系統還未分配(或者創建)的頁。未分配的
9.3.1 DRAM緩存的組織結構
DRAM
表示虛擬存儲器系統的緩存,在主存中緩存虛擬頁
,有兩個特點。
DRAM
緩存不命中處罰十分嚴重。- 因為
磁盤
比DRAM
慢100000多倍。
- 因為
- 訪問一字節開銷
- :從一個磁盤的一個扇區讀取第一個字節的時間開銷要比從該扇區中讀連續的字節慢大約100000倍
DRAM
緩存的組織結構由這種巨大的不命中開銷驅動。因此有以下特點。
(有些地方不是特別懂,之后看完第六章應該會好點)
-
虛擬頁
往往很大。- 4KB~2MB
- 訪問一字節開銷的原因才要這么大。
-
DRAM
緩存是全相聯
- 也就是: 任何
虛擬頁
都能放在任何物理頁
中。 - 原因在於大的不命中懲罰
- 也就是: 任何
-
更精密的替換算法
- 替換錯了虛擬頁的懲罰很高。
-
DRAM
緩存總是寫回
- 因為對磁盤的訪問時間很長
- 而不用
直寫
9.3.2 頁表
判斷命中和替換又多種軟硬件聯合提供。
- 操作系統軟件,MMU中的地址翻譯硬件和
頁表(page table)
。-
頁表
是存放在物理存儲器的數據結構。頁表
將虛擬頁映射到物理頁。- 地址翻譯硬件將虛擬地址轉換為物理地址都會讀取
頁表
。
-
操作系統負責維護
頁表
的內容,以及磁盤及DRAM之間來回傳送頁。
-
頁表
就是一個頁表條目(Page Table Entry,PTE)
的數組.- 虛擬地址空間 中每個頁在頁表的固定偏移量處都有一個
PTE
. - 每個
PTE
由一個有效位
和n位地址字段
。有效位
表明虛擬頁是否被緩存。- 如果有效位存在,那么地址字段指向對應的物理存儲器。
- 如果有效位不存在。
- 地址字段要么為NULL
- 要么指向虛擬頁在磁盤所在的位置。
- 虛擬地址空間 中每個頁在頁表的固定偏移量處都有一個
9.3.3 頁命中
- 一個頁命中的過程。
- 一個虛擬地址轉換為物理地址的過程。
9.3.4 缺頁
在虛擬存儲器的習慣說法中,DRAM緩存不命中稱為缺頁
。
處理過程如下:
-
讀取虛擬地址所指向的
PT
。 -
讀取
PTE
有效位,發現未被緩存,觸發缺頁異常。 -
調用缺頁異常處理程序
- 選擇犧牲頁。
- 如果犧牲頁發生了改變,將其拷貝回磁盤(因為是
寫回
) - 需要讀取的頁代替了犧牲頁的位置。
- 結果:犧牲也不被緩存,需要讀取的頁被緩存。
-
中斷結束,重新執行最開始的指令。
-
在
DRAM
中讀取成功。
虛擬存儲器
是20世紀60年代發明的,因此即使與SRAM緩存使用了不同的術語。
塊
被稱為頁
。- 磁盤和DRAM之間傳送
頁
的活動叫做交換(swapping)
或者頁面調度(paging)
。 - 有
不命中
發生時,才換入頁面,這種策略叫做按需頁面調度(demand paging)
。- 現代系統基本都是用這個。
9.3.5 分配頁面
比如某個頁面
所指向地址為NULL
,將這個地址指向磁盤某處,那么這就叫分配頁面
。
此時虛擬頁
從未分配
狀態 變為 未緩存
。
9.3.6 又是局部性拯救了我們
虛擬存儲器
工作的相當好,主要歸功於老朋友局部性(locality)
盡管從頭到尾的活動頁面數量大於物理存儲器大小。
但是在局部內,程序往往在一個較小的活動頁面集合工作
-
這個集合叫做
工作集(working set)
或者叫常駐集(resident set)
- 初始載入開銷比較大。
-
程序有良好的
時間局部性
,虛擬存儲器
都工作的相當好。 -
如果程序實在很爛,或者物理空間很小,
工作集
大於物理存儲器
大小。這種狀態叫顛簸(thrashing)
.- 這時,頁面不斷換進換出。性能十分低。
統計缺頁次數
可以利用Unix的getrusage
函數檢測缺頁數量。
9.4 虛擬存儲器作為存儲器的管理工具
實際上,操作系統為每個進程提供一個獨立的頁表
。
因此,VM
簡化了鏈接
和加載
,代碼
和數據共享
,以及應用程序的存儲器
分配。
-
簡化鏈接
-
獨立的空間地址意味着每個進程的存儲器映像使用相同的格式。
- 文本節總是從
0x08048000
(32位)處或0x400000
(64位)處開始。 - 然后是數據,bss節,棧。
- 文本節總是從
-
一致性極大簡化了
鏈接器
的設計和實現。
-
-
簡化加載
加載器
可以從不實際拷貝任何數據從磁盤到存儲器。- 基本都是虛擬存儲系統完成。
將一組連續的
虛擬頁
映射到任意一個文件中的任意位置的表示法稱作存儲器映射
。Unix提供一個稱為mmap
的系統調用,允許程序自己做存儲器映射。在9.8詳細講解。 -
簡化共享
- 獨立地址空間為操作系統提供了一個管理用戶進程和操作系統自身之間的一致
共享
機制. - 例子
- 操作相同的操作系統內核代碼
- C標准庫的
printf
.
- 因此操作系統需要將不同進程的適當的虛擬頁映射到相同的物理頁面。
- 多個進程共享這部分代碼的一個拷貝。
- 而不是每個進程都要加載單獨的內核和C標准庫的拷貝。
- 獨立地址空間為操作系統提供了一個管理用戶進程和操作系統自身之間的一致
-
簡化存儲器分配.
- 即
虛擬頁
連續(虛擬頁還是單獨的),物理頁
可以不連續。使得分配更加容易。
- 即
9.5 虛擬存儲器作為存儲器保護的工具
任何現代操作系統必須為操作系統提供手段來控制對 存儲器系統的訪問。
- 不應該允許用戶進程修改它的只讀文本段。
- 不允許它讀或修改任何內核的代碼和數據結構
- 不允許讀寫其他進程的私有存儲器。
- 不允許修改共享的虛擬頁,除非所有共享者顯示允許這么做(通過調用明確的進程間通信)
方式:在PTE
上添加一些格外的許可位
來控制訪問。
SUP
:是否只有在內核模式下才能訪問?READ
:讀權限。WRITE
:寫權限。
如果指令違反了許可條件,觸發一般保護性異常,然后交給異常處理程序,Shell
一般會報告為段錯誤(segmentaion fault)
。
9.6 地址翻譯
認識到硬件在支持虛擬存儲器中的角色
以下是接下來可能要用到的符號,作參考。
-
形式上來說,地址翻譯是一個N元素的虛擬地址空間(
VAS
)中的元素和一個M元素的物理地址空間(PAS
)元素之間的映射, -
以下展示了
MMU
(Memory Management Unit
,存儲器管理單元)如何利用頁表實現這樣的功能-
頁表基址寄存器(Page Table Base Register,PTBR)
指向當前頁表。 -
n
位的虛擬地址
包含兩個部分- 一個
p
位的虛擬頁面偏移(Virtual Page Offset
,VPO
) - 一個
n-p
位的虛擬頁號(Virtual Page Number
,VPN
)MMU
利用VPN
選取適當的PTE(頁面條目,Page Tabe Entry,PTE)
- 一個
-
頁面條目 (
PTE
)中物理頁號(PPN
)和虛擬地址中的VPO
串聯起啦,即是物理地址
PPO
和VPO
是相同的- 不要忘記
VPN
,PPN
都是塊,都是首地址而已,所以需要偏移地址PPO
,VPO
-
圖(a)展示頁面命中,CPU硬件執行過程
- 第一步:處理器生成虛擬地址,把它傳送給
MMU
。 - 第二步:
MMU
生成PTE
地址(PTEA
),並從高速緩存/主存請求中得到它。 - 第三步: 高速緩存/主存向MMU返回
PTE
。 - 第四步:
MMU
構造物理地址(PA),並把它傳送給高速緩存/主存。 - 第五步: 高速緩存/主存返回所請求的數據字給處理器。
頁面命中完全由硬件處理,與之不同的是,處理缺頁需要 硬件和操作系統內核協作完成。
- 第一到三步: 與命中時的一樣
- 第四步:
PTE
有效位是零,所以MMU
觸發異常,傳遞CPU中的控制到操作系統內核中的 缺頁異常處理程序。 - 第五步:缺頁異常處理程序確定出物理存儲頁中的犧牲頁,如果這個頁面已經被修改,則把它換出到磁盤。
- 第六步:缺頁異常處理程序調入新的頁面,並更新存儲器中的
PTE
。 - 第七部:缺頁異常處理程序返回到原來的進程,再次執行導致缺頁的指令,之后就是頁面命中一樣的步驟。
9.6.1 結合高速緩存和虛擬存儲器(PA->內存)
在任何使用虛擬存儲器又使用SRAM
高速緩存的系統中,都存在應該使用虛擬地址 還是 使用 物理地址 來訪問SRAM高速緩存
的問題。
使用虛擬地址的優點,就是類似於使用虛擬存儲器的優點,更好的利用空間。但是設計更復雜。兩者的使用需要權衡。
大多數系統是選擇物理尋址。
-
使用物理尋址,多個進程同時在高速緩存中有存儲塊和共享來自相同虛擬頁面的塊稱為簡單的事。
- 而且還無需處理保護問題,因為 訪問權限的檢查在地址翻譯中(
PTE
)的一部分。
- 而且還無需處理保護問題,因為 訪問權限的檢查在地址翻譯中(
-
以下是一個例子(將
PTE
進行高速緩存)。
9.6.2 利用TLB加速地址翻譯(VA->PA)
每次CPU產生一個虛擬地址,MMU
就必須查閱一個PTE
,以便將虛擬地址翻譯為 物理地址。
- 在最糟糕的情況下,會從內存中取數據,代價是幾十 到幾百個周期
- 如果
PTE
碰巧緩存在L1
中,那么開銷就下降到一到兩個周期
許多系統都試圖消除這樣的開銷,他們在MMU
中包含了一個關於PTE
的小緩存,稱為翻譯后備緩沖器(Translation Lookaside Buffer,TLB)
。
-
TLB
是一個小的,虛擬尋址的緩存。-
每一行都保存着一個由單個
PTE
組成的塊。 -
TLB
通常用於高度的相連性 -
如圖所示
- 用於組選擇和行匹配的`索引`和`標記字段`是從虛擬地址中的**虛擬頁號**中提取出來的。 - 如果`TLB`有T=2^t個組 - 那么`TLB索引`(`TLBI`)是由VPN的`t`個最低位組成。(對應於`VPO`) - `TLB標記`(`TLBT`)是由VPN中剩余位組成(對應於`VPN`)
-
-
下圖展示了
TLB
命中步驟- 關鍵點:所有的地址翻譯步驟都是在芯片上的MMU中執行的,因此非常快
-
TLB
命中- 第一步:CPU產生虛擬地址。
- 第二步和第三部:
MMU
從TLB
取出對應的PTE
。 - 第四步:
MMU
將這個虛擬地址翻譯成一個物理地址,發送到高速緩存/主存
- 第五步:
高速緩存/主存
所請求的數據字返回給CPU
-
當
TLB
不命中的時候,MMU
必須從L1
緩存或內存中取出相應的PTE
,並進行類似缺頁處理過程。
9.6.3 多級頁表
如果我們有一個32位地址空間,4KB
大小的頁面(p=2^12
)和一個4B
的PTE
,即使應用所引用的只是虛擬地址空間中很小的一部分,也總是需要一個4MB
的頁表駐留在存儲器中。
所以多級頁表
的誕生用於解決在很少使用時有一個很大的頁表常駐於內存。
計算方式,最多可能要
2^32/4KB=1MB
個頁面,每個頁面需要4B的PTE
所以需要4MB
大小的頁表。
思考虛擬地址是31~p
,p-1~0
即VPN,VPO。
VPN
即可表示頁面個數(上文中的1MB
),VPO
即頁面大小(上文中的4KB
),顯然知道兩者相乘為2^32 次方、
用來壓縮頁表的常用方式是使用層次結構的頁表。
頁表本身一個優點就是用來解決 內存不夠裝載程序所用內存的情況,進行動態分配。那么當我們發現內存裝載那么大的頁表也是負擔的時候,顯然也可以用類似頁表的形式來解決,這就是多級頁表。
以下用上圖的 兩層 作為例子。
-
總共有
9KB
個頁面,PTE為4個字節。- 前
2KB
個頁面分配給代碼和數據。 - 接下來
6KB
個頁面未分配 - 再接下來
1023
個頁面也未分配 - 接下一個頁面分配給用戶棧
- 前
-
一級頁表中的每個
PTE
負責映射虛擬地址空間中一個4MB
大小的片(chunk)
.- 每一個
片
都是由1024個連續的頁面組成。 4MB=1024個頁面*PTE大小4字節
。
- 每一個
-
如果
片i
中每個頁面都沒有分配,那么一級PTE i
就為空。- 例如圖中的
PTE 2
~PTE 7
- 但是如果
片i
中有一個被分配了,那么PTE i
就不能為空。- 是不是覺得這樣很浪費啊~所以說,
三級四級頁表
的原由也是如此。 - 而且后文會發現,頁表級數即使很大,復雜度也不會怎么變化。
- 是不是覺得這樣很浪費啊~所以說,
- 例如圖中的
-
這種方法從兩個方面減少了存儲器要求。
- 如果一級頁表
PTE
為空,那么相應的二級頁表就根本不會存在。- 一種巨大的潛在節約,大部分時候內存都是未分配的。
- 只有一級頁表才需要總是在主存中。
- 虛擬存儲器系統可以在需要時創建,頁面調入,調出二級頁面,減少主存壓力。
- 如果一級頁表
k級頁表層次結構的地址翻譯。
虛擬地址
被分為k
個VPN
和一個VPO
。每個VPN i
都是i-1
級頁表到i
級頁表的索引。PPN
存於k級頁表。PPO
依舊與VPO
相同。
此時TLB能發揮作用,因為層次更細,更利於緩存。使得多級頁表的地址翻譯不比單級頁表慢很多。
9.6.4 綜合:端到端的地址翻譯
在這一節里,我們通過一個具體的端到端的地址翻譯示例,來綜合一下我們學過的內容。
一個在有一個TLB
和L1 d-cache
的小系統上。作出如下假設:
- 存儲器都是按字節尋址的。(?)
- 存儲器訪問是針對一字節的字的。(?)
- 虛擬地址是
14
位長(n=14) - 物理地址是
12
位長(m=12) - 頁面大小是
64
字節(P=2^6) TLB
是四路組相連的,總共有16
個條目(?)L1 d-cache
是物理尋址,高速緩存,直接映射(E=1)的,行大小為4字節,而總共有16個組。(?)
存儲結構快照
TLB
:TLB
利用VPN
的位進行緩存。頁表
: 這個頁表是一個單級設計。一個有256個,但是這里只列出16個。高速緩存
:直接映射的緩存通過物理地址的字段來尋址。- 因為是直接映射,通過索引就能直接找到。且
E=1
。 - 直接能判定是否命中。
- 因為是直接映射,通過索引就能直接找到。且
9.7 案例研究: Intel Core i7/Linux 存儲器系統
處理器包(processor package)
- 四個核
- 層次結構的
TLB
- 虛擬尋址
- 四路組相連
Linux
一頁4kb
- 層次結構的
數據和指令
高速緩存。- 物理尋址
L1
,L2
八路組相連L3
十六路組相連塊
大小64字節。
- 快速的點到點鏈接。
- 基於
Intel QuickPath
技術。 - 為了讓核與其他核和外部
I/O
橋直接通信。
- 基於
- 層次結構的
L3
高速緩存DDR3存儲器控制器
。
- 四個核
9.7.1 Core i7地址翻譯
上圖完整總結了Core i7
地址翻譯過程,從虛擬地址到找到數據傳入CPU。
Core i7
采用四級頁表層次結構。CR3
控制寄存器指向第一級頁表(L1)的起始位置CR3
也是每個進程上下文的一部分。- 上下文切換的時候,
CR3
也要被重置。
一級,二級,三級頁表PTE
的格式:
-
P=1
時 地址字段包含了一個40位物理頁號(PPN)
,指向適當的頁表開始處。 -
強加了一個要求,要求物理頁
4kb
對齊。- 因為
PPO
為12
位 =4kb
PPO
的大小就跟物理頁的大小有關。
- 因為
四級頁表的PTE
格式:
-
PTE
有三個權限位,控制對頁的訪問R/W
位確定頁的內容是可以 讀寫還是 只讀。U/S
位確定用戶模式是否能夠訪問,從而保護操作系統內核代碼不被用戶程序訪問。XD
(禁止執行) 位是在64位系統引入,禁止某些存儲器頁取指令。- 這是一個重要的新特性,限制只能執行只讀文本段,降低緩沖區溢出的風險。
-
當
MMU
翻譯虛擬地址時,還會更新兩個內核缺頁處理程序會用到的位。-
A
位- 每次訪問一個頁,
MMU
都會設置A
位,稱為引用位(reference bit)
. - 可以利用這個
引用位
來實現它的頁替換算法。
- 每次訪問一個頁,
-
D
位- 每次對一個頁進行了
寫
就會設置D
位,又稱臟位(dirty bit)
. 臟位
告訴內核在拷貝替換頁前是否要寫回
。
- 每次對一個頁進行了
-
內核通過調用一條特殊的內核模式指令來清除
引用位
或臟位
。
-
四級頁表如何將VPN
翻譯成物理地址
- 每個
VPN
被用作頁表的偏移量。 CR3
寄存器包含L1頁的物理地址
優化地址翻譯
在對地址翻譯中,我們順序執行這兩個過程
MMU
將虛擬地址翻譯成物理地址。- 物理地址傳送到
L1
高速緩存。
然而實際的硬件實現使用了一個靈巧的技巧,允許這兩個步驟並行。加速了對高速緩存的訪問
例如:頁面大小為4KB
的Core i7
上的虛擬地址有12
位的VPO
,且PPO
=VPO
.而且物理地址的緩存,也是
6
位索引+6
位偏移,剛好是VPO
的12位。這不是巧合
- 一方面通過
VPN
找PPN
。- 另一方面直接通過
PPO
對高速緩存進行組選擇。- 等找到
VPN
后就能立即進行關鍵字匹配。
9.7.2 Linux 虛擬存儲系統
目標:對Linux的虛擬存儲系統做一個描述,大致了解操作系統如何組織虛擬存儲器,如何處理缺頁
。
內核虛擬存儲器
-
內核虛擬存儲器
包含內核中的代碼和數據。-
內核虛擬存儲器
的某些區域被映射到所有進程共享的物理頁面- 如:內核代碼,全局數據結構。
-
Linux
也將一組連續的虛擬頁面
(大小等同於系統DRAM
總量)映射到相應的一組物理頁面
。(這句話啥意思???????????????????????????????)
-
-
內核虛擬存儲器
包含每個進程不相同的數據。- 頁表,內核在進程上下文中時使用的棧,等等。
Linux 虛擬存儲器區域
Linux
將虛擬存儲器組織成一些區域
(也叫做段
)的集合。
-
一個
區域
就是已經存在着的(已分配的) 虛擬存儲器的連續片
,這些片/頁已某種形式相關聯。- 代碼段,數據段,堆,共享庫段,用戶棧。
- 所有存在的
虛擬頁
都保存在某個區域。
-
區域
的概念很重要- 允許
虛擬地址空間
有間隙。
- 允許
一個進程中虛擬存儲器的內核數據結構。
內核
為系統中每個進程維護了一個單獨的任務結構
。任務結構
中的元素包含或指向內核運行該進程所需要的全部信息。
task_struct
mm_struct
- 描述了虛擬存儲器的當前狀態。
pgd
- 指向
第一級頁表
的基址。 - 當進程運行時,內核將
pgd
存放在CR3
控制寄存器
- 指向
mmap
vm_area_structs(區域結構)
- 每個
vm_area_structs
都描述了當前虛擬地址空間的一個區域(area)
. vm_start
:指向這個區域的起始處。vm_end
:指向這個區域的結束處。vm_port
:描述這個區域內包含的所有頁的讀寫許可權限。vm_flags
:描述這個區域頁面是否與其他進程共享,還是私有。- 還有一些其他事情
vm_next
: 指向鏈表的下一個區域。
- 每個
Linux 缺頁異常處理
MMU
在試圖翻譯虛擬地址A時,觸發缺頁。這個異常導致控制轉移到缺頁處理程序
,執行一下步驟。
-
虛擬地址A是合法的嗎?
- A在某個區域結構定義的區域內嗎?
- 解決方法:
- 缺頁處理程序搜索
區域結構
鏈表。 - 把A和每個區域的
vm_start
和vm_end
做比較。- 通過某種
樹的數據結構算法
查找
- 通過某種
- 缺頁處理程序搜索
- 如果不合法,觸發段錯誤。
-
試圖訪問的存儲器是否合法?
- 即是否有讀,寫,執行這個頁面的權限?
- 如果不合法,觸發
保護異常
,終止進程。
-
一切正常的話
- 選擇犧牲頁,替換,重新執行指令
9.8 存儲器映射
存儲器映射
: Linux通過將一個虛擬存儲器區域
與一個磁盤
上的對象關聯起來,以初始化這個虛擬存儲器區域
的內容,這個過程叫做存儲器映射
。
虛擬存儲器區域
可以映射到以下兩種類型文件。
-
Unix文件系統中的普通文件:一個
區域
可以映射到一個普通磁盤文件的連續部分。- 例如,一個可執行文件。
文件區(section)
被分成頁
大小的片,每一片包含一個虛擬頁面
的初始化內容。- 僅僅是
初始化
,虛擬頁面
此時還並未進入物理存儲器
。- 直到
CPU
第一次引用這個頁面。
- 直到
-
匿名文件 : 一個
區域
可以映射到一個匿名文件
。-
匿名文件
由內核創建,包含的全是二進制零。 -
CPU
第一次引用這樣區域(匿名文件)的虛擬頁面
時。- 將存儲器中
犧牲頁面
全部用二進制零
覆蓋。 - 並將
虛擬頁面
標記為駐留在存儲器中。 - 注意: 實際上,虛擬頁面並沒有跟存儲器進行數據傳送。
- 反正是送零過去,不如我自己用零賦值,這樣子更快。
- 將存儲器中
-
又叫
請求二進制零的頁(demand-zero page)
。
-
交換文件
,交換空間
。(win
下叫做paging file
)
-
一旦一個虛擬頁面被初始化了,它就在一個由內核維護的專門的
交換文件(swap file)
之間換來換去。交換文件
也叫交換空間
或者交換區域
。 -
需要意識到,在任何時刻,
交換空間
都限制着當前運行着的進程分配的虛擬頁面總數。 -
這一段不太明白。
9.8.1 再看共享對象
共享對象
的由來
- 許多進程有同樣的
只讀文本區域
。printf
- 運行
Uinx shell
的tcsh
- 如果每個進程都加載進內存一次,極其浪費。
- 存儲器映射提供一種機制,來
共享對象
。
一個對象被映射到虛擬存儲器
的一個區域,一定屬於以下兩種。
- 共有對象
- 一個進程將一個
共有對象
映射到它的虛擬地址空間的一個區域。- 進程對這個
區域
的寫操作,對於那些也把這個共享對象映射它的虛擬存儲器的進程
是可見的。 - 這些變化也會反映到
磁盤
上的原始對象。
- 進程對這個
- 映射到的虛擬存儲器那個
區域
叫做共享區域
。
- 一個進程將一個
- 私有對象
- 對一個映射到
私有對象
的區域做出的改變,對於其他進程不可見. - 並且進行的寫操作不會反映到
磁盤
上。 - 映射到的虛擬存儲器那個
區域
叫做私有區域
。
- 對一個映射到
9.8.1.1 共享對象
-
進程1
,將共享對象映射到虛擬存儲器
中,然后虛擬存儲器
將這一段找一塊物理存儲器
存儲。 -
當
進程2
也要引用同樣的共享對象時。- 內核迅速判定,
進程1
已經映射了這個對象。 - 使
進程2
的虛擬存儲器
直接指向了那一塊進程1
指向的物理存儲器
。
- 內核迅速判定,
-
即使
對象
被映射到多個共享區域,物理存儲器依舊只有一個共享對象的拷貝。- 大大解決了物理存儲器內存。
9.8.1.2 私有對象
私有對象
使用一種叫做寫時拷貝(conpy-on-write)
的巧妙技術。
-
私有對象
開始生命周期的方式基本與共享對象
一樣。- 即使對象被多個引用,在物理內存都只保留一個拷貝。
-
對於每個映射
私有對象
的進程,相應私有區域
的頁表條目
都被標記為只讀。- 並且
區域結構(vm_area_structs)
被標記為私有的寫時拷貝
。
- 並且
-
過程:只要有進程試圖寫
私有區域
內的某個頁面,那么這個寫操作
觸發保護異常
。故障處理程序
會在物理存儲器
中創建被修改頁面的一個新拷貝。- 更新
頁表條目(PTE)
指向這個新的拷貝,恢復被修改頁面的可寫權限。 故障處理程序
返回,CPU重新執行這個寫操作
。
-
通過延遲私有對象中的拷貝直到最后可能的時刻,
寫時拷貝
充分使用了稀缺的物理存儲器。
9.8.2 再看fork函數(私有對象的應用)
了解fork
函數如何創建一個帶有自己獨立虛擬地址空間的新進程。
-
當
fork
函數被當前進程調用時。- 內核為
新進程
創建內核數據結構,並分配給它唯一一個PID
。 - 為了給
新進程
創建虛擬存儲器。- 創建了當前進程的
mm_struct
,區域結構
和頁表的原樣拷貝。 - 將兩個進程的每個頁面都標記為只讀。並給兩個區域進程的每個區域結構都標記為
私有的寫時拷貝
。 - 注意:並沒有對物理存儲器進行拷貝哦~,利用的是
私有對象
的寫時拷貝
技術。
- 創建了當前進程的
- 內核為
-
當
fork
函數在新進程返回時。- 新進程現在的虛擬存儲器剛好和調用
fork
時存在的虛擬存儲器相同。 - 當兩個進程中任一個需要被
寫
時,觸發寫時拷貝機制
。
- 新進程現在的虛擬存儲器剛好和調用
9.8.3 再看execve函數
理解execve
函數實際上如何加載和運行程序。
- 假設運行在當前的進程中的程序執行了如下的調用:
Execve("a.out",NULL,NULL);
execve
函數在當前進程加載並執行目標文件a.out
中的程序,用a.out
代替當前程序。- 加載並運行需要以下幾個步驟。
-
刪除已存在的用戶區域。
- 刪除當前進程虛擬地址的用戶部分中已存在的
區域結構
。
- 刪除當前進程虛擬地址的用戶部分中已存在的
-
映射私有區域。
- 為新程序的文本,數據,
bss
和棧區域創建新的區域結構
。-
所有新的
區域結構
都是私有的
,寫時拷貝
的。 -
文本和數據區域被映射到
a.out
文件中的文件和數據區。 -
bss
區域是請求二進制零
,映射到匿名文件。- 大小包含在
a.out
中
- 大小包含在
-
堆,棧
區域也是請求二進制零
。
-
- 為新程序的文本,數據,
-
映射共享區域
a.out
程序與共享對象鏈接。- 這些對象都是動態鏈接到這個程序。
- 然后映射到用戶虛擬地址的共享區域。
-
設置程序計數器(PC)
execve
最后一件事設置PC
指向文本區域的入口點。
-
- 加載並運行需要以下幾個步驟。
9.8.4 使用mmap
函數的用戶級存儲器映射
Unix進程
可以使用mmap
函數來創建新的虛擬存儲器區域
,並將對象映射到這些區域中。
#include <unistd.h>
#include <sys/mman.h>
void *mmap(void *start,size_t length,int prot,int flags,int fd,off_t offset);
返回:若成功時則為指向映射區域的指正,若出錯則為MAP_FAILED(-1).
參數解釋:
fd
,start
,length
,offset
:
mmap
函數要求內核創建一個新的虛擬存儲器區域,最好是從地址start
開始的一個區域,並將文件描述符fd
指定的對象的一個連續的片chunk
映射到這個新的區域。
- 連續對象片大小為
length
字節 - 從據文件開始處偏移量為
offset
字節的地方開始。 statr
地址僅僅是個暗示- 一般被定義為
NULL
,讓內核自己安排。
- 一般被定義為
prot
參數prot
包含描述新映射的虛擬存儲器區域的訪問權限位
。(對應區域結構中的vm_prot
位)
PROT_EXEC
:這個區域內的頁面由可以被CPU執行的指令組成。PROT_READ
:這個區域內的頁面可讀。PROT_WRITE
: 這個區域內的頁面可寫。PROT_NONE
: 這個區域內的頁面不能被訪問。
flag
參數flag
由描述被映射對象類型的位
組成。
MAP_ANON
標記位:映射對象是一個匿名對象
。MAP_PRIVATE
標記位:被映射對象是一個私有
的,寫時拷貝
的對象。MAP_SHARED
標記位:被映射對象是一個共享
對象。
例子
bufp = mmap(NULL,size,PROT_READ,MAP_PRIVATE|MAP_ANON,0,0);
- 讓內核創建一個新的包含size字節的只讀,私有,請求二進制零的虛擬存儲區域。
- 如果調用成功,那么
bufp
包含新區域地址。
munmap
函數刪除虛擬存儲器的區域:
9.9 動態存儲器分配
雖然可以使用更低級的mmap
和munmap
函數來創建和刪除虛擬存儲器的區域。
但是C程序員還是覺得用動態存儲器分配器(dynamic memory allocator)
更方便。
-
動態存儲器分配器
維護着一個進程的虛擬存儲區域,稱為堆(heap)
。- 系統之間細節不同,但是不失通用型。
- 假設
堆
是一個請求二進制零的區域。- 緊接着未初始化的
bss
區域,並向上生長(向更高的地址)。 - 對於每個進程,內核維護一個變量
brk(break)
,指向堆頂。
-
分配器
將堆
視為一組不同大小的塊block
的集合來維護。每個塊
就是一個連續的虛擬存儲器片
,即頁面大小。- 要么是
已分配
,要么是空閑
。已分配
已分配的塊
顯式地保留供應用程序使用。已分配
的塊保持已分配狀態,直到它被釋放
。- 這種
釋放
要么是應用程序顯示執行。 - 要么是存儲器分配器自身
隱式
執行(JAVA)。
- 這種
空閑
空閑塊
可用於分配。空閑快
保持空閑,直到顯式地被應用分配。
-
分配器
有兩種基本分格。-
都要求應用
顯式
分配。 -
不同之處在於那個實體負責釋放已分配的塊。
-
顯式分配器(explict allocator)
-
要求應用程序顯式地
釋放
。 -
C語言中提供一種叫
malloc
程序顯示分配器。malloc
和free
-
C++
new
和delete
-
-
隱式分配器(implicit allocator)
-
要求
分配器
檢測一個已分配塊何時不再被程序所使用,那么就釋放
這個塊。 -
隱式分配器
又叫做垃圾收集器(garbage collector)
.- 自動釋放未使用的已分配的塊的過程叫做
垃圾收集(garbage collection)
.
- 自動釋放未使用的已分配的塊的過程叫做
-
Lisp
,ML
以及Java
等依賴這種分配器。
-
-
本節剩余的部分討論的是顯示分配器
的設計與實現。
9.9.1 malloc和free 函數
malloc
C標准庫提供了一個稱為malloc
程序包的顯示分配器
。
#include<stdlib.h>
void* malloc(size_t size);
返回:成功則為指針,失敗為NULL
-
malloc
返回一個指針,指向大小為至少size
字節的存儲器塊。- 不一定是
size
字節,很有可能是4
或8
的倍數
- 這個
塊
會為可能包含在這個塊
內的任何數據對象
類型做對齊。 Unix
系統用8
字節對齊。
- 這個
malloc
不初始化它返回的存儲器。- 如果想要初始化,可以用
calloc
函數。calloc
是malloc
一個包裝函數。
- 如果想要初始化,可以用
- 想要改變已分配塊大小。
- 用
realloc
h函數
- 用
- 不一定是
-
如果
malloc
遇到問題。- 返回
NULL
, 並設置errno
。
- 返回
-
動態存儲分配器
,可以通過使用mmap
和munmap
函數,顯示分配和釋放堆存儲器。-
或者可以使用
sbrk
函數。#include<unistd.h> void *sbrk(intptr_t incr); 返回:若成功則為舊的brk指針,若出錯則為-1,並設置errno為ENOMEML.
sbrk
函數通過將內核的brk
指針增加incr
(可為負)來收縮和擴展堆。
-
free
程序通過調用free
函數來釋放已分配的堆塊。
#include<stdlib.h>
void free(void *ptr);
返回:無
ptr
參數必須指向一個從malloc
,calloc
,realloc
獲得的已分配塊的起始位置。- 如果不是,那么
free
行為未定義。 - 更糟糕的是,
free
沒有返回值,不知道是否錯了。
- 如果不是,那么
這里的字=4字節,且malloc是8字節對齊。
9.9.2 為什么要使用動態存儲器分配
程序使用動態存儲器分配的最重要原因是:
- 經常直到程序實際運行時,它們才知道某些數據結構的大小。
9.9.3 分配器的要求和目標
約束
顯式分配器有如下約束條件
- 處理任意請求序列。
- 立即響應請求。
- 不允許為提高性能重新排列或
緩沖
請求。
- 不允許為提高性能重新排列或
- 只使用
堆
。 - 對齊
塊
。- 上文的
8
字節。
- 上文的
- 不修改已分配的塊。
目標
吞吐率最大化
和存儲器使用率
最大化。這兩個性能要求通常是相互沖突的。
-
目標1:
最大化吞吐率
-
假定n個分配和釋放請求的某種序列
R1,R2,R3.....Rn
吞吐率 :
每個單位時間完成的請求數。
-
通過使
分配和釋放請求
的平均時間最小化 來最大化吞吐率
-
-
目標2:
最大化存儲器利用率
-
設計優秀的分配算法。
-
需要增加分配和釋放請求的時間。
-
評估使用
堆
的效率,最有效的標准是峰值利用率(peak utilization)
-
假定n個分配和釋放請求的某種序列
R1,R2,R3.....Rn
有效載荷(payload)
:如果一個應用程序請求一個p
字節的塊,那么得到的已分配塊
的有效載荷
是p
字節。(很有可能會分配p+1
個字節之類的)聚集有效載荷(aggregate payload)
:請求Rk
完成之后,Pk
表示當前已分配塊的有效載荷
之后。又叫做聚集有效載荷
。Hk
表示堆的當前的大小(單調非遞減的)。
-
峰值利用率為
Uk
-
-
-
吞吐率
和存儲器利用率
是相互牽制的,分配器
設計的一個有趣的挑戰就是在兩者之間找到一個平衡。
9.9.4 碎片
造成堆利用率很低的主要原因是一種稱為碎片(fragmentation)
的現象。
碎片
:雖然有未使用的存儲器但不能滿足分配要求時的現象。-
1.
內部碎片
:已分配塊比有效載荷(實際所需要的)大時發生。- 比如:上文中只要5個字(有效載荷),卻給了6個字(已分配塊),那一個多的就是
碎片
. - 任何時刻,
內部碎片
的數量取決於以前請求
的模式和分配器的實現方式。- 可計算的,可量化的。
- 比如:上文中只要5個字(有效載荷),卻給了6個字(已分配塊),那一個多的就是
-
2.
外部碎片
:當空閑存儲器合計
起來足夠滿足一個分配請求,但是沒有一個單獨
的空閑塊足夠大可以處理這個請求發生的。外部碎片
的量化十分困難。- 不僅取決於以前
請求
的模式和分配器的實現方式,還要知道將來請求
的模式。
- 不僅取決於以前
- 優化: 需要
啟發式策略
來用少量的大空閑塊替換大量的小空閑塊。
-
9.9.5 實現問題
一個實際的分配器要在吞吐率
和利用率
把我平衡,必須考慮一下幾個問題。
- 空閑塊組織: 如何記錄空閑塊? (對應
9.9.6
) - 放置: 如何選擇一個合適的空閑快來放置一個新分配的塊? (對應
9.9.7
) - 分割: 將一個新分配的塊放入某個空閑塊后,如何處理這個空閑快中的剩余部分?(對應
9.9.8
) - 合並: 我們如何處理一個剛剛被釋放的塊
9.9.6 隱式空閑鏈表(老本行了)
堆塊
(十分巧妙的利用了本該永遠為0的低三位):
- 一個
塊
由一個字的頭部
,有效載荷
,以及可能的填充
組成。頭部
:編碼了這個塊
的大小(包括頭部和填充),以及這個塊
是否分配。- 假設是
8字節
的對齊約束條件- 那么頭部低三位一定是
0
。 - 所以釋放低三位來表示一些其他信息。
- 即塊大小還是能表示
0~2^32
(只是必須是8的倍數),非0~2^29
。 - 低三位就能表示
是否分配
之類的信息。
- 即塊大小還是能表示
- 那么頭部低三位一定是
- 假設是
將堆
組織為一個連續的已分配塊
和空閑塊
的序列。
這種結構就叫做隱式空閑鏈表
-
隱式
:-
為什么叫
隱式鏈表
。- 因為不是通過指針(
next
)來鏈接起來。 - 而是通過
頭部
的長度隱含地鏈接起來。
- 因為不是通過指針(
-
終止頭部
(類似與普通鏈表的NULL
)已分配
,大小為零
的塊
-
-
優缺點
:- 優點:簡單
- 缺點1:任何操作的
開銷
都與已分配塊和空閑塊的總數呈線性關系O(N)
.- 放置分配的塊。
- 對空閑鏈表的搜索。
- 缺點2: 即使申請一個
字節
,也會分配2
個字
的塊。空間浪費。
9.9.7 放置已分配的塊
當應用請求k
字節的塊,分配器搜索空閑鏈表,查找一個足夠大可以放置請求的空閑塊。
有一下幾種搜索放置策略
首次適配
從頭開始
搜索空閑鏈表,選擇第一個合適的空閑塊。
下一次適配
- 和
首次適配
很類似,但不是從頭開始,而是從上一次查詢
的地方開始。
- 和
最佳適配
- 檢查每個空閑塊,找一個滿足條件的最小的空閑塊(貪心)。
優缺點
首次適配
- 優點
- 往往將大的空閑塊保留在鏈表后面。
- 缺點
- 小的空閑塊往往在前面,增大了對
較大快
的搜索時間。
- 小的空閑塊往往在前面,增大了對
- 優點
下一次適配
- 優點
- 速度塊。
- 缺點
存儲器利用率
低
- 優點
最佳適配
-
優點
- 利用率高
-
缺點
- 要完整搜索鏈表,速度慢。
-
后面有更加精細復雜的
分離式空閑鏈表
。
-
9.9.8 分割空閑塊
兩種策略
-
占用
所有
空閑塊-
缺點:產生更多的
內部碎片
(但是如果內部碎片很少,可以接受) -
優點:能使得 空閑塊+已分配塊的數量減少
- 能加快
搜索速度
。 - 有的
外部碎片
(幾個字節,很有可能是外部碎片)可能根本放置不了東西,但是卻占用了搜索時間,還不如當內部碎片算了
- 能加快
-
放置策略趨向於產生好的匹配中使用。
- 即占用所有
空閑塊
,內部碎片也很少。
- 即占用所有
-
-
分割空閑塊
- 缺點:更多的空閑塊和已分配塊,搜索速度降低。
- 優點:空間利用率更高。
9.9.9 獲取額外的堆存儲器
如果分配器
不能為請求塊找到合適的空閑塊
將發生什么?
合並
相鄰的空閑塊(下一節描述)。sbrk
函數- 在最大化合並還不行的情況。
- 向內核請求額外的堆存儲器。
- 並將其轉為
大的空閑塊
- 將塊插入鏈表。
- 並將其轉為
9.9.10 合並空閑塊
假碎片
: 因為釋放
,使得某些時候會出現相鄰的空閑塊。
- 單獨的放不下請求(
碎片
),合並卻可以(假性
),所以叫假碎片
。
何時合並?
重要的決策決定,何時執行合並?
-
立即合並
- 定義:
塊
被釋放時,合並所有相鄰的塊。 - 缺點:對於某些請求模式,會產生
抖動
。
- 定義:
-
推遲合並
- 定義: 一個稍晚的時候,再合並。
- 比如:上文中的找不到合適空閑塊的時候。
- 定義: 一個稍晚的時候,再合並。
在對分配器的討論中,我們假設使用立即合並
。
但要知道,快速的
分配器通常會選擇某種形式的推遲合並
。
9.9.11 帶邊界標記的合並
Q
:釋放當前塊
后,如果要合並下一個
塊是十分簡單,但是合並上一塊
復雜度卻很高。
A
:Knuth
提出邊界標記
。
-
就是是
頭部
的副本。 -
其實就是
雙向鏈表
啦。 -
缺點:每個塊保持一個頭部和腳部,浪費空間。
- 在應用程序操作許多個
小塊
時,產生明顯的存儲器開銷
。
- 在應用程序操作許多個
Q
: 如何解決這種開銷
。
A
: 使用邊界標記
優化方法.
-
把前面塊的
已分配/空閑位
存放到當前塊多出來的低位(000
)中。- 這樣能快速判斷前面的是否是
分配/空閑
- 這樣能快速判斷前面的是否是
-
如果是
已分配
的,不需要處理。- 所以
已分配
的不需要腳部。
- 所以
-
如果是
未分配
的,需要處理。未分配
的依舊需要腳部。- 但是反正都是未分配的,占用一點不用的空間又怎樣?
十分優美的優化。
9.9.12 綜合:實現一個簡單的分配器
基於隱式空閑鏈表
,使用立即邊界標記合並
方式,從頭到尾講述一個簡單分配器的實現。
1.一般分配器設計
-
序言塊
8
字節的已分配塊。- 只有一個頭部和腳部組成。
- 初始時創建,永不釋放。
-
普通塊
malloc
和free
使用
-
結尾塊
- 大小為0的已分配塊。
-
序言塊和結尾塊都是用來消除合並邊界條件的小技巧。
之后具體的代碼不一一描述了,需要的時候翻閱。
9.9.13 顯式空閑鏈表
隱式空間鏈表
就是一個玩具而已,用來介紹基本分配器
概念。對於實際應用,還是太簡單。
優化1 顯式數據結構
根據定義,程序並不需要一個空閑塊
的主體。所以可以將空閑塊
組織成一種顯式數據結構。
- 雙向鏈表
-
優點:
- 使得首次適配的分配時間從
O(塊總數)
降低到O(空閑塊總數)
。
- 使得首次適配的分配時間從
-
不過
釋放
塊時可能是線性,也可能是常數(普通的是常數)- 取決於空閑鏈表中塊的排序策略。
-
后進先出(LIFO)
策略-
新釋放的塊直接放到雙向鏈表的開始處。(釋放常數級別)
- 前繼沒有
- 后繼就是之前的在第一個的。
-
(處理的好的話,合並也是常數級別)
-
-
地址優先
-
釋放是線性級別。
- 尋找合適的前繼要從頭遍歷。
-
更好的空間利用率。
-
-
- 取決於空閑鏈表中塊的排序策略。
-
缺點:
- 最小的空閑塊必須足夠大,提高了
內部碎片
程度。
- 最小的空閑塊必須足夠大,提高了
9.9.14 分離的空閑鏈表
分離存儲
: 維護多個空閑鏈表,其中每個鏈表中的塊有大致相等的大小。
- 一般的思路是將所有可能的塊大小分成一些等價類,也叫做
大小類(size class)
。- 有很多種方式定義
大小類
。- 根據2的冪 :
{1},{2},{3,4},{5~8},...{1025~2048},{2048~+oo}
. - 小的塊是本身,大塊按2的冪:
{1},{2},{3},{4},{5},{6},...{1025~2048},{2048~+oo}.
- 根據2的冪 :
- 有很多種方式定義
有關動態存儲分配的文獻描述了幾十種
分離存儲方法。
- 主要的區別在於
- 如何定義大小類。
- 何時進行合並。
- 何時向操作系統請求額外的堆存儲器。
- 是否允許分割。
我們介紹兩種基本的方法
簡單分離存儲(simple segregated storage)
和分離適配(segregated fit)
。
簡單分離存儲
-
大小類
- 每個
大小類
的空閑鏈表包含大小相等的塊,每個塊的大小就是這個大小類中最大元素的大小。- 例如,
{17~32}
中,這個類的空閑鏈表全是32
的塊。
- 例如,
- 每個
-
如何分配
- 檢查相應大小最接近的
空閑鏈表
-
如果非空,簡單的分配其中第一塊的全部。
- 不用分割,是全部哦
-
如果為空,請求一個固定大小的
額外存儲器片
,將這個片分割,然后加入對應的鏈表。- 然后繼續跳回非空執行。
-
常數級
- 檢查相應大小最接近的
-
如何釋放
- 直接
釋放
即可,然后分配器將釋放
后的塊直接插入空閑鏈表
。 常數級
- 直接
-
不分割,不合並。
- 已分配塊不需要頭部。
- 都不需要腳部。
-
最顯著的
缺點
- 很容易造成
內部碎片
和外部碎片
- 很容易造成
分離適配
分配器維護着一個空閑鏈表
的數組。
- 每個
空閑鏈表
是和一個大小類相關聯的,並且被組織稱某種類型的顯示或隱式鏈接。 - 每個
鏈表
包含潛在的大小
不同的塊。- 這些塊的
大小
是大小類
的成員。
- 這些塊的
有許多種不同的分離適配分配器,這里介紹一個簡單版本。
-
如何分配
- 對適當的
空閑鏈表
做首次適配。-
成功
- 那我們(可選的)
分割
它。 - 並將剩余部分插入到適當的
空閑鏈表
。
- 那我們(可選的)
-
失敗
- 繼續找
空閑鏈表
- 如果找遍了都沒有,就請求額外的堆存儲器。
- 繼續找
-
- 對適當的
-
釋放,合並。
釋放
一個塊,並執行合並,存入相應的空閑鏈表
。
分離適配方法
是一種常見的選擇,C標准庫提供的GUN malloc
包就是采用的這種方法。
- 快速
- 搜索時間少
- 對存儲器的
利用率
高- 對
分離空閑鏈表
簡單的首次適配
搜索,其存儲器利用率
近似對堆的最佳適配搜索
。
- 對
3. 伙伴系統
伙伴系統(buddy system)
是分離適配的一種特例,其中每個大小類都是2的冪。
-
大小類
- 都是2的冪,最大為
2^m
- 都是2的冪,最大為
-
如何分配
請求塊
大小向上舍入到最接近的2的冪,假設為2^k
。- 在空閑鏈表中找到第一個
2^j
,滿足(k<=j<=m
) - 二分變成
2^(j-1)
和2^(j-1)
兩部分,其中半塊丟入空閑鏈表中。- 兩者互相為
伙伴
。
- 兩者互相為
- 不斷上面步驟,直到
j=k
。 - 復雜度
O(log(m))
,很低
-
如何釋放,合並
- 釋放時,遞歸
合並
。- 給定地址和塊的大小,和容易計算它的伙伴地址。
- 如果
伙伴
處於空閑就不斷合並
,否則就停止。 - 復雜度
O(log(m))
,很低。
- 釋放時,遞歸
伙伴系統
分配器的主要
-
優點
- 它的快速搜索和快速合並。
-
缺點
- 要求塊大小為2的冪可能導致顯著的
內部碎片
。 - 不適合
通用目的
的工作負載。
- 要求塊大小為2的冪可能導致顯著的
-
對於預先知道其中塊大小是
2的冪
的系統,伙伴系統
分配器就很有吸引力。
9.10 GC_垃圾收集
垃圾收集器(garbage collector)
是一種動態存儲分配器。
垃圾
: 它自動釋放不再需要的已分配塊,這些塊稱為垃圾(garbage)
.垃圾收集(garbage collection)
:自動回收堆存儲的過程叫做垃圾收集
。- 應用
顯式
分配堆塊,但從不顯式
釋放堆塊。 垃圾收集器
定期識別垃圾快,並調用相應地free,將這些快放回空閑鏈表。
- 應用
垃圾收集
可以追溯到John McCarthy
在20實際60年代早期在MIT開發的Lisp
系統。
- 它是
Java
,ML
,Perl
和Mathematic
等現代語言系統的一個重要部分。 - 有關文獻描述了大量的
垃圾收集
方法,數量令人吃驚。 - 我們討論局限於
McCarthy
自創的Mark&Sweep(標記&清除)
算法。- 這個算法很有趣。
- 它可以建立已存在的
malloc
包的基礎上,為C和C++提供垃圾收集。
9.10.1 垃圾收集器的基本知識
垃圾收集器
將存儲器視為一張有向可達圖
。
-
圖的結點被分成一組
根結點
和一組堆結點
堆結點
對應於堆中一個已分配的塊。根結點
對應於這樣一種不在堆中的位置。- 包含指向堆的
指針
,寄存器
,棧里的變量
,或者是虛擬存儲區域中讀寫數據區域中的全局變量
- 包含指向堆的
- 有向邊
p->q
意味着塊p
中的某個位置指向塊q
中的某個位置- 實體化就是一個
指針
。
- 實體化就是一個
-
當存在一條任意從
根結點
出發到達p
的有向路徑時。- 我們說
p
是可達
的。 - 否則是
不可達的
,不可達
結點對應於垃圾。
- 我們說
垃圾收集器
的角色是維護可達圖
的某種表示,並釋放不可達結點返回給空閑鏈表。
-
ML
和Java
這樣的語言的垃圾收集器,對應用如何創建和使用指針都有嚴格的控制。- 能夠維護可達圖的精確的表示,因而能回收所有垃圾。
-
C
和C++
通常不能維護可達圖的一種精確表示。這樣的收集器叫做保守的垃圾收集器
保守
: 每個可達塊都被標記為可達塊,但有些不可達塊也被標記為可達塊。- 原因是,
指針
由自己管理,系統無法判定數據是否為指針,那么就不好精確的遍歷。
如果malloc
找不到合適的空閑塊,就會調用垃圾收集器
。回收一些垃圾到空閑鏈表。
- 關鍵的思想是: 用收集器代替應用調用
free
。
9.10.2 Mark&Sweep 垃圾收集器
Mark&Sweep
垃圾收集器由標記(mark)
階段和清除(sweep)
階段
標記
階段:標記出根結點的所有可達和已分配的后繼。清除
階段:后面的清除階段釋放每個未被標記的已分配塊。- 塊
頭部
的低位的一位用來表示是否被標記
。
- 塊
標記
的算法 就是從根結點開始,對結點的指針數據
深搜並標記。
- 通過
isPtr()
來判斷是否是指針,p
是否指向一個分配塊的某個字。- 如果是,就返回該分配塊的
起始位置
。
- 如果是,就返回該分配塊的
清除
的算法 就是遍歷圖,然后釋放未被標記的。
9.10.3 C程序的保守Mark & Sweep
(很有意思的一小節,敗也指針)
C語言的isPtr()
的實現有一些有趣的挑戰。
-
C
不會用任何類型信息來標記存儲器位置。- 無法判斷輸入參數
p
是不是一個指針。- 所以在
java
等語言里面,指針全部由系統管理。
- 所以在
- 無法判斷輸入參數
-
即使假設是,
isPtr()
也沒沒有明顯的方式判斷p
是否指向一個已分配塊的有效載荷的某個位置。-
解決方法: 將
已分配塊
維護成一顆平衡二叉樹
。
頭部
新增Left
,和Right
Left
:地址小於當前塊
的塊
。Right
:地址大於當前塊
的塊
。
- 通過判斷
addr<= p <= (addr + Size)
判斷是否屬於這個塊。
-
這樣子就能二分查找
p
屬於那個已分配塊
。
-
C語言是保守的原因是,無法判斷p
邏輯上是指針
,還是一個int標量
-
因為,無論
p
是個什么玩意,都必須去訪問,如果他是指針
呢?- 而且這個
int
剛好還是某個不可到達塊
的地址。那么就會有殘留。
- 而且這個
-
而且這種情況很常見,畢竟
指針
在數據段里畢竟不是特別多。 -
但是在
java
等語言里,指針由系統統一管理,那么很容易就知道p
是否是一個指針了。 -
比如
scanf("%d",a);
程序會把a的int值
看作指針
。而且運行中,無法判斷。
9.11 C程序中常見的與存儲器有關的錯誤
9.11.1 間接引用壞指正
scanf("%d",&val);
scanf("%d",val);
- 最好的情況 : 以異常中止。
- 有可能覆蓋某個合法的
讀/寫
區域,造成奇怪的困惑的結果。
9.11.2 讀未初始化的存儲器
堆存儲器
並不會初始化。
- 正確做法
- 使用
calloc
. - 顯示
y[i]=0
;
- 使用
9.11.3 允許棧緩沖區溢出(不太懂,還沒接觸I/O)
程序不檢查輸入串的大小就寫入棧中的目標緩沖區
- 那么就有
緩沖區溢出錯誤(buffer overflow bug)
。 gets()
容易引起這樣的錯誤- 用
fgets()
限制大小。
- 用
9.11.4 假設指針和它們所指向對象是相同大小。
有的系統里,int
和 int *
都是四字節,有的則不同。
9.11.5 越界
沒啥好說的。
9.11.6 引用指針,而不是它所指向的對象
對指針的優先級用錯。
例 :*size--
本意 (*size)--
- 錯誤:先操作的指針-1,再訪問。
9.11.7 誤解指針的運算
忘記了指針的算術操作是以它們指向的對象
的大小為單位來進行的,這種大小不一定是字節。
9.11.8 引用不存在的變量
返回一個指針,指向棧里面一個變量的地址。但是這個變量在返回的時候已經從棧里被彈出。
- 地址是正確的,指向了棧。
- 但是卻沒有指向想指向的變量。
9.11.9 引用空閑堆塊的數據
引用了某個已經free
掉的塊。在C++
多態中經常容易犯這個錯誤。
9.11.10 引起存儲器泄露
-
即是沒有回收垃圾。導致內存中垃圾越來越多。
- 只有重啟程序,才能釋放。
-
對於
守護進程
和服務器
這樣的程序,存儲器泄露是十分嚴重的事。- 因為一般情況,不能隨便重啟。
9.12 小結
虛擬存儲器
是對主存的一個抽象。
- 使用一種叫
虛擬尋址
的間接形式來引用主存。- 處理器產生
虛擬地址
,通過一種地址翻譯硬件來轉換為物理地址
。- 通過使用頁表來完成翻譯。
- 又涉及到各級緩存的應用。
- 頁表的內容由操作系統提供
- 通過使用頁表來完成翻譯。
- 處理器產生
虛擬存儲器
提供三個功能
-
它在主存中自動緩存最近使用的存放在
磁盤
上的虛擬地址空間內容。虛擬存儲器
緩存中的塊叫做頁
-
簡化了存儲器管理,
- 進而簡化了
鏈接
- 進程間
共享數據
。 - 進程的
存儲器分配
以及程序加載
。
- 進而簡化了
-
每條頁表條目里添加保護位,從而簡化了
存儲器保護
。
地址翻譯
的過程必須和系統中所有的硬件緩存的操作集合。
- 大多數條目位於
L1
高速緩存中。- 但是又通過一個
TLB
的頁表條目的片上高速緩存L1
。
- 但是又通過一個
現代系統通過將虛擬存儲器片
和磁盤上的文件片
關聯起來,以初始化虛擬存儲器片
,這個過程叫做存儲器映射
。
-
存儲器映射
為共享數據,創建新的進程 以及加載數據提供一種高效的機制。 -
可以用
mmap
手工維護虛擬地址空間區域
。- 大多數程序依賴於
動態存儲器分配
,例:malloc
- 管理虛擬地址空間一個稱為
堆的區域
- 分配器兩種類型。
顯示分配器
C
,C++
隱式分配器
JAVA
等
- 管理虛擬地址空間一個稱為
- 大多數程序依賴於
GC
是通過不斷遞歸訪問指針
來標記已分配塊
,在需要的時刻進行Sweep
。
C,C++
無法辨認指針導致無法實現完全的GC
。- 只有保守的
GC
。 - 需要配合平衡樹進行查找
p
所指向的塊
- 只有保守的