JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

分頁大小、共享分頁,與取捨

整套分頁機制我們都有了——分頁、分頁表、TLB,以及應付龐大位址空間的招數。這最後一篇導覽退一步,問起工程師會問的問題:一頁該多大?兩個行程怎麼能真的共用同一塊記憶體?而每一個選擇又付出什麼代價?

決定一頁的大小

整個這個階段,我們一直說「一個 4 KB 的分頁」,彷彿那個數字是天上掉下來的。它不是——它是個設計選擇,而和這主題裡多數選擇一樣,是個沒有免費贏家的取捨。分頁大小永遠是 2 的次方(這樣硬體就能把一個位址切成分頁編號與位移,完全不必做除法,只要切割位元即可),但要哪一個次方呢?真實系統出貨過從 512 位元組一路到 4 KB 的分頁,現代硬體還提供 2 MB 甚至 1 GB 的「巨大」分頁。要選得好,你得知道分頁變大時,什麼變好、什麼變糟。

先看小分頁的代價。每頁越小,一個行程需要的分頁就越多,而分頁表每頁一個項目——所以微小的分頁大小意味著一張龐大的表,光是「描述記憶體」就吃掉記憶體。較小的分頁也意味著每個 TLB 項目涵蓋的範圍更小,於是給定的工作負載會更快溢出TLB,你便付出更多失誤。而在分頁錯誤時,磁碟的額外開銷大致上是「每次傳輸」固定的,所以搬許多小分頁,遠不如搬少數幾個大分頁來得有效率。這三股壓力全都把人往更大的分頁推。

那為什麼不把分頁做得巨大無比?因為我們在配置那個階段遇過的浪費。分頁治好了外部碎裂,卻在每個行程的最後一頁留下一絲內部碎裂:平均而言一個行程浪費約莫半頁,因為它的最後一頁很少剛好滿到邊。用 4 KB 的分頁,那大約是每個行程損失 2 KB——微不足道。用 1 GB 的分頁,那將是平均每個行程浪費半 GB——荒謬。較大的分頁在分頁錯誤只為碰幾個位元組卻拖進一整個大分頁時,也浪費 I/O。所以:小分頁浪費表空間與 TLB 觸及範圍;大分頁浪費記憶體與 I/O。經典的 4 KB 是個歷久彌新的折衷,而巨大分頁則留給特殊的、龐大的、行為良好的工作負載。

Make the page BIGGER  ->   page table:        smaller  (fewer entries)   GOOD
                           TLB reach:         wider    (covers more)     GOOD
                           page-fault I/O:    fewer, bigger transfers    GOOD
                           internal frag:     bigger waste (~half a page) BAD

Make the page SMALLER ->   internal frag:     tiny waste                 GOOD
                           page table:        larger                     BAD
                           TLB reach:         narrower -> more misses     BAD
沒有制勝的大小,只有一個平衡。4 KB 歷經數十年仍存活,正因為它落在一般程式甜蜜點的附近。

一份複本、兩個讀者:共享分頁

這裡是分頁表幾乎免費奉送給我們的一份安靜禮物。回想一下,分頁表是每個行程私有的索引,把它的分頁編號對應到實體頁框——就像一本書後面的索引,把字詞指向頁碼。現在想像兩本不同的書,它們的索引恰好把某些項目指向了完全相同的實體頁面。兩個行程可以做到正是這件事:如果行程 A 第 7 頁的項目,和行程 B 第 12 頁的項目,都指名同一個頁框,那麼 A 和 B 就是透過不同的位址,望著同一塊共享的 RAM。這就是共享分頁,而一旦你看見它,作業系統節省記憶體的許多做法就咔嗒一聲到位了。

日常的回報是共享程式碼。像 C 標準函式庫這樣的共享函式庫,或是你已啟動五十次的某支程式那唯讀的文字段,執行期間從不改變。那為什麼要在 RAM 裡留著五十份一模一樣的複本?只映射一次,讓每個行程的分頁表都指向那單一複本。核心會在分頁表項目裡把那些共享分頁標成唯讀,這正是讓共享安全的原因——沒有人能在別人賴以為生的程式碼上亂塗。一份實體複本、許多邏輯視角,機器就能在同樣的 RAM 裡塞進多得多的程式。不過要記住這條規則:只有不變的、唯讀的資料,才能這樣乾淨地共享。

共享會變的東西:寫入時複製

唯讀共享很容易。但會改變的資料怎麼辦?回想一下行程那個階段,fork() 建立一個子行程,作為父行程整個位址空間近乎完美的複本。急切地複製每一頁既浪費又慢,尤其子行程常常立刻呼叫 exec(),把那些分頁全扔掉。優雅的修法是寫入時複製:在 fork 的當下,什麼都不要複製。反之,讓父與子共享每一頁,卻把它們在兩邊的分頁表裡全標成唯讀——連可寫的那些也標成唯讀。

如今只要雙方都只讀,共享就維持著。某一方一旦寫入某個共享分頁,硬體就看見一次「對唯讀分頁的寫入」並陷入核心——很像一次分頁錯誤,但是個保護錯誤。核心認出這是一個寫入時複製的分頁,便只為這個寫入者把那唯一一頁做一份私有複本,把它的項目翻回可讀寫,再讓寫入在那份新複本上進行。另一個行程仍保有原件,分毫未動。所以分頁是被懶惰地、一次一頁地複製,只在真有人去修改時才複製——而沒人寫的分頁,根本永遠不會被複製。

  1. fork() 執行:核心不複製記憶體,而是讓子行程的分頁表指向父行程的頁框,並把每個共享分頁在兩張表裡都標成唯讀。
  2. 兩個行程都執行,自由地從同樣的實體頁框讀取——快、便宜、不必複製。
  3. 某個行程試圖寫入一個共享分頁。這次寫入撞上唯讀分頁,陷入核心。
  4. 核心配置一個新的頁框,只把那一頁複製進去,把寫入者的項目重新指向那裡,並標成可讀寫。
  5. 寫入在私有複本上完成;另一個行程仍看見未變的原件。只有被碰過的分頁才會被複製。

取捨的全景

退一步就會注意到,這整個階段是一長串的取捨,而不是朝著單一正確答案的行軍。分頁本身換掉了外部碎裂,卻買進一點內部碎裂與分頁表的開銷。分頁表治好了連續性的問題,卻造出雙重記憶體存取的問題——每一次邏輯存取都需要一趟去讀表、另一趟去讀資料。TLB修好了常見情況,卻是個小而有限的快取,碰上冷的或散落的存取就會失誤,而它要靠參考的局部性才划得來。多層分頁表為稀疏的位址空間縮小了表,卻在每次失誤多加幾趟記憶體之行;反置分頁表則以更難、更慢的查找為代價,用另一種方式縮小它。

貫穿其中每一項的有一條線:每個修法都針對常見情況最佳化,並接受較糟的罕見情況。TLB 賭你很快會重用分頁(通常成立)。寫入時複製賭多數共享分頁始終不被寫(通常成立)。需求分頁賭你不會一次碰到位址空間的大部分(通常成立)。當賭注成立,分頁感覺起來是免費的;當它落空——隨機的存取樣式、一個碰遍每頁的寫入者、一個大於 RAM 的工作集——代價就會討回來,有時討得兇狠。

把這個階段帶在身上

你現在握有整個分頁的故事。記憶體被切成大小相等的頁框,而每個行程的視角被切成大小相等的分頁;每個行程一張分頁表把分頁對應到頁框,使外部碎裂無從發生;一個位址切成分頁編號與位移;分頁表基底暫存器讓硬體找到那張表;TLB 快取近期的轉譯,讓常見的存取只花一趟記憶體之行而非兩趟;分頁表項目的位元強制執行有效性、保護與髒污狀態;多層、雜湊與反置分頁表馴服龐大的位址空間;而分頁可以唯讀共享,或在寫入時懶惰地複製。這就是一套完整、可運作的心智模型,說明現代電腦如何讓每支程式「以為」自己獨佔整台機器。

注意我們一路上悄悄假設了什麼:每一個行程需要的分頁,都端坐在 RAM 裡。這個假設即將崩塌。同樣的這張分頁表,連同它的有效—無效位元,正是下一個大想法的樞紐——讓一個行程在大部分分頁仍躺在磁碟上時就執行,只在真正被碰到時才取進來。那就是虛擬記憶體搭配需求分頁,正在這個階段之後等著的主題。你在這裡學到的一切,都是它運轉所憑藉的機件;你已經造好了引擎,接下來就是把它發動。