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

需求分頁與分頁錯誤

如果一支程式能在它絕大部分都還沒被載入之前就先開始執行,而它從沒碰過的部分根本一開始就不必搬進來,會怎麼樣?這就是需求分頁,而讓它運作起來的,是一個友善的小陷阱——分頁錯誤。

只把被要求的東西搬進來

在上一篇導覽裡,你已經看到本階所建立的宏大幻覺:一個行程的邏輯位址空間可以遠大於你實際擁有的 RAM,因為它不必整個同時都待在實體記憶體裡。這篇導覽要回答接下來顯而易見的問題——那麼,一塊內容到底是「什麼時候」被搬進來的?那個誠實到近乎懶惰的答案,正是虛擬記憶體的核心:只有在某條指令真正伸手要某個分頁的那一刻,才把它搬進來,一刻也不提早。這個策略有個名字,叫做需求分頁,而它字面上就是這個意思:分頁是被需要時才到。

想像一本為宴席準備的厚厚食譜活頁夾——那就是磁碟上整支邏輯程式。笨拙的廚房會在大廚動手前,先把每一頁都影印一份。需求分頁則是那位更聰明的廚師:他讓活頁夾保持闔上,只在某道食譜真正用到某一頁的那一瞬間,才去影印那一頁。其中許多頁——甜點那一節、永遠不會跑到的錯誤處理、你從沒點開的功能——都被延後影印,甚至永遠不印。程式幾乎能立刻啟動,因為它要開跑,只需要它最最開頭的那一頁;而且它從不浪費力氣在沒人要的位元組上。當一支程式啟動時記憶體裡空無一物、連它第一條指令所在的那一頁都是當場才錯誤搬入時,我們稱這個極端情形為純需求分頁

一個位元,說「在」或「不在」

要讓需求分頁運作,硬體需要在每一次存取時,都能問一個是非題:這一頁此刻在實體記憶體裡嗎?你已經認識了分頁表——那本書的索引,把每個分頁號對映到裝著它的頁框。現在我們在每一列上多加一個小小的欄位:有效/無效位元。當它顯示「有效」時,這一頁確實正坐在某個頁框裡,而該列的頁框號可以信任。當它顯示「無效」時,硬體就必須停下並拒絕——而關鍵在於,「無效」這個字背負著兩個非常不同的意思。

一個意思是「這一頁屬於你的位址空間,但它目前在磁碟上、不在 RAM 裡」——合法,只是缺席。另一個意思是「這個位址根本不在你的位址空間裡——你沒道理碰它」。硬體光憑那個位元自己分辨不出這兩者;兩種情形讀起來都是「無效」。所以這個位元的工作很單純:把控制權往上彈給作業系統,由作業系統(在它自己的表裡)保有更完整的故事,知道哪些缺席的分頁是合法的。硬體拉響警報;核心——我們的大樓管理員——才來判斷這位訪客,是弄丟鑰匙的房客,還是闖空門的小偷。

把分頁表想成一列一列的,每一列有一個分頁號、一個頁框號,以及那個多出來的有效/無效欄位。第 0 頁也許讀作「頁框 12、有效」——它在 RAM 裡,而且頁框號可信。第 1 頁讀作「有效位元關閉」:它是你合法的一頁,但此刻住在磁碟上,所以它的頁框號是空白的。第 3 頁同樣讀作「無效」,卻是出於第二個理由——它根本就不曾屬於你的位址空間。當 CPU 要第 1 頁時,硬體看到位元是關的,便陷入作業系統,而作業系統查閱自己的地圖,得知第 1 頁只是缺席(去取它),而第 3 頁則是不合法(把該行程結束掉)。

分頁錯誤:是門鈴,不是災難

當一條指令觸及某個位元為「無效」的分頁時,硬體會引發一次分頁錯誤。「錯誤」這個詞聽起來嚇人,但對一個「缺席但合法」的分頁來說,它完全是例行公事——與其把它想成當機,不如想成一聲門鈴,一個中斷,說著「有人需要一個還沒搬進來的分頁」。它和你在行程發出系統呼叫時遇到的那種陷入核心是同一類,只不過這一次是由記憶體硬體在指令執行到一半時觸發的,而不是程式刻意去要求的。程式什麼都沒做錯;它只是碰到了一個尚未常駐的分頁,而作業系統就安靜地介入,把這件事擺平。

以下就是完整的服務流程——核心面對那聲門鈴的標準應答。值得慢慢走一遍,因為本階的第三篇導覽會詳細停留在每一步的成本上;現在,先把它整體的形狀記住。

  1. 硬體察覺到「無效」位元,當場凍結那條出事的指令,並陷入作業系統,把出錯的邏輯位址交給它。
  2. 作業系統查閱自己的紀錄:這一頁是該行程合法但缺席的部分,還是一次不合法的存取?若不合法,該行程就被結束掉(這就是亂跑的指標換來的那次乾淨當掉);若合法,就繼續往下。
  3. 在實體記憶體裡找一個空閒的頁框。(若沒有空閒的,就得先選一個犧牲分頁把它逐出——那整個故事叫做分頁置換,是下一階的事。)
  4. 排定一次磁碟讀取,把需要的那一頁從交換空間(或程式檔)複製進那個頁框。這是慢的部分;磁碟和 RAM 比起來慢如冰河,所以作業系統會讓這個行程睡著,趁這空檔去跑別人。
  5. 讀取完成後,更新分頁表:把頁框號寫進去,並把位元翻成「有效」。這一頁現在常駐了。
  6. 重新啟動當初出錯的那一條指令。它從頭再執行一次,這回那次存取就成功了,而程式從頭到尾都不知道自己曾經暫停過。

重新啟動指令——以及這為什麼很難

那最後一步藏著一個很深的要求。整套把戲能成立,前提是出錯的指令必須能像從沒跑過一樣、從頭重新啟動——這就是可重啟指令的要求。對一條單純的「從位址 X 載入一個值」來說,重啟很容易:那次載入根本沒完成,再做一次就好。危險的是那種在撞上缺席分頁之前、已經做完一部分工作的指令。想像一條區塊複製指令,把一大塊位元組從某個區域搬到另一個區域;假設它已經複製了一半,這時目的端跨進了一個尚未常駐的分頁,於是出錯。

如果我們服務完錯誤後天真地重啟那條指令,它會把整個區塊再複製一次——而來源的前半段可能在這期間已被覆寫,於是我們得到一團亂碼。CPU 用不同的辦法解決這個問題:有些會在動手做任何工作之前,先檢查這條指令將會碰到的每一頁都已存在;有些則保留足夠的隱藏狀態,好把已做的部分效果撤銷掉。對你而言,重點不是確切的修法,而是那個原則:一個架構唯有把它的指令設計成能乾淨重啟,才有可能支援需求分頁。這是硬體和作業系統必須協同設計的、那些安靜的角落之一。

為什麼連一丁點錯誤率都很傷——以及為什麼通常並不會

現在來看那令人清醒的算術。一次正常的記憶體存取,大約要 100 奈秒。服務一次分頁錯誤——跑去磁碟一趟——可能要好幾百萬奈秒(就算它 8 毫秒,也就是八百萬奈秒)。設 p 為分頁錯誤率,也就是會出錯的存取所佔的比例。那麼有效存取時間就是一個簡單的加權平均:絕大多數存取很快,少數慢得災難性,而你依各自發生的頻率把它們混在一起。

  effective access time = (1 - p) * 100 ns  +  p * 8,000,000 ns

  If p = 0          ->  100 ns           (the dream)
  If p = 1 in 1000  ->  about 8100 ns    (~80x slower!)
  If p = 1 in 1,000,000 -> about 108 ns  (only ~8% slower)

  A fault is ~80,000x slower than a hit, so 'rare' has to mean REALLY rare.
由於一次錯誤的代價是命中的數萬倍,錯誤率必須小到微乎其微,才能讓平均存取時間維持在接近記憶體速度。

看中間那一行:一千次存取裡出錯一次——聽起來很罕見——就已經讓機器慢了大約八十倍。要把拖慢控制在還能忍受的百分之幾,你需要讓錯誤頻率達到數十萬分之一甚至更稀有。所以需求分頁之所以根本行得通,全靠真實程式的一個美好事實:參考局部性。程式不會把它的存取隨機撒在整個位址空間上;它們會聚集,在一段時間裡反覆打同一小撮分頁(一個迴圈、一個熱門的資料結構),之後才漂到別處。一個行程當下正積極使用的那一撮分頁,就是它的工作集。把每個行程的工作集留在 RAM 裡,錯誤就能幸福地維持稀有;本階接下來大半,講的都是尊重局部性,好讓 p 保持微小。

同一招的再利用:便宜的 fork 與映射檔案

一旦你手上有了分頁表、有效/無效位元,以及把錯誤當成工具,作業系統就會把它們再利用,換來「不只是塞得下比 RAM 還多」之外的巧妙好處。最好的例子是寫入時複製,它讓 fork 變得便宜。當一個行程 fork 時,子行程理應拿到父行程記憶體的一份完整自有副本——天真地做,就是一次龐大又緩慢的複製。作業系統反過來讓父與子共用「完全相同」的實體頁框,並標記為唯讀。兩者都能自由地讀。只有當其中一方試圖寫入時,錯誤才會引發;這時核心才為「那唯一一頁」做一份私有副本,修好寫入方的表,再讓那次寫入完成。那些只會被讀的分頁,根本永遠不會被複製。

另一種再利用是記憶體映射檔案:與其用一個個明確的 read(fd, buf, n) 呼叫去讀檔案,你改而請作業系統把檔案的內容直接映射進你的位址空間,於是那個檔案就單純地以一段記憶體的樣子出現。讀檔案的第 5000 個位元組,變成讀那段映射的記憶體位址 5000——而分頁錯誤正是在第一次觸碰時,把每一塊從磁碟取進來的東西,就是你剛學到的那套需求分頁機制,只不過現在服務的是一個檔案,而不是一個被換出的分頁。本階的第五篇導覽會把這兩者各自完整地展開;現在,只要留意一件事:同一個機制——出錯、修好、重啟——如何安靜地驅動著三項相當不同的功能。