世界を動かす技術を、日本語で。

fork()とexec()を超えて

8時間前原文(lwn.net)

概要

  • Linuxカーネルにおける プロセス生成の現状と課題 を解説
  • Li Chen による「spawn templates」提案の内容とその評価
  • fork()/exec() パターンの非効率性と、その最適化アプローチ
  • posix_spawn() 導入の可能性と今後の方向性
  • 今後の Linuxプロセス生成API の進化の展望

Linuxのプロセス生成とspawn templates提案

  • Unixの伝統的なプロセス生成は fork() で親プロセスをコピーし、 exec() で新プログラムに切り替える方式

  • Linuxカーネルでは clone()execve() が該当システムコール

  • fork() はプロセス状態(メモリなど)をコピーするため、 高コストな操作

  • 多くの場合、 fork()の直後にexec() が呼ばれ、コピーしたメモリがすぐ破棄される非効率なパターン

  • vfork() などの最適化も限定的な効果

  • Li Chen の「spawn templates」パッチセットの概要

    • 同じ実行ファイルを何度も起動するケースでの最適化を狙い

    • 例:Gitコマンドを繰り返し呼び出すプログラム

    • spawn_template_create() システムコールでテンプレートを作成

      • 実行ファイルのパスやファイルディスクリプタを指定
      • カーネルが実行ファイルの情報をキャッシュ
      • テンプレートはファイルディスクリプタで管理
    • プロセス起動時の個別設定は spawn_template_spawn_args 構造体で指定

      • 引数リスト(argv)、環境変数(envp)、ファイルディスクリプタやシグナルの操作(actions)など
      • actionsは spawn_template_action 構造体で表現
        • 例:特定FDのクローズや複製、作業ディレクトリ変更など
    • spawn_template_spawn() システムコールで新プロセスを起動

      • 内部的にはfork()/exec()に近いが、テンプレートのキャッシュで高速化
      • ベンチマークでは 約2%の速度向上 を確認
      • 大規模なプロセス起動が頻繁なアプリケーションで効果を発揮

posix_spawn()と今後のプロセス生成API

  • Mateusz Guzik による詳細なレビュー

    • 問題の本質は fork()のコスト にあり、fork()自体を排除すべきと指摘
    • 「現在のプロセスをコピーするより、 クリーンな新プロセスの生成 が最適」と主張
  • Christian Brauner の提案

    • exec用のビルダーAPI のアイデアを肯定
    • 既存の pidfd 抽象化を活用すべきと提案
      • pidfd_open() で空のプロセスを生成するオプション
      • 新システムコール pidfd_config() で環境や実行イメージなどを設定
      • fsconfig() に類似した設定方式
    • 目標は ユーザ空間でのposix_spawn()実装 を可能にすること
      • fork()/exec()の隠蔽ではなく、ネイティブなAPIとして提供
  • Li Chen もBraunerの方向性に同意し、今後はそちらの設計で進める意向

  • 現時点では「spawn templates」はLinuxカーネルに採用されない見込み

  • 将来的に 本格的なposix_spawn()実装 がLinuxに導入される可能性

まとめ

  • 現行の fork()/exec()モデル は高コストで非効率な場合が多い
  • 「spawn templates」は部分的な最適化だが、根本的な解決には新APIの導入が必要
  • pidfd ベースの新しいプロセス生成APIと posix_spawn() 実装への期待
  • Linuxのプロセス生成は今後も進化が続く見通し

Hackerたちの意見

チェンのパッチが却下されたのは驚かないよ。あれはすごくニッチなユースケースで、サポートする価値がないからね。シェル開発者の視点から言うと、「開発者は、(現在の実装とは違って)fork()とexec()を隠さないネイティブ実装を歓迎するだろう」という締めの意見には同意だね。

彼らはそのコンセプトには興味があるみたいだけど、特定の実装には興味がないって感じだね。

ここでこの古いAPIについての議論がたくさんあるよ、たとえばここね。 https://news.ycombinator.com/item?id=31739794

最近、フォークしたプロセスでファイルディスクリプタをもっと閉じる必要があって、ちょっとしたバグに遭遇したんだ。「現在のプロセスのクローンが欲しい」ってのは、経験上「完全に新しいプロセスが欲しい」よりもずっと一般的じゃない気がする。後者を直接表現する方法がないのはおかしいし、クローンしてから後で修正するしかないのは変だよね。

「完全に新しいプロセス」ってどういう意味?

でも、一般的にはそのプロセスと通信したいから、ファイルディスクリプタとかの設定が必要だよね。親プロセスから情報を渡す必要があるし。

それはO_CLOEXECでカバーされてるんじゃない?

後者のことを直接表現する方法がないなんて、ちょっとおかしいよね。posix_spawnってそれのためにあるんじゃないの?

fork() + exec()モデルのエレガンスは、フォーク後にすべての通常のAPIを使ってあらゆる種類の設定ができるところだよ。今まで見た中で、これを組み合わせた呼び出しに置き換えようとした試みは、すべて根本的に劣っているように見えた。なぜなら、すべての設定オプションを呼び出しのパラメータとして追加しなきゃいけなくて、後で拡張できるようにしつつ、混乱しないようにする必要があるから。

僕は全く逆の意見だね。UNIX的なモデルの大きな間違いは、プロセス作成時にあまりにも多くの状態が保持されることだと思う。たとえば、特定のものをfd番号4にするAPIがあって、それを使ってプログラムを実行し、そのものをfd4で見つけることができる。これって変だよね。Windowsは、たくさんの欠点があるけど、fork+execを使わずにプロセスを作成する方法の選択肢がほとんどだった。優雅ではなかったけど、それが正しい決断だったと思う。

そうだね。fork()を排除する正しい方法は、プロセス状態を変更する通常のAPIが明示的なプロセスハンドルを取るようにすることだよ。そうすれば、同じAPIを使って空のプロセスをセットアップできるし、IPCやデバッグのために他の方法でも組み合わせられる。

これは主に、ほとんどのシステムコールがターゲットPIDを受け入れないという設計ミスを誤魔化してるだけだね。そうじゃなければ、単にサスペンドされたプロセスを作って、ターゲットPIDを明示的に受け取るシステムコールで設定してから開始すればいいんだ。

同意するよ。今のやり方は(Cで)すごく使いやすいと思う。最良の方法は、vfork()に似たものを持ちつつ、POSIXのルールに縛られないことだと思う。それから、通常のPOSIX API(close、setuidなど)をRustの「ビルダー」パターンのように振る舞わせる。明示性のためにプレフィックスを付けるのもいいかも。そうすれば、「巨大な構造体を埋める」人たちの希望も叶えられるし、ただ速いPOSIX体験を求めている人たちは全く新しい概念やAPIを学ぶ必要がなくなる。将来的にも拡張可能になるしね(ビルダーにプレフィックス付きの呼び出しを追加するだけで済む)。

fork(2)のエレガンスがどうであれ、clone(2)はもっとあるよ。

記事で説明されている新しいシステムコールには、ファイルディスクリプタを閉じたり複製したりするための拡張可能な宣言型コマンドインターフェースが組み込まれているんだ。反対はしないけど、確かに目を引いたね。

それをエレガントだと言うのは、fork+execの歴史に依存してるよね。もしfork+execが存在しなかった別の世界があったら、たぶんその「通常のAPI」の多くは、プロセスの設定を別のプロセスから変更できる明示的なpid引数を持ってたと思う。(これがFuschiaの動き方だよ、例えば)。この世界には多くの利点があるよね。最も明白なのは、設定エラーを報告するためだけにIPCシステムを魔法のように作る必要がないことだけど、子プロセスの属性を調整するマネージャプロセスを持てることにも実際にかなりの利便性がある(例えば、デバッガーはこれを好むだろうね)。

スポーンして、設定して、execするべきだね。設定は、プロセスがptraceでアタッチされてスレッドがない状態で始まればできるから、システムコールを強制的に実行させることができる。Linuxには「スレッドがないプロセス」という概念すらないから、たぶんダミースレッドを持たせる必要があるだろうね。

これの裏側は、新しいプロセスを正しく開始するためには、ライブラリで行われたすべてのことを含むプロセスの全状態を把握しておく必要があるってこと。さあ、君のプログラムで一番高い番号のオープンファイルディスクリプタは何?複数のスレッドが動いてると、さらに悪化するよ。調べずに、フォークされたプロセス内のさまざまな同期プリミティブの状態はどうなってる?

fork()は比較的高価なシステムコールで、子プロセスのためにプロセス全体の状態(メモリを含む)をコピーしなきゃいけない。何年にもわたって多くの最適化が行われてきたけど、フォークは依然として根本的にコストのかかる操作なんだ。さらに悪いことに、fork()の呼び出しの後にはexec()がすぐに続くことが多く、そのために子プロセスのために注意深くコピーされたメモリがすべて破棄されてしまう。コピーオンライトについての言及がないのは変だね。これは、すべてのメモリをコピーしないようにする最適化なんだから。

状態って書いてあるね。コピーオンライトでも、内容をコピーしなくてもO(ページテーブルエントリの数)になるんだ。大きな仮想メモリサイズのプログラムをフォークするのが遅いのはよく知られた問題だよ。

この記事では暗黙のうちに触れられているけど、ここで言うプロセス状態のコピーっていうのは、メモリ管理構造のことだね。主にページテーブルとVMA(仮想メモリアドレス空間)だよ。実際にページが指しているメモリが共有されていても、これらの構造のコピーを保持するために新しいページを割り当てる必要があるんだ。そして、これらの構造をコピーするために歩き回るのはまだコストがかかる。

コピーオンライトでも、fork()はCOWのセットアップコストを支払わなきゃいけないんだよね。親プロセスに忙しいスレッドがたくさんあると(例えばJava)、exec()が発火する前に不必要なCOWをたくさんやる羽目になることがある。

コピーオンライトについて言及しないのは変だね こういう論文の対象読者にとっては、これは基本的な知識だよ。

Redisはこれが非常に重要なプロセスで、fork()がメモリをコピーしないとはいえ、ページテーブルをコピーする必要がある。数十GBのRAMを持つプロセスにとって、fork()は時間がかかるし、Redisが.rdbファイルをダンプしたり、バイナリログ(「AOF」)を書き換えるたびにこれが発生する。2012年のこのブログ投稿でも、この操作の高コストが示されてたよ:https://redis.io/blog/testing-fork-time-on-awsxen-infrastruc... m2.xlargeで約25GBのRAMを使用して、fork()は5.67秒かかった。Redisクライアントがほとんどの操作で数ミリ秒のレイテンシを経験する中で、これは長い待機時間だよね。そう、これはページテーブルをコピーするのに必要な時間だけど、巨大ページについて言及しないのは驚きだね。ここでは重要な考慮事項のように思える。14年後にはハードウェアも速くなってるだろうけど、RedisインスタンスはおそらくもっとRAMを使ってるだろうし。このベンチマークが再検討されるのは面白いだろうね。

関連する議論: 「A fork() in the road」: https://www.microsoft.com/en-us/research/wp-content/uploads/... > 概要 > 受け入れられている考え方では、Unixのプロセス生成におけるfork()とexec()の独特な組み合わせは、インスパイアされたデザインだと言われています。でもこの論文では、forkは1970年代の機械やプログラムにとっては巧妙なハックだったけど、今ではその有用性を超えてしまって、むしろ足かせになっていると主張しています。forkが現代のプログラマーにとってどれだけひどい抽象化であるかを列挙し、OSの実装にどのように妨げになるかを説明し、代替案を提案しています。 > オペレーティングシステムの設計者や実装者として、forkが第一級のOSプリミティブとして存在し続けることがシステム研究を妨げていることを認め、非推奨にすべきです。教育者としては、forkを歴史的な遺物として教え、学生が最初に出会うプロセス生成メカニズムとしては扱わないべきです。

forkはzygoteパターンにとって素晴らしいよね。同じくらい効率的でエレガントな最適化を考えるのは難しい。

面白いのは、forkを使わない最も広く使われている「大きな」OS、つまりWindowsが、プロセス生成がすごく遅いことだよね。非フォークのプリミティブが必要だと思うけど、パフォーマンスが最良の議論かどうかはちょっと疑問だな。

当時の議論: https://news.ycombinator.com/item?id=19621799 - fork()の分岐点 (2019-04-10, 178件のコメント)

この論文は素晴らしいし、参考文献の一つ[29]もすごく好きだ。スケーラブルなインターフェースの微妙な部分、特にforkについて詳しく書いてあるからね。個人的には宝物だと思うよ。「スケーラブル・コミュタティビティ・ルール:マルチコアプロセッサ用のスケーラブルソフトウェア設計」 https://people.csail.mit.edu/nickolai/papers/clements-sc.pdf

受け入れられている知恵によれば、Unixのプロセス生成におけるfork()とexec()の異常な組み合わせは、インスパイアされたデザインだと言われている。いや、それは親プログラムと一緒にメモリに収まらないほど大きなプログラムを起動できるようにするためにそうなったんだ。元の実装は、fork()呼び出し時にフォークするプログラムをディスクにスワップアウトすることで動作していた。そして、プログラムがスワップアウトされた瞬間に制御が戻っていないとき、プロセステーブルのエントリが複製されて調整され、メモリ内に1つとスワップアウトされた1つの2つのプロセスができた。メモリ内のプロセスが制御を得て、exec()呼び出しを行うことができた。これにより、大きなプログラムが小さなPDP-11マシンで動作できるようになったんだ。本当に高価なメモリの時代には必要だった。それが理由だよ。QNXは面白いアプローチを持っていた。プログラムの読み込みはOSには全くない。forkはあるけど、プログラムの読み込みはライブラリにあるんだ。実行可能ヘッダーを読み込んで、メモリを割り当てて、プログラムをロードして、実行の準備をして、開始する.soファイルにリンクしている。プログラムローダーはユーザースペースで動作し、特権がない。これが多分正しいやり方だね。

fork()が安いっていうのは、妙に一般的な誤解なんだよね… プロセスのサイズに対してO(N)だし、ずっとそうだった。確かにコピーオンライトだけど、プロセスのサイズとそれを表現するために必要なページテーブルエントリの数には線形の関係があるんだ。

forkは、最初に学んだときから概念的にひどいと思ってた。もし一つのこと(プロセスを開始する)をしたいなら、無関係なこと(プロセスをforkする)をするための謎の呪文を使う必要はないはずだよね。記事にある、一つのプロセスが多くのgitサブプロセスを生成する例をどう扱うのがベストなのか気になる。長時間実行される親操作の中で、何度も最初からgitを起動するのは明らかに意味がないよね。でも、同じ結果を得るための低コストな抽象化って何だろう?

libgit2があるよ。パイプやソケットを通じてgitdとやり取りすることも考えられるけど、それがいいアイデアかは分からないな。それ以外だと、プロセスを生成するしかないね。

うん、もともとWindowsから来た人間としては、fork+execのモデルは全然理解できなかった。今はただの歴史的な奇妙さだってわかるけど、なぜかfork+execが実際に良いものだと振る舞う人たちがまだいるんだよね…

美的には、これ以上進むつもりはないよ。カーネルのスケジューラーと「ヘビーウェイト」プロセスをコアにマッピングする方法に満足してる。スレッドコードは使ってるけど、書くのも理解するのもかなり難しい。CSのキャリアも45年目だし、年を取ってきたなぁ。賢い人たちよりも上手くやるには、賢くないといけないんだ。賢い人たちがfork()/exec()で俺を引き上げてくれたし、自分の限界は分かってる。

コアが9ビット以上必要になって、RAMがテラバイト単位になると、古い前提の多くを変える必要がある。スケジューラーはユーザースペースで実装する必要があるし、RAMは4kではなくGB単位で割り当てる必要がある。I/Oはカーネルとユーザースペース間の往復を減らす必要があるし、NICはデータがCPUに届く前にもっと多くの作業をしなければならない。

EmacsといろんなCLIツールを使ってるけど、スレッドは便利だけど、プログラムの複雑さを必要以上に上げちゃうことがあるよね。await/asyncの構文的な甘さに対処するより、スレッドプールとタスクキューを設定するボイラープレートの方がずっと好きだな。

execやforkを置き換える問題は、新しいプロセスを設定したいことが多いってことだよね。例えば、シグナルハンドラの設定、FDのクローズやオープン、名前空間の切り替え、seccompの設定、権限の調整とか。これらのシステムコールは現在のプロセスにしか適用されないから、何かそれを置き換えるものが必要なんだ。記事の提案は、これのための新しいAPIを作ることだった。俺のアイデアは、「spawn」みたいな新しいシステムコールを作って、新しい空のプロセスを作成し、軽量な「ローダー」を読み込んで、任意の設定データを渡すってこと。ローダーがプロセスを設定して、メインプログラムをexec()する。これでメモリをフォークするのを避けつつ、既存のAPIを維持できるけど、ファイルディスクリプタや他のものをフォークする必要はあるね。

幸運なことに、タイムマシンを持ってる誰かが君の投稿を見て、POSIX.1-2001に追加したんだね :) (冗談じゃなかったらごめん)でも、そう、posix_spawn()は実際にあって、glibcではforkはclone()のエイリアスに過ぎない。OPのアイデアとはちょっと違うけど、fork/execは本当にレガシーだよ。