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

寫入時複製,以及 fork 如何保持便宜

fork() 看起來像在一瞬間複製了整個行程——它記憶體裡的每一個位元組。若這是字面意義上的真複製,搬移好幾 GB 會讓它慢如爬行。本篇揭開那個把戲:父行程與子行程共用相同的實體頁、標成唯讀,而某一頁只在有人寫入它的那一刻才被複製。這正是上一篇那套分頁錯誤機制,被轉用到一個不同而美麗的用途上。

fork() 看似製造的問題

在「行程」那一階,你見過了 fork()——透過複製呼叫它的行程來建立一個新行程的系統呼叫。子行程拿到一切的副本:同樣的程式碼、同樣已開啟的檔案描述符,以及——這裡我們在意的部分——它自己私有的定址空間,一份父行程記憶體的完整複本。fork 之後兩者彼此獨立:子行程改動的變數不會在父行程裡改變,反之亦然。那份獨立性正是重點所在,而且它是真實的。

但照字面讀,這聽起來貴得要命。假設父行程是一個用掉 4 GiB 記憶體的資料庫。一個天真的 fork() 得先找到 4 GiB 空閒的實體記憶體,然後把全部 4 GiB 一個位元組一個位元組複製過去,子行程才能執行哪怕一條指令。光是那次複製就可能花掉可察覺的零點幾秒——對一個程式不斷在做的操作而言,這是一段永恆。更糟的是,那通常是白費的:子行程在 fork() 後最常做的事,就是立刻呼叫 exec()、用一支不同的程式取代它整個記憶體影像。你複製了 4 GiB,卻在幾微秒後就把它丟掉。

於是核心面臨一個張力。子行程「表現上」必須像是擁有一份完整、私有、獨立的父行程記憶體副本——那是程式所預期、所仰賴的。然而事先真的做出那份副本既慢又幾乎總是白費。化解之道是作業系統裡最優雅的把戲之一,也是本篇的主題:給子行程一份邏輯上立即、實體上只在需要時才存在的副本。

先共享,晚複製

讓這把戲奏效的關鍵觀察是這個。只要沒有人改動其中任何一份,兩份內容相同的資料就無法區分。如果父行程與子行程都只「讀取」某一頁,它們分不出自己讀的是私有副本、還是單一份共享的原件——反正位元組都一樣。一份獨立副本真正開始重要的唯一時刻,是當其中一方「寫入」時,因為那是它們兩個版本會開始分歧的第一個瞬間。那麼,何必在那一刻之前就複製?何不一路共享單一份實體副本,直到那次逼它們分開的寫入為止?

這正是 寫入時複製(copy-on-write,COW)所做的事。fork() 執行時,核心「並不」複製父行程的頁。它反而讓子行程的分頁表指向父行程早已在用的那些實體頁框。現在兩個行程的分頁表,都把各自的頁對映到同一組共享的頁框。此刻,這份「副本」純粹是兩張分頁表裡的項目——幾千筆小小的對映,而非好幾 GB 的資料。這就是為什麼無論父行程多大,fork() 幾乎都瞬間返回:它複製的是分頁「表」,從不複製頁的「內容」。

但共享帶來一個明顯的危險。如果兩張分頁表指向同一個頁框、而父行程往裡頭寫入,子行程也會看見那個改動——它原本應該私有的副本會被汙染,我們承諾過的獨立性就成了謊言。我們需要一個辦法,去攔截對任何共享頁的第一次寫入,好在那次寫入落地之前悄悄做出一份真正的副本。實現它的機制,就坐在虛擬記憶體這一階這裡,在分頁表項的位元裡。

唯讀陷阱

回想第 2 篇:每一筆分頁表項都帶著權限位元,其中之一是可寫位元。回想第 3 篇:一次被 MMU 判定為非法的存取——包括「對唯讀頁的寫入」——會引發一次分頁錯誤,把控制權交給核心。寫入時複製把這兩個事實接在一起。當 fork() 共享那些頁時,它把每一筆共享的分頁表項標成唯讀,在父行程「與」子行程的表裡「都」標——即使對它們各自而言,資料邏輯上是可寫的。讀取直接通過、維持共享、不花成本。但任一方的第一次寫入,都會撞上那個唯讀位元。

那次寫入觸發一個保護分頁錯誤,現在輪到核心上場。它檢視出錯的那一頁、認出這個情況:這不是真正的權限錯誤,而是一個共享的寫入時複製頁,終於有人想去改它了。於是它做那件被它延後的複製——但「只做一頁」。它配置一個全新的實體頁框、把共享頁的內容複製進去、把那個正在寫入的行程的分頁表項重新指向新的私有頁框、把那筆項目設回可寫,再重新執行剛才出錯的那條指令。這次寫入落在了該行程自己的私有頁上。另一個行程仍然對映著原本那個頁框、毫髮無傷。它們分歧了,恰好就在那需要分歧的一頁上,而且早一個位元組都不會。

fork():
    parent page  ->  [ frame 0x9000 ]  <-  child page    (both READ-ONLY)

child writes to its page:
    write -> read-only PTE -> protection page fault -> kernel
    kernel copies frame 0x9000 into a fresh frame 0xA000

after the fault is serviced:
    parent page  ->  [ frame 0x9000 ]   (READ-ONLY, still shared with nobody now)
    child page   ->  [ frame 0xA000 ]   (WRITABLE, private copy)
在第一次寫入時,一個共享頁變成兩個私有頁。只有這一頁被複製;任一方都沒寫過的每一頁,在頁框層級維持共享。

為什麼這讓 fork() 變得便宜

現在好處清楚了。fork() 的成本不再隨父行程用了多少記憶體而擴大——它隨父行程與子行程之後「改動」了多少而擴大。複製值 4 GiB 的分頁表對映、把一切標成唯讀、然後返回:無論背後是不是 4 GiB,這都既快又大致恆定。從那之後,代價是一頁一頁付的,只為真正被寫到的頁付,也只由實際寫它的那一方付。一個讀很多卻寫很少的子行程幾乎不花什麼成本。這和上一篇的需求分頁是同一種懶惰精神:在某個錯誤證明工作真的有必要之前,什麼都不做。

在最常見的模式上贏得最多:先 fork 再 exec。當 shell 執行一個指令時,它 fork、而子行程立刻呼叫 exec(),那會丟棄整個繼承來的定址空間、載入一支新程式。有了寫入時複製,子行程在 fork() 與 exec() 之間幾乎不碰任何繼承來的頁——所以幾乎什麼都不會被複製。核心設好了幾千筆唯讀對映,子行程寫到了其中寥寥幾筆(也許是 exec() 呼叫用的一兩頁堆疊),然後 exec() 把所有對映都扔了。一個 4 GiB 的父行程 fork 並 exec 了一支新程式,卻只複製了微不足道的寥寥幾頁。這就是為什麼在 Unix 上、即使從一個巨大的父行程,啟動一個行程也很快。

看見它,以及它出現在哪裡

你可以在分頁錯誤的計數器裡看見寫入時複製在發生。回想第 3 篇那個區分:次要錯誤(在記憶體裡解決)對上主要錯誤(需要磁碟)。一次寫入時複製錯誤永遠是一次次要錯誤:那一頁早已在 RAM 裡,核心只需配置一個頁框、複製一頁,完全不牽涉磁碟。所以一個 fork 後又弄髒許多共享頁的行程,會顯示次要錯誤的尖峰、卻沒有主要錯誤。像 `/usr/bin/time -v ./a.out` 這樣的工具會回報「minor page faults」,而一個大量 fork、或 fork 後大量寫入的程式,會把那個數字點亮。

為了讓它具體,我們走一遍一個普通的全域變數在一次 fork 前後發生的事。

  1. 在 fork() 之前,父行程有一個 int counter 住在某一頁裡;它的分頁表項是正常且可寫的,對映到實體頁框 0x9000。
  2. fork() 執行。核心把分頁表複製進子行程,並在兩個行程裡都把那一頁標成唯讀。counter 現在住在單一共享頁框 0x9000,父子行程一致地以唯讀對映它——沒有資料被複製。
  3. 子行程做 counter = counter + 1。那次寫入撞上唯讀的分頁表項,MMU 引發一次保護分頁錯誤,核心接手。
  4. 核心配置一個全新頁框 0xA000,把那一頁(含 counter 當前的值)複製進去,把子行程的分頁表項重新指向 0xA000 並設為可寫,再重新執行那次加法。子行程的 counter 在它私有的頁裡遞增;父行程的 counter 仍在 0x9000、原封不動。
  5. 子行程從未寫過的其他每一頁,都在頁框層級永遠維持共享——或直到 exec() 把整份對映丟棄為止。所承諾的獨立性守住了,而它是一頁一頁付出代價換來的。

寫入時複製比 fork() 大得多。同一個「唯讀共享、然後再複製」的點子也支撐共享零頁(一塊剛配置的區域,會把每一頁都對映到單一共享的全零頁框,直到你寫入),而它會在你下一篇遇到 mmap() 時再次現身——在那裡,一個檔案或區域可以用私有的寫入時複製語意被對映。它也活在核心之外:許多語言執行環境與資料結構用寫入時複製來便宜地共享看似不可變的物件、只在被改動時才把它們拆開。一旦你看見這個模式——唯讀時共享、攔截第一次寫入、只複製那一塊——你就會到處都認出它。

這把你帶到了哪裡

你現在懂了那個讓一個行程能複製自己、卻無須付出複製記憶體代價的把戲。fork() 給子行程一份「邏輯上立即、實體上只在需要時才存在」的副本:核心共享父行程的頁框、把每一筆共享的分頁表項標成唯讀,並讓第一次寫入陷入一次分頁錯誤,那次錯誤恰好把一頁複製進一個私有頁框、再重新執行那次寫入。成本隨「改動了什麼」擴大,而非隨「存在著什麼」——這正是為什麼 fork() 保持便宜,也是為什麼先 fork 再 exec即使從一個巨大的父行程,也能一閃之間啟動一支全新的程式。

留意貫穿整個這一階的模式:幾乎每一個功能,都是一組分頁表項位元再加上一個錯誤處理器。需求分頁是一個「不在場」位元;記憶體保護是可寫與不可執行位元;寫入時複製是一個「在場但唯讀」位元。MMU 強制執行這些位元,錯誤把控制權交給作業系統,而作業系統決定那次錯誤「意味著什麼」。最後一篇把這同一套機制朝外轉,讓你的程式能用 mmap() 刻意請求對映——包括你剛學會認出的那種寫入時複製。