[CSAPP筆記][第九章虛擬存儲器][吐血1500行]


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)的專用硬件。
        • 利用存儲在主存中的查詢表來動態翻譯虛擬地址。
        • 查詢表由操作系統管理。

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來分配
    • 緩存的:當前緩存在物理存儲器的已分配頁。
    • 未緩存的:沒有緩存在物理頁面存儲器中的已分配頁。

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串聯起啦,即是物理地址

      • PPOVPO是相同的
      • 不要忘記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產生虛擬地址。
      • 第二步和第三部:MMUTLB取出對應的PTE
      • 第四步:MMU將這個虛擬地址翻譯成一個物理地址,發送到高速緩存/主存
      • 第五步:高速緩存/主存所請求的數據字返回給CPU
    • TLB不命中的時候,MMU必須從L1緩存或內存中取出相應的PTE,並進行類似缺頁處理過程

9.6.3 多級頁表

如果我們有一個32位地址空間,4KB大小的頁面(p=2^12)和一個4BPTE,即使應用所引用的只是虛擬地址空間中很小的一部分,也總是需要一個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級頁表層次結構的地址翻譯。

  • 虛擬地址被分為kVPN和一個VPO。每個VPN i都是i-1級頁表到i級頁表的索引。
  • PPN存於k級頁表。
  • PPO依舊與VPO相同。

此時TLB能發揮作用,因為層次更細,更利於緩存。使得多級頁表的地址翻譯不比單級頁表慢很多。

9.6.4 綜合:端到端的地址翻譯

在這一節里,我們通過一個具體的端到端的地址翻譯示例,來綜合一下我們學過的內容。

一個在有一個TLBL1 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
      • 層次結構的數據和指令高速緩存。
        • 物理尋址
        • L1L2 八路組相連
        • 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對齊。

    • 因為 PPO12位 = 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高速緩存。


然而實際的硬件實現使用了一個靈巧的技巧,允許這兩個步驟並行。加速了對高速緩存的訪問


例如:頁面大小為4KBCore i7上的虛擬地址有12位的VPO,且PPO=VPO.

而且物理地址的緩存,也是6位索引+6位偏移,剛好是VPO的12位。這不是巧合

  • 一方面通過VPNPPN
  • 另一方面直接通過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_startvm_end做比較。
        • 通過某種 樹的數據結構算法查找
    • 如果不合法,觸發段錯誤。
  • 試圖訪問的存儲器是否合法?

    • 即是否有讀,寫,執行這個頁面的權限?
    • 如果不合法,觸發保護異常,終止進程。
  • 一切正常的話

    • 選擇犧牲頁,替換,重新執行指令

9.8 存儲器映射

存儲器映射: Linux通過將一個虛擬存儲器區域與一個磁盤上的對象關聯起來,以初始化這個虛擬存儲器區域的內容,這個過程叫做存儲器映射

虛擬存儲器區域可以映射到以下兩種類型文件。

  • Unix文件系統中的普通文件:一個區域可以映射到一個普通磁盤文件的連續部分。

    • 例如,一個可執行文件。
    • 文件區(section)被分成大小的片,每一片包含一個虛擬頁面的初始化內容。
    • 僅僅是初始化虛擬頁面此時還並未進入物理存儲器
      • 直到CPU第一次引用這個頁面。
  • 匿名文件 : 一個區域 可以映射到一個匿名文件

    • 匿名文件由內核創建,包含的全是二進制零。

    • CPU第一次引用這樣區域(匿名文件)的虛擬頁面時。

      • 將存儲器中犧牲頁面全部用二進制零覆蓋。
      • 並將虛擬頁面標記為駐留在存儲器中。
      • 注意: 實際上,虛擬頁面並沒有跟存儲器進行數據傳送。
        • 反正是送零過去,不如我自己用零賦值,這樣子更快。
    • 又叫請求二進制零的頁(demand-zero page)

交換文件交換空間。(win下叫做paging file)

  • 一旦一個虛擬頁面被初始化了,它就在一個由內核維護的專門的交換文件(swap file)之間換來換去。交換文件也叫交換空間或者交換區域

  • 需要意識到,在任何時刻,交換空間都限制着當前運行着的進程分配的虛擬頁面總數。

  • 這一段不太明白。

9.8.1 再看共享對象

共享對象的由來

  • 許多進程有同樣的只讀文本區域
    • printf
    • 運行Uinx shelltcsh
    • 如果每個進程都加載進內存一次,極其浪費。
  • 存儲器映射提供一種機制,來共享對象

一個對象被映射到虛擬存儲器的一個區域,一定屬於以下兩種。

  • 共有對象
    • 一個進程將一個共有對象映射到它的虛擬地址空間的一個區域。
      • 進程對這個區域的寫操作,對於那些也把這個共享對象映射它的虛擬存儲器的進程可見的。
      • 這些變化也會反映到磁盤上的原始對象。
    • 映射到的虛擬存儲器那個區域叫做共享區域
  • 私有對象
    • 對一個映射到私有對象的區域做出的改變,對於其他進程不可見.
    • 並且進行的寫操作不會反映到磁盤上。
    • 映射到的虛擬存儲器那個區域叫做私有區域

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 動態存儲器分配

雖然可以使用更低級的mmapmunmap函數來創建和刪除虛擬存儲器的區域。

但是C程序員還是覺得用動態存儲器分配器(dynamic memory allocator)更方便。

  • 動態存儲器分配器維護着一個進程的虛擬存儲區域,稱為堆(heap)

    • 系統之間細節不同,但是不失通用型。
    • 假設
      • 是一個請求二進制零的區域。
      • 緊接着未初始化的bss區域,並向上生長(向更高的地址)。
      • 對於每個進程,內核維護一個變量brk(break),指向堆頂。
  • 分配器視為一組不同大小的塊block的集合來維護。

    • 每個塊就是一個連續的虛擬存儲器,即頁面大小。
    • 要么是已分配,要么是空閑
      • 已分配
        • 已分配的塊顯式地保留供應用程序使用。
        • 已分配的塊保持已分配狀態,直到它被釋放
          • 這種釋放要么是應用程序顯示執行。
          • 要么是存儲器分配器自身隱式執行(JAVA)。
      • 空閑
        • 空閑塊可用於分配。
        • 空閑快保持空閑,直到顯式地被應用分配。
  • 分配器有兩種基本分格。

    • 都要求應用顯式分配。

    • 不同之處在於那個實體負責釋放已分配的塊。

    • 顯式分配器(explict allocator)

      • 要求應用程序顯式釋放

      • C語言中提供一種叫malloc程序顯示分配器。

        • mallocfree
      • C++

        • newdelete
    • 隱式分配器(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字節,很有可能是48倍數
      • 這個會為可能包含在這個內的任何數據對象類型做對齊
      • Unix系統用8字節對齊。
    • malloc不初始化它返回的存儲器。
      • 如果想要初始化,可以用calloc函數。
        • callocmalloc一個包裝函數。
    • 想要改變已分配塊大小。
      • realloch函數
  • 如果malloc遇到問題。

    • 返回NULL, 並設置errno
  • 動態存儲分配器,可以通過使用mmapmunmap函數,顯示分配和釋放堆存儲器。

    • 或者可以使用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個字(已分配塊),那一個多的就是碎片.
      • 任何時刻,內部碎片的數量取決於以前請求的模式和分配器的實現方式。
        • 可計算的,可量化的。
    • 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字節的已分配塊。
    • 只有一個頭部和腳部組成。
    • 初始時創建,永不釋放。
  • 普通塊

    • mallocfree使用
  • 結尾塊

    • 大小為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}.

有關動態存儲分配的文獻描述了幾十種 分離存儲方法。

  • 主要的區別在於
    • 如何定義大小類。
    • 何時進行合並。
    • 何時向操作系統請求額外的堆存儲器。
    • 是否允許分割。

我們介紹兩種基本的方法

  • 簡單分離存儲(simple segregated storage)分離適配(segregated fit)

簡單分離存儲

  • 大小類

    • 每個大小類的空閑鏈表包含大小相等的塊,每個塊的大小就是這個大小類中最大元素的大小。
      • 例如,{17~32}中,這個類的空閑鏈表全是32的塊。
  • 如何分配

    • 檢查相應大小最接近的空閑鏈表
      • 如果非空,簡單的分配其中第一塊的全部。

        • 不用分割,是全部哦
      • 如果為空,請求一個固定大小的額外存儲器片,將這個片分割,然后加入對應的鏈表。

        • 然后繼續跳回非空執行。
    • 常數級
  • 如何釋放

    • 直接釋放即可,然后分配器將釋放后的塊直接插入空閑鏈表
    • 常數級
  • 不分割,不合並。

    • 已分配塊不需要頭部。
    • 都不需要腳部。
  • 最顯著的缺點

    • 很容易造成內部碎片外部碎片

分離適配

分配器維護着一個空閑鏈表的數組。

  • 每個空閑鏈表是和一個大小類相關聯的,並且被組織稱某種類型的顯示或隱式鏈接。
  • 每個鏈表包含潛在的大小不同的塊。
    • 這些塊的大小大小類的成員。

有許多種不同的分離適配分配器,這里介紹一個簡單版本。

  • 如何分配

    • 對適當的空閑鏈表做首次適配。
      • 成功

        • 那我們(可選的)分割它。
        • 並將剩余部分插入到適當的空閑鏈表
      • 失敗

        • 繼續找空閑鏈表
        • 如果找遍了都沒有,就請求額外的堆存儲器。
  • 釋放,合並。

    • 釋放一個塊,並執行合並,存入相應的空閑鏈表

分離適配方法是一種常見的選擇,C標准庫提供的GUN malloc包就是采用的這種方法。

  • 快速
    • 搜索時間少
  • 對存儲器的利用率
    • 分離空閑鏈表簡單的首次適配搜索,其存儲器利用率近似對堆的最佳適配搜索

3. 伙伴系統

伙伴系統(buddy system)是分離適配的一種特例,其中每個大小類都是2的冪。

  • 大小類

    • 都是2的冪,最大為2^m
  • 如何分配

    • 請求塊大小向上舍入到最接近的2的冪,假設為2^k
    • 在空閑鏈表中找到第一個2^j,滿足(k<=j<=m)
    • 二分變成2^(j-1)2^(j-1) 兩部分,其中半塊丟入空閑鏈表中。
      • 兩者互相為伙伴
    • 不斷上面步驟,直到j=k
    • 復雜度O(log(m)),很低
  • 如何釋放,合並

    • 釋放時,遞歸合並
      • 給定地址和塊的大小,和容易計算它的伙伴地址。
    • 如果伙伴處於空閑就不斷合並,否則就停止。
    • 復雜度O(log(m)),很低。

伙伴系統分配器的主要

  • 優點

    • 它的快速搜索和快速合並。
  • 缺點

    • 要求塊大小為2的冪可能導致顯著的內部碎片
    • 不適合通用目的的工作負載。
  • 對於預先知道其中塊大小是2的冪的系統,伙伴系統分配器就很有吸引力。

9.10 GC_垃圾收集

垃圾收集器(garbage collector)是一種動態存儲分配器。

  • 垃圾: 它自動釋放不再需要的已分配塊,這些塊稱為垃圾(garbage).
  • 垃圾收集(garbage collection) :自動回收堆存儲的過程叫做垃圾收集
    • 應用顯式分配堆塊,但從不顯式釋放堆塊。
    • 垃圾收集器定期識別垃圾快,並調用相應地free,將這些快放回空閑鏈表。

垃圾收集可以追溯到John McCarthy在20實際60年代早期在MIT開發的Lisp系統。

  • 它是Java,ML,PerlMathematic等現代語言系統的一個重要部分。
  • 有關文獻描述了大量的垃圾收集方法,數量令人吃驚。
  • 我們討論局限於McCarthy自創的Mark&Sweep(標記&清除)算法。
    • 這個算法很有趣。
    • 它可以建立已存在的malloc包的基礎上,為C和C++提供垃圾收集。

9.10.1 垃圾收集器的基本知識

垃圾收集器將存儲器視為一張有向可達圖

  • 圖的結點被分成一組根結點和一組堆結點

    • 堆結點對應於堆中一個已分配的塊。
    • 根結點對應於這樣一種不在堆中的位置。
      • 包含指向堆的指針寄存器棧里的變量,或者是虛擬存儲區域中讀寫數據區域中的全局變量
    • 有向邊p->q意味着塊p中的某個位置指向塊q中的某個位置
      • 實體化就是一個指針
  • 當存在一條任意從根結點出發到達p的有向路徑時。

    • 我們說p可達的。
    • 否則是不可達的不可達結點對應於垃圾。

垃圾收集器的角色是維護可達圖的某種表示,並釋放不可達結點返回給空閑鏈表。

  • MLJava這樣的語言的垃圾收集器,對應用如何創建和使用指針都有嚴格的控制。

    • 能夠維護可達圖的精確的表示,因而能回收所有垃圾。
  • CC++ 通常不能維護可達圖的一種精確表示。這樣的收集器叫做保守的垃圾收集器

    • 保守: 每個可達塊都被標記為可達塊,但有些不可達塊也被標記為可達塊。
    • 原因是,指針由自己管理,系統無法判定數據是否為指針,那么就不好精確的遍歷。

如果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 假設指針和它們所指向對象是相同大小。

有的系統里,intint *都是四字節,有的則不同。

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
      • 管理虛擬地址空間一個稱為堆的區域
      • 分配器兩種類型。
        • 顯示分配器
          • CC++
        • 隱式分配器
          • JAVA

GC是通過不斷遞歸訪問指針來標記已分配塊,在需要的時刻進行Sweep

  • C,C++無法辨認指針導致無法實現完全的GC
    • 只有保守的GC
    • 需要配合平衡樹進行查找p所指向的


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM