fork は本当にメモリをコピーするのか — copy-on-write の実際の動き

読了 5分

前回、プロセスの生成は高いと言いました。ところが妙です。Unix で新しいプロセスを作る方法は fork()、つまりいまのプロセスを丸ごと複製することだけなのに、数 GB のメモリを使うプロセスでも fork は一瞬で終わります。ギガバイトをコピーするのにミリ秒? 答えは「コピーすると言っておいて、実際にはコピーしない」からです。この装置が copy-on-write(COW、書くときコピー)で、Linux のプロセスモデルの中核のトリックです。

fork の意味 — 複製として始まるプロセス #

fork() を呼ぶと、呼んだプロセス(親)とほぼ同じ子プロセスが生まれます。同じコード、同じ変数の値、同じ開いたファイルを持ちます。戻り値だけが違うので(親は子の PID、子は 0)、両者は自分がどちらか分かります。新しいプログラムを実行したければ、子が続けて exec() で自分の中身を新しいプログラムに入れ替えます。シェルがコマンドを実行する方式が、まさにこの fork + exec の組み合わせです。

ここで疑問が生まれます。どうせ exec で中身を入れ替えるなら、親のメモリをコピーするのは純粋な無駄ではないでしょうか? そのとおりです。だから実際にはコピーしません。

copy-on-write — 書く側がコピーの費用を払います #

プロセスのメモリはページ(通常 4KB)単位で管理され、各プロセスは「仮想アドレス → 物理メモリ」の対応表であるページテーブルを持ちます。fork が実際にコピーするのはメモリではなくこのページテーブルです。

  1. fork の直後、親と子のページテーブルは同じ物理ページ群を指しています。 ギガバイトのプロセスでもコピーされたのは対応表だけなので、fork が速いのです。
  2. 代わりにカーネルは、共有されたページを両側で書き込み禁止として印を付けておきます。
  3. どちらかがそのページに書き込みを試みた瞬間、CPU がページフォルトを起こし、カーネルがその時点でそのページ一枚だけをコピーして書く側に付けてやります。以後、両者はそのページに限って別々になります。

つまりコピーは「fork の時点で全部」ではなく「実際に書き替えるページだけ、書き替える瞬間に」起きます。fork + exec が安い理由も自明になります。子が何も書き替えずにすぐ exec すれば、コピーは結局ほとんど起きません。

実戦で COW が顔を出す場所 #

COW はカーネル内部の知識で終わらず、サーバー運用のあちこちで顔を出します。

  • Redis のスナップショット(RDB 保存) — Redis は保存の時点で fork し、子がその瞬間のメモリの状態をゆっくりディスクに書きます。COW のおかげで fork は即座に終わり、親はリクエストを受け続けます。代わりに保存中に親がデータを多く書き替えると、書き替えたページごとにコピーが起きてメモリ使用量がその分だけ膨らみます。「保存中にメモリが 2 倍近く跳ねることがある」という Redis の運用の常識が、まさに COW の請求書です。
  • プリフォーク(pre-fork)サーバー — gunicorn、uWSGI などはアプリケーションを全部ロードしたマスターがワーカーを fork します。各ワーカーがコード・ライブラリのページを物理的に共有するので、ワーカー 10 個の実際のメモリは 10 倍よりはるかに小さいのです。逆にワーカーが各自データを書き替え始めると共有が壊れて使用量が膨らみます。Python のようにオブジェクトごとに参照カウントを書き替えるランタイムは、読むだけでもページが汚れて共有の利得が減ることで有名です。
  • コンテナイメージのレイヤー — ファイルシステムの次元ですが同じ思想です。イメージのレイヤーを共有し、書き替えるファイルだけ上のレイヤーへコピー(copy-up)するオーバーレイファイルシステムも、「書くときコピー」という同じ原理で回ります。

メモリの計算が紛らわしくなる理由 — そしてオーバーコミット #

COW の副作用は、「このプロセスはメモリをどれだけ使っているのか?」という質問を難しくすることです。ps の RSS をワーカー 10 個について足すと、共有ページが 10 回重複して数えられます。共有を考慮した指標(PSS、smem ツールや /proc/<pid>/smaps_rollup)を見てはじめて実際に近づきます。メモリの記事で見た「合計が合わない」状況のよくある原因の一つが、この重複計算です。

もう一つ。fork は「約束だけして、コピーは後で」という構造なので、カーネルは物理メモリより多い約束をしてやれます(オーバーコミット)。8GB を使うプロセスが fork する瞬間、理論上は 16GB の約束が生まれますが、実際の使用は 8GB+α です。オーバーコミットを禁止(vm.overcommit_memory=2)したシステムで大型プロセスの fork がメモリ不足で失敗する事故、Redis の文書がオーバーコミットの許可を推奨する理由が、みなここから出てきます。

まとめ #

  • fork はプロセスを丸ごと複製しますが、実際にコピーされるのはページテーブルだけです。物理ページは書き込み禁止で共有されます。
  • コピーは書き替えるページだけ、書き替える瞬間に起きます(copy-on-write)。だから fork + exec はほぼただです。
  • Redis のスナップショットのメモリ増加、プリフォークサーバーのメモリ節約(とその崩壊)は、すべて COW の直接の結果です。
  • 複数ワーカーの RSS の合算は共有ページを重複計算します。実際に近い値は PSS で見ます。
  • fork は約束(仮想)と使用(物理)を分離します。オーバーコミットの設定と大型プロセスの fork の失敗は、この分離から出てくる運用の論点です。

次回は Linux の指標の中で最も誤読される数字、ロードアベレージです。

X