Dev Study
OSと低レイヤのしくみ コース

9. fork()が速い理由 — コピーオンライト

fork()がメモリを複製せずに済ませ、書き込みの瞬間まで複製を先送りする仕組みを図で追っていきましょう。

① 素朴な発想: forkは全メモリを複製する?

もし本当に全部コピーしていたら
親プロセス物理メモリ 1GB を使用中
fork() を呼ぶ
1GB を丸ごと複製物理ページを1枚ずつコピー
数百ミリ秒〜秒
親 1GB元の物理ページ
子 1GB新しい物理ページ
直後によくある展開
子はすぐ execve()コピーした1GBは即座に破棄
  • ・fork(2) は「親プロセスと子プロセスは別々のメモリ空間で動作する。fork() の時点では両方のメモリ空間は同じ内容を持つ」と定めています
  • ・仕様が要求するのは「内容が同じに見えること」だけで、「物理的に複製すること」ではありません
  • ・この差がコピーオンライトの出発点です

仕様どおりに素直に複製すると、使われないかもしれない1GBのコピー代を毎回前払いすることになります。

② 実際のfork: 複製するのはページテーブルだけ

fork(2) が明記するコスト
fork() のコストman page の記述
実際に複製されるもの
ページテーブル仮想→物理の対応表
task構造体子プロセス1つ分
複製されないもの
物理ページの中身1バイトもコピーしない
親のPTE群物理ページを指す
同じ物理ページ共有される
子のPTE群同じ物理ページを指す
  • ・fork(2): 「Linux では fork() はコピーオンライトページを用いて実装されている。そのため fork() が伴う唯一のペナルティは、親のページテーブルを複製し、子に固有の task 構造体を作成するのに必要な時間とメモリだけである」
  • ・PTE (Page Table Entry) は仮想ページ1枚と物理ページ1枚の対応を持つ表の1エントリです
  • ・カーネル側では dup_mmap() が親のVMAを1つずつ辿り、各VMAについて copy_page_range() がページテーブルを複製します

複製されるのは「地図」であるページテーブルだけで、「土地」である物理ページは親子で共有されます。1GBのプロセスでも即座に1GBがコピーされるわけではありません。

③ 仕掛け: 親子ともPTEを書き込み不可に落とす

共有した瞬間に両者を書き込み禁止にする
fork前の親PTEpresent=1 / write=1
自由に書ける
copy_page_range が両側に write保護を適用
親PTEpresent=1 / write=0
子PTEpresent=1 / write=0
両方が指す先
同一の物理ページ参照している側が2つに増える
読み出しどちらも自由に可能
書き込みどちらもフォルトする
  • ・書き込み保護は ptep_set_wrprotect() / pte_wrprotect() でPTEの書き込み許可ビットを落として行われます
  • ・親も子も対称に落とすのが要点です。どちらが先に書いても必ず検出できます
  • ・VMA(仮想メモリ領域)側は書き込み可のままなので、カーネルは「VMAは書き込み可なのにPTEは書き込み不可」という食い違いからCOW対象だと判別できます
  • ・PTEを書き込み可から不可へ変えたら、TLBに残る古いエントリを無効化する必要があります。さもないとCPUがキャッシュ済みの書き込み可エントリを使い、フォルトを取りこぼします

共有と同時に両者の書き込み権を取り上げます。これが「書き込みを検知するための罠」であり、コピーオンライトの心臓部です。

④ 誰かが書いた瞬間: 書き込み保護違反フォルト

子が共有ページに書き込む
子が変数に代入共有ページへのストア命令
PTEの write=0 に阻まれる
ページフォルト発生書き込み保護違反
ページは存在するのに書けない
CPUがカーネルへ制御を移す
ページフォルトハンドラフォルト種別を判定
present=1 かつ 書き込み要求
do_wp_page()write protect フォルトの処理
  • ・これは「ページが無い」フォルトではありません。物理ページは存在しマップもされている、権限だけが足りない状態です
  • ・カーネルはこの2種を区別して扱います。userfaultfd(2) でも、書き込み保護によるフォルトは UFFD_PAGEFAULT_FLAG_WP で通知され、ページが未マップの場合はそのフラグが立たない形で区別されます
  • ・フォルトを起こした命令は完了しておらず、処理後に同じ命令が再実行されます

書き込みは失敗ではなく、カーネルへの合図になります。「今このページを本当に分ける必要が出た」という通知です。

⑤ ここで初めて複製する: wp_page_copy()

書いた側にだけ新しい物理ページを与える
do_wp_page()このページは他と共有中と判断
wp_page_copy()
新しい物理ページを確保1ページ(通常4KB)だけ
旧ページの内容をコピー1ページ分のみ
書いた側のPTEだけ張り替える
子PTE新ページ / write=1
親PTE旧ページ / write=0 のまま
フォルトした命令を再実行
子の書き込みが成功親からは見えない
  • ・複製の単位はプロセス全体ではなく、フォルトしたページ1枚だけです
  • ・旧ページを参照している側が1つ減るので、参照カウントも合わせて減らされます
  • ・fork(2): 「一方のプロセスが行ったメモリ書き込み、ファイルマッピング (mmap(2))、アンマッピング (munmap(2)) は他方のプロセスに影響しない」。この独立性がページ単位の複製で実現されています

コピー代は「触ったページの分だけ」「触った瞬間に」支払われます。1GBのうち1ページしか書かなければ、複製されるのも1ページです。

⑥ 共有相手が消えたら複製しない: wp_page_reuse()

最後の1人になったページは作り直さない
子が全ページをCOW済みあるいは子が exit した
旧ページの参照者が親だけになる
物理ページ参照しているのは1プロセスのみ
ここで親が書き込む → フォルト
do_wp_page()排他的に所有しているか判定
共有相手が居ない
wp_page_reuse()複製せずPTEを write=1 へ戻す
  • ・自分しか使っていないページを複製しても、中身が同じページが2枚できるだけで意味がありません
  • ・カーネルはページが排他的に所有されているかを確認し、そうであれば書き込み許可を戻すだけで済ませます
  • ・この判定があるため、1回のforkで生じるコピーは、実際に書き込まれた共有ページの分だけに収まります

複製が必要なのは「他に見ている人がいる」あいだだけです。共有が解消されればコストはゼロに戻ります。

⑦ なぜこの設計なのか

fork の直後に何が起きるかを見た設計
fork の典型的な用途実際の使われ方
fork してすぐ execve親のメモリは全部捨てる
fork して子は読むだけ設定やヒープを参照
どちらも「親のメモリの大半に書かない」
先にコピーすると無駄捨てるためにコピーすることになる
だから遅延させる
コピーオンライト書いたページの分だけ後払い
  • ・コストは「fork時に全ページ分を前払い」から「書いたページ分だけ後払い」へ移ります
  • ・書き込みが多いプロセスでは総コピー量は変わりませんが、支払いが分散し fork 自体は速く返ります
  • ・同じ発想は mmap(2) の MAP_PRIVATE にもあります。man page はこれを「プライベートなコピーオンライトマッピングを作成する」と定義しています
  • ・fork時に固定で要る費用はページテーブルとtask構造体の複製です。ページテーブルは実際にマップされている領域の分だけ作られるため、マッピングが多いプロセスほどこの費用は大きくなります

コピーオンライトは「コピーを速くする」技術ではなく、「本当に必要になるまでコピーしない」ための仕組みです。多くのforkでは、その必要が最後まで訪れません。

公式ドキュメントで詳しく ↗