上一篇留下的那個缺口
第 2 篇帶你走過了位址轉譯的機械:一個虛擬位址被切成一個頁號和一個位移,MMU 拿頁號去分頁表裡查,找到一個裝著對應實體框格的分頁表項目,再把位移黏回去。你的程式做的每一次記憶體存取,都要經過這次查找。但整個故事悄悄地假設了那個分頁表項目真的指向某處——那一頁真的就坐在實體 RAM 裡。本篇談的,正是那個假設失敗的那一刻,而結果證明,那不是一個錯誤,反而是記憶體被配置出來的「正常」方式。
這裡有個 MMU 仰賴的關鍵事實。每一個分頁表項目,除了框格號之外,還帶著一小組旗標位元——其中一個就是 present(在場)位元。如果 present 是 1,這個項目裝著一個有效的實體框格,轉譯就在硬體裡無形地成功。如果 present 是 0,MMU 沒辦法完成轉譯:這一頁此刻「不在」實體記憶體裡。硬體不會去猜、也不會放棄——它做它被接線好、在無法轉譯時該做的那一件事,就是拋出一個錯誤(fault)、把控制權交給作業系統。
分頁錯誤究竟是什麼
分頁錯誤是 CPU 在指令執行到一半時拋出的一個陷阱,發生在它試圖轉譯一個分頁表項目 present = 0 的虛擬位址時。它不是 bug、不是當機,更絕對不是區段錯誤——請把這兩者乾淨地分開,因為名字長得很像。區段錯誤的意思是那個位址「無效」:你的程式根本沒資格去碰它。分頁錯誤的意思是那個位址完全「有效」、只是那一頁還沒被載入。一個是你的錯;另一個是系統在做它份內的事。
當錯誤觸發時,CPU 做的,和它對任何陷阱所做的一樣,這你在系統呼叫那一階已經見過:它從使用者模式切換到核心模式、跳到核心事先註冊好的一個固定處理常式——分頁錯誤處理常式。關鍵在於,硬體還會記下「哪一個」虛擬位址出錯了(在 x86-64 上它會落在一個控制暫存器 cr2 裡),這樣核心就確切知道該修哪一頁。處理常式的工作是問一個問題:這個位址,是這個行程「被允許」存取的東西嗎,如果是,那一頁上應該要有什麼?然後它據此行動。
需求分頁:為什麼頁是故意遲到的
現在來看回報。需求分頁是這樣一套政策:只在一頁「第一次被碰到」的那一刻、才把它帶進實體記憶體——「按需求」——而不是事先就帶進來。當你呼叫 malloc() 要 100 MiB,或你的程式啟動、載入器映射一個 50 MiB 的執行檔時,幾乎沒有任何東西馬上被複製進 RAM。核心只是在你的分頁表裡記下:這些虛擬頁存在、而它們的內容將從哪裡來,把每一個 present 位元都留在 0。在你的程式碼真的去讀或寫某一頁之前,沒有任何一個實體框格被投入。
所以分頁錯誤不是需求分頁勉強容忍的意外——它是需求分頁建立其上的「觸發器」。你第一次寫入一頁剛 malloc 出來的記憶體時,它的 present 位元是 0,MMU 出錯,核心處理常式看到一頁有效的堆積頁,抓一個空閒的實體框格、把它清零(這樣你絕不會看到別的行程的舊資料)、把 present 翻成 1、然後重啟你那條指令。你的儲存完成了。從你程式內部看,沒有任何不尋常的事發生:你寫了一個位元組,它就留下了。在底下,一整趟核心的來回剛剛在你需要的那個地方、那個時刻、把實體記憶體配置出來,而不在別處。
正是這份懶惰,讓虛擬記憶體感覺幾乎不要錢。一個行程可以映射一個稀疏的 1 GiB 陣列、卻只碰其中零星散落的幾 KiB;只有那些被碰過的頁才花真實的 RAM。這就是為什麼位址空間可以浩瀚而大部分空著、卻不浪費,而這也正是下一篇的寫入時複製、以及本階最後一篇的 mmap() 兩者都騎乘其上的同一套懶惰機械。搞懂需求分頁,你就握住了這兩者底下的引擎。
次要錯誤、主要錯誤,以及各自的代價
不是所有分頁錯誤的代價都一樣,而這個差別對效能影響極大。系統靠一件事來區分次要錯誤與主要錯誤:核心是否得去碰磁碟。「次要」錯誤是便宜、常見的情形——那一頁所需的資料已經在 RAM 裡的某處了,所以處理常式只需要修補分頁表。把一頁新堆積頁清零是次要錯誤。把一頁內容已快取在記憶體中的頁接上線,或一次寫入時複製的拆分(下一篇)也是。不碰磁碟,只是繞核心一小圈:微秒等級。
「主要」錯誤是昂貴的那種:那一頁的內容住在一個比較慢的裝置上,必須先被讀進來、你那條指令才能繼續。經典的情形,是第一次讀取執行檔或記憶體映射檔案的某一頁——它的位元組在磁碟上,所以處理常式發出一次真正的磁碟讀取、並「擋住你的行程」直到它完成。另一個來源是置換(swapping):在記憶體壓力下,核心把一頁閒置的頁逐出到磁碟上的置換區、好騰出它的框格;當你下次碰到那一頁時,一次主要錯誤必須把它讀回來。一趟磁碟來回是毫秒等級——比次要錯誤慢上數千倍——這正是為什麼主要錯誤才是你要盯的那種。
page fault | +-- address valid for this process? | | | no --> deliver SIGSEGV (the segfault) | yes | | | +-- contents already in RAM? | yes --> fix page table, present=1 (MINOR fault, ~us) | no --> read from disk/swap, then (MAJOR fault, ~ms) | fix page table, present=1 | +-- in all valid cases: restart the faulting instruction
當需求分頁反過來咬你:輾轉(thrashing)
需求分頁加上置換,是個美麗的幻覺——一路美到工作集裝不下為止。你的工作集,是你的程式在任何一小段時間裡實際用到的那一小撮頁——多虧了區域性,真實的程式碰到的頁,遠少於它們映射的。只要工作集裝得進 RAM,錯誤就稀少、而且大多是次要的,一切順暢運轉。但一旦把記憶體需求推過實體所能提供的,核心就被迫逐出那些「仍在使用中」的頁來騰空間——結果程式片刻之後又立刻把它們錯誤回來,而為了做到這件事,又得逐出「另一頁」仍被需要的頁。
那個惡性循環就是輾轉(thrashing):機器幾乎把所有時間都花在服務主要錯誤上——把頁在磁碟與記憶體之間來回搬運——而幾乎沒在跑你的程式碼。最明顯的徵兆,是一個系統從靈敏變成糖蜜般慢,而 CPU 大多閒著、磁碟燈卻一直亮著;吞吐量崩潰,儘管沒有任何單一程式做了什麼顯然錯誤的事。每個程式只是要求一個再也裝不下的工作集,而核心想公平地讓每個人都輪到的誠實嘗試,退化成了純粹的額外開銷。
這把你帶到了哪裡
你現在能讀懂 present 位元的整個故事了。一個 present = 0 的分頁表項目不是壞掉了——它是一份邀請。當 MMU 撞上它,它拋出一個分頁錯誤,核心檢視出錯的位址,於是兩件事之一發生:要嘛這次存取是非法的、你得到一個區段錯誤,要嘛它是合法的、核心把那一頁變成在場、彷彿什麼都沒發生地重跑你那條指令。第二條路,被刻意運用時,就是需求分頁——一套懶惰地、恰好在第一次碰觸時才配置實體框格的政策,正是它讓一個程式得以映射遠多於它所用的。
把那兩個代價等級記在心裡,因為它們正是分頁錯誤在真實效能工作裡現身的方式:一次次要錯誤是一趟不碰磁碟的微秒核心繞道;一次主要錯誤是一趟毫秒級的磁碟讀取,而在記憶體壓力下的一場暴風雪就是輾轉。握住這些,你就準備好迎接下一篇了——在那裡,同樣這個「碰觸即出錯」的技巧,再多拿一個旗標位元——寫入保護——來施展寫入時複製,正是這份懶惰的共享讓 fork() 變得便宜。