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

寫入時複製與記憶體映射檔案

你剛剛建立起來的分頁錯誤機制,不只是替裝不下的記憶體準備的安全網。把它反過來用,它就成了一件富有創造力的工具:兩個行程可以共用同一份記憶體,直到某人真的寫入的那一刻為止;而磁碟上的一個檔案,可以變成一段你像操作陣列那樣直接讀寫的區域。這篇導覽會說明同一個陷阱如何驅動這兩種把戲,以及支撐這一切的交換空間究竟住在哪裡。

把錯誤當工具,而非當問題

在這個階段裡,分頁錯誤目前一直扮演著「意外」的角色:某個行程碰了一頁不在 RAM 裡的記憶體,硬體陷入核心,核心手忙腳亂地把缺失的頁取回來,再重新執行那道指令。那就是第三篇導覽的全部故事。但退一步就會注意到,分頁錯誤的本質其實是什麼——它是一個可程式化的掛鉤,恰恰在某個行程觸碰某一頁時觸發。核心可以當場決定,那一次觸碰應該意味著什麼。

這個重新框定,是整篇導覽的關鍵。一旦你把分頁錯誤看成一個核心可以隨意應答的門鈴,兩種美妙的把戲就自然落出來。第一,核心可以讓許多行程共用同一份頁的實體副本,只在第一次寫入時才把它們拆開——這就是寫入時複製。第二,核心可以把磁碟上的一個檔案直接映射進某個行程的位址空間,於是讀寫記憶體就悄悄地在讀寫那個檔案——這就是記憶體映射檔案。兩者所用的,不過就是有效位元、分頁表裡的保護位元,以及你早已理解的那個錯誤處理常式而已。

寫入時複製:便宜的 fork

回想一下行程那個階段的fork():它建立一個子行程,是父行程幾乎一模一樣的複本,擁有整個位址空間的自有副本。若照字面實作,那貴得嚇人。如果父行程用了 500 MB,fork 就得在子行程連一道指令都還沒執行之前,先複製 500 MB 的頁。更糟的是,極為常見的模式是 fork 緊接著 exec(),後者把那整份剛複製好的副本丟掉,載入一個全新的程式。你會為了在幾微秒後就丟掉它,而複製了半個 GB。這很荒謬,而寫入時複製正是解法。

這個想法是「聰明而安全地說謊」。當 fork 執行時,核心根本不複製任何資料頁。它反而給子行程一個分頁表,指向父行程所用的那些一模一樣的實體頁框——它們成了共用頁——並把這些項目在兩個行程裡都標成唯讀。如今父與子看著的是同一份記憶體的實體副本。只要他們都只讀,這就完全正確:讀取不會改動任何東西,所以共用是看不見的。那昂貴的複製,就只是沒有發生而已。

魔法發生在第一次寫入。假設子行程試著把一個值存進某個共用頁。那一頁被標成唯讀,於是硬體掀起一個保護錯誤——和分頁錯誤是同一套陷阱機制,只是由一次被禁止的寫入、而非一頁的缺失所觸發。核心的處理常式認出這是一個寫入時複製的頁,而不是真正的錯誤。它把那「單獨一頁」複製到一個新的空閒頁框裡,更新那個正在寫入的行程的分頁表,讓它指向這份私有副本,把兩份副本都重新標成可寫,然後重新執行那道指令。寫入如今落在該行程自己的頁上;另一個行程仍保有原本那份。只有真正被修改的那些頁,才會真的被複製——所以一次 fork 接 exec 幾乎不花成本,因為 exec 在不過寥寥幾頁被寫到之前,就把整個位址空間替換掉了。這正是為什麼 fork 在每一個現代類 Unix 系統上都很便宜:成本已被推遲到寫入的那一刻,而往往那一刻永遠不會到來。

After fork(), before any write:

  parent page table --\
                       >--> [ frame 7 ]  (read-only, shared)
  child  page table --/

Child writes to that page -> protection fault -> COW copy:

  parent page table -----> [ frame 7 ]  (writable again)
  child  page table -----> [ frame 9 ]  (private copy, writable)
寫入之前,兩張表都指向同一個頁框;第一次寫入分裂出一份私有副本,另一個行程則完全不受影響。

記憶體映射檔案:一個其實只是陣列的檔案

現在把同一套機制指向一個檔案。平常你用像 read(fd, buf, n) 這樣明確的呼叫來讀檔案,它把位元組從核心的磁碟緩衝區複製進你自己的緩衝區,寫回去也是同樣的方式。一個記憶體映射檔案則跳過所有這些繁文縟節。用一個呼叫(經典的名字是 mmap),你請核心把檔案的一段區域直接映射進你的位址空間。從此,那個檔案就「是」一段記憶體:檔案的第 0 個位元組落在某個位址,第 100 個位元組在再往後一百位元組處,而你讀或改這個檔案,就是讀或改那些位址,就好像它是記憶體裡的一個陣列。

核心怎麼在不事先讀完整個檔案的情況下辦到這件事?就用需求分頁處理任何其他位址的同一種方式。映射時,核心替那段區域設好分頁表,卻把項目標成無效——還沒有載入任何頁框。你的程式第一次碰到映射裡的某個位元組時,你會吃到一個分頁錯誤;處理常式看出這一頁屬於一個被映射的檔案,便從磁碟把那「一頁」讀進來,修好項目,重新執行你的指令。你只讀你真正碰到的那些頁,按需取用,用的就是你整個階段一直在研究的那同一條錯誤路徑。

寫入則是反過來運作,而髒位元在這裡就派上用場了。當你存進一個被映射的頁,硬體會把那一頁的髒位元設起來,標記它已被修改。核心不急著把改動沖到磁碟;它讓你的髒頁累積起來,再懶洋洋地寫回去——在需要記憶體時、在你明確要求時,或在你解除映射那段區域時。從未被修改的乾淨頁,根本不需要寫回,所以髒位元讓核心得以跳過無謂的磁碟 I/O。這也是為什麼兩個行程映射同一個檔案就能共用它:它們映射相同的頁框,一方所做的改動會被另一方看見,因為記憶體裡確確實實只有一份副本。

被逐出的頁去了哪裡:交換空間

這一切引出了一個我們至今一直閃躲的問題。當 RAM 滿了,而你在第四篇導覽遇到的分頁置換挑出一個犧牲者要逐出時,那個犧牲者去了哪裡?如果這一頁來自一個被映射的檔案,答案很簡單:把它寫回磁碟上它的檔案,或者,若它是乾淨的,就直接丟掉。但若是一個普通的可寫頁呢——一塊堆疊、堆積,或一個已被私有複製過的寫入時複製頁?它沒有檔案可以回家。它需要磁碟上一塊專為此目的保留的暫存區。那塊區域就是交換空間

交換空間是磁碟上的一塊區域——一個專用的分割區或一個檔案——核心拿它當作 RAM 的溢位延伸。當這樣一頁被逐出時,核心執行一次分頁換出:它把這一頁的內容複製到交換空間裡一個空閒的格子,並在這一頁的分頁表項目裡記下它如今住在磁碟的何處。那個頁框於是空出來給別人用。稍後,如果這個行程又碰到那一頁,它會發生錯誤;處理常式看出項目雖無效卻標著一個交換位置,便把這一頁從交換空間讀回一個新的頁框,再重新執行那道指令。這又是需求分頁——只不過後備儲存是交換空間,而不是一個檔案。

  1. RAM 滿了,分頁置換挑出一個犧牲頁以騰出空間。
  2. 如果犧牲者是乾淨的(它的髒位元沒被設起),就直接丟棄它——磁碟上或它的映射檔案裡早已有一份一模一樣的副本。
  3. 如果犧牲者是髒的且有後備檔案,就把它寫回那個檔案;如果它是匿名的(堆疊、堆積、複製過的寫入時複製頁),就把它換出到交換空間。
  4. 在這一頁的分頁表項目裡記下新的磁碟位置,並把項目標成無效,好讓日後的存取發生錯誤、把這一頁帶回來。

交換空間也正是讓記憶體超額配置成為可能的東西——同時也是讓它變得危險的東西。核心可以發放遠多於它所擁有的 RAM 的虛擬記憶體,賭的是行程們不會同時全部碰到所有那些記憶體;寫入時複製與懶惰映射讓這個賭注通常是安全的。但若這賭注賭錯了,大家都碰了自己的頁,系統就會重重地壓在交換空間上,而因為磁碟比 RAM 慢上數千倍,它可能崩落成輾轉現象——幾乎把所有時間都花在把頁換進換出,而不是在運算。交換空間讓你執行超出容量的工作,卻無法讓磁碟和記憶體一樣快,而過度依賴它,正是一台忙碌機器慢到爬行的原因。

把這個階段串起來

看看這篇導覽需要的新機制有多麼少。寫入時複製、記憶體映射檔案,以及從磁碟換入,全都是同一個想法換穿不同的衣裳:一個分頁表項目可以被標成無效或唯讀,一次觸碰於是陷入核心,核心再決定那次觸碰該做什麼——複製一頁、從檔案讀一頁、從交換空間讀一頁。第二、三篇導覽裡那個單一的機制,那個倚靠在分頁表項目的有效位元上的「錯誤再重啟」迴圈,做著所有的工作。

而這一切之所以行得通,全靠這個階段其餘部分所確立的那些真理。推遲一次複製或載入之所以正確,是因為指令是可重啟的——那道發生錯誤的存取可以被重試,毫髮無傷。它之所以負擔得起,是因為參考局部性工作集:一個行程在任何時刻只碰到它的頁裡那一小撮、緩慢移動的幾頁,所以去取回其餘部分的那些錯誤是罕見的。把局部性抽走,這些聰明的把戲就會在幾乎每一次存取時都發生錯誤,而整座大廈會把自己輾轉到死。

這就是整個階段的深層課題。虛擬記憶體不是一個讓你跑得更快的把戲——每一次分頁錯誤都是純粹的額外負擔,而一個輾轉中的系統比一個 RAM 充足的系統慢得多。虛擬記憶體買到的是「可能性」:比 RAM 還大卻照樣能跑的程式、幾乎不花成本的 fork、被你當記憶體看待的檔案,以及數十個行程共存於「每個都獨佔整台機器」的幻覺之中。你最初當成緊急事件遇到的那個分頁錯誤,到頭來竟是這一切背後那具安靜的引擎。