fork() + exec()を超えて
(lwn.net)- spawn templates は、同じ実行ファイルを繰り返し実行するアプリケーション向けに、カーネルが実行ファイル情報をキャッシュして後続のプロセス起動を高速化しようとする Linux カーネル向けのプロセス生成提案
- fork() は子プロセスのためにメモリを含むプロセス全体の状態をコピーする必要があり、その直後に続く exec() がそのメモリを破棄することが多いため、既存パターンには非効率がある
- spawn_template_create() は execfd または絶対パス
filenameのいずれかで実行ファイルを指定し、テンプレートファイルディスクリプタを返す。カーネルはそのファイルを開き、高速実行に必要な情報をキャッシュする - spawn_template_spawn() は通常の fork()/exec() 経路に近い形で動作し、新しいファイルの実行時に適用される検査は維持する。カバーレターのベンチマークでは約 2% の改善を記録 {p:2}
- pidfd ベースの空のプロセス生成と
pidfd_config()による構成のほうが、より良いアプローチと評価されており、目標はユーザー空間でのposix_spawn()実装の支援
Unix プロセス生成モデルの限界
- Unix の初期から、fork() は親のコピーとして子プロセスを作り、exec() は現在のプロセスの場所で新しいプログラムを実行する中核的なプロセス指向システムコールだった
- Linux カーネルでは、同じ中核機能は clone() と execve() としてより広く知られている
- このプロセス生成モデルには優雅さと欠点の両方があり、Li Chen による spawn templates の提案 は現在の形では Linux カーネルに受け入れられない見込みだが、将来の新しいプロセス生成プリミティブにつながる可能性がある
- fork() は、子プロセスを作るためにメモリを含むプロセス全体の状態をコピーしなければならない、比較的高コストなシステムコールである
- 長年にわたりさまざまな最適化が施されてきたが、fork() は本質的にコストの高い処理である
- fork() の呼び出しの直後に exec() が続くことは多く、exec() は子のためにコピーされたメモリをすべて破棄する
- vfork() のような最適化の試みはあったが、fork() の後に exec() が続くパターンは、依然として可能な水準より高コストな構造である
Spawn templates
- Li Chen のパッチセットは、fork() と exec() のパターンを最適化するため、同じ実行ファイルを繰り返し実行するアプリケーションに焦点を当てている
- 例として、リポジトリのコンテンツ情報を取得するために Git を繰り返し実行しなければならないプログラムが挙げられる
- このような場合、プログラムは複数回の実行に設定コストを分散するためにテンプレートを作成し、そのテンプレートで呼び出しを高速化する
- テンプレートの作成には
spawn_template_create()システムコールを使う- シグネチャは
int spawn_template_create(struct spawn_template_create_args *args, size_t args_size);
- シグネチャは
- この呼び出しは、実行ファイルテンプレートを表すファイルディスクリプタを返す
- 実行ファイルは、ファイルディスクリプタ
execfdまたは絶対パスfilenameのどちらか一方で指定しなければならず、両方を同時に使うことはできない - カーネルは指定されたファイルを開き、その後そのファイルをより高速に実行するために必要な各種情報をキャッシュする
- 各実行では、異なる引数、環境、ファイルディスクリプタ変更、シグナル処理変更を持つことができる
- 具体的な実行情報は
spawn_template_spawn_args構造体に配置するargvはプログラムに渡す引数リストを指すポインタenvpはプログラム環境を指すポインタactionsはファイルディスクリプタとシグナル処理の変更を渡すspawn_template_action配列へのポインタ
spawn_template_actionはtype、flags、fd、newfd、argの各フィールドで構成される- 子プロセスでファイルディスクリプタ 4 を閉じる必要がある場合、
typeはSPAWN_TEMPLATE_ACTION_CLOSE、fdは 4 に設定する - そのほかのアクションとして、ファイルディスクリプタの複製、ファイルのオープン、作業ディレクトリの変更、シグナル処理の変更をサポートする
- 子プロセスでファイルディスクリプタ 4 を閉じる必要がある場合、
- 実行情報を埋めた後、
spawn_template_spawn()で新しいプロセスを実行する- シグネチャは
int spawn_template_spawn(int template_fd, struct spawn_template_spawn_args *args, int args_size);
- シグネチャは
- 内部動作は通常の fork()/exec() 経路に近い方式である
- 新しいファイルの実行時に適用される通常の検査はすべてそのまま維持される
- テンプレートにキャッシュされた情報によって、生成フロー全体の速度が向上する
- カバーレターのベンチマーク結果では約 2% の改善が示されており、想定されたパターンに合うアプリケーションでは意味のある差になり得る {p:2}
posix_spawn() に向けて
- Mateusz Guzik は「fork + exec というイディオム全体はひどく、退場すべきだ」と評した
- パッチセットの奇妙な点は fork() 部分をそのまま残していることであり、コストの大部分はそこにあるという判断である
- 最適化は現在のプロセスのコピーをなくし、「クリーンな (pristine) プロセス」を作る方式であるべきだ
- Christian Brauner は、exec のための builder API という構想は「それほど奇妙ではない」という立場を示した
- ただし新 API は、既存の pidfd 抽象化の上に構築するアプローチを好んでいる
- 具体的な詳細はまだないが、pidfd_open() に空のプロセスを作るオプションを追加する方式が正しいアプローチとされる
- その後、新しい
pidfd_config()システムコールを複数回呼び出して、環境や実行するイメージなど望む設定を新しいプロセスに適用する pidfd_config()は fsconfig() と似た役割を果たす- 新しいインターフェースの重要な目標は、ユーザー空間での posix_spawn() 実装を支援することである
- posix_spawn() は fork()/exec() パターンの代替として適している
- 現在の実装は内部で fork() と exec() を隠しているが、ネイティブ実装はその構造とは異なるものになる
- Li Chen は、Brauner が大きく描いた API のほうがより良さそうだという点に同意し、今後の作業をその方向で進める予定である
- spawn templates は Linux カーネルには入らないが、今後の作業が実を結べば、Linux は適切な posix_spawn() 実装を持てるようになるかもしれない
1件のコメント
Hacker Newsの意見
関連する議論として A fork() in the road 論文がある: https://www.microsoft.com/en-us/research/wp-content/uploads/...
要旨では、Unix の
fork()+exec()の組み合わせはひらめきに満ちた設計だという通念とは異なり、1970年代のマシンやプログラムにとっては巧妙なハックだったが、今では現代のプログラマにとって悪い抽象化であり、OS 実装も制約すると主張しているOS の第一級のプリミティブとして残すよりも歴史的遺物として教えるべきで、学生が最初に学ぶプロセス生成方式にすべきではないという立場
fork()+exec()がそのようになった理由は、親プログラムと一緒にメモリに載せられないほど大きいプログラムを実行できるようにするためだった元の実装では
fork()呼び出し時に fork するプログラムをディスクにスワップアウトし、制御が戻る前にプロセステーブルの項目を複製・調整して、メモリ上のプロセスとスワップアウトされたプロセスを作っていた。そしてメモリ上の側が制御を受けてexec()を呼び出せたこの方式のおかげで小さな PDP-11 マシンでも大きなプログラムを実行でき、メモリが非常に高価だった時代には必要だった
QNX は興味深いことにプログラムローディングが OS 内ではなくライブラリにある。実行ファイルヘッダを読み、メモリを確保し、プログラムをロードして実行準備を整えたうえで起動する
.soにリンクし、プログラムローダーは権限のないユーザー空間で動作する。おそらくこちらの方が正しいやり方に近いfork()を使わない最も広く使われている「大きな」OS である Windows のプロセス生成が非常に遅いという点は興味深いfork()ではないプリミティブが必要だという点には同意するが、性能が最良の論拠なのかはよく分からないfork()を含む スケーラブルなインターフェースの微妙な点を扱っていて特に良かった: The Scalable Commutativity Rule: Designing Scalable Software for Multicore Processors https://people.csail.mit.edu/nickolai/papers/clements-sc.pdffork()は zygote パターンには素晴らしいそれほど効率的でエレガントな最適化はなかなか思いつかない
最近、fork されたプロセスでさらに多くの ファイルディスクリプタを閉じる必要があることから生じた、分かりにくいバグを経験した
私の経験では「現在のプロセスの複製が欲しい」より「完全に新しいプロセスが欲しい」の方がはるかに一般的だが、後者を直接表現する方法がなく、複製してから事後的に修正する形でしか近似できないのは奇妙に感じる
O_CLOEXECで解決するのでは?posix_spawnの用途では?「
fork()は比較的高コストなシステムコールであり、子プロセスのためにメモリを含むプロセス全体の状態をコピーしなければならない。長年にわたり多くの最適化が行われてきたが、本質的にコストの高い作業だ。さらに悪いことに、fork()呼び出しの直後にexec()が続き、子のために丹念にコピーしたメモリをすべて捨ててしまうことが多い」と言いながら コピーオンライト(copy-on-write) に触れていないのは妙だ実際にはメモリ全体をコピーしないようにする最適化なのに抜けている
実際のページが指すメモリは共有されるとしても、これらの構造のコピーを格納するために新しいページを確保する必要がある。そしてそれらの構造をすべて走査してコピーすること自体が依然として高コストだ
fork()はメモリ自体はコピーしないが ページテーブルは依然としてコピーしなければならない数十 GB の RAM を抱えるプロセスなら
fork()に長時間かかることがあり、Redis が.rdbファイルをダンプしたり、バイナリログである AOF を再書き込みしたりするたびに一度発生する2012年にもこの作業の高コストを示した記事があった: https://redis.io/blog/testing-fork-time-on-awsxen-infrastruc...
約 25GB の RAM を使う
m2.xlargeでfork()に 5.67 秒かかった。Redis クライアントが通常ほとんどの処理で一桁ミリ秒のレイテンシしか経験しないことを考えると、長い停止時間だ。これはページテーブルのコピー時間だけの話であるhuge page に触れていないのは意外で、ここでは重要な考慮事項に見える。14年後にはハードウェアは速くなっているだろうが、Redis インスタンスもより多くの RAM を使っている可能性が高いので、このベンチマークをやり直すと面白そうだ
fork()はその設定コストを支払わなければならない。親プロセスに忙しいスレッドが多いと、たとえば Java ではexec()が実行される前に不要なコピーオンライトが大量に発生し得る大きな仮想メモリサイズを持つプログラムの fork が遅いことはよく知られた問題だ
fork()+exec()モデルの優雅さは、fork()の後で通常の API をそのまま使ってあらゆる種類の設定ができる点にあるこれまで見てきた結合呼び出し方式の代替案は、根本的に貧弱に見えた。というのも、すべての設定オプションを呼び出し引数として追加しなければならず、しかも後から拡張可能でありつつ混沌としないように作る必要があるからだ
fork()/exec()が場合によっては有用だとしても、API がpidfd引数を受け取れればかなり良さそうだ。0 は現在のプロセスを意味させればよい問題になるのは
setuid/setgidバイナリ程度だろうが、この場合はexecで特別扱いするほうがよいかもしれないたとえば
pidfd_t ps = spawn();で停止したプロセスを作り、setuid(ps, 33);、capset(ps, ...);、socket(ps, ...);、mmap(ps, ...);、process_vm_writev(ps, ...);、exec(ps, ...);、signal(ps, SIGCONT);のように構成できるこれは、「自分にアクセス権のある別プロセスにこの操作をしたい場合は?」という観点を、通常のシステムコール API が十分に考慮していないという批判でもある。こうすれば
fork()におけるスレッド安全性もある程度は実現できるただし、多数の引数を受け取る
CreateProcessのような方式がユーザー空間 API として優れているわけではない、という点には同意するたとえば、あるオブジェクトがファイルディスクリプタ番号 4 になるようにする API があり、プログラムを実行してそのプログラムに 4 番ディスクリプタからそのオブジェクトを見つけさせることができる。これはおかしい
Windows は数多くの欠点があるにもかかわらず
fork()+exec()を使わず、代わりに主としてプロセス生成方法に関するオプションを提供している。優雅ではなかったが、方向性は正しかったfork()+exec()の歴史における 経路依存性だfork()+exec()が存在しない別世界なら、そうした「通常の API」の多くは、別プロセスの設定を変更できる明示的なpid引数を持っていただろう。Fuchsia はおおむねそういう方式だこの世界には利点が多い。最も明白なのは、設定エラーを報告するために別個の IPC 仕組みを魔法のように作り出す必要がないことだし、子の属性を調整する管理プロセスを置けるのもかなり有用だ。デバッガは特に喜びそうだ
fork()をなくす正しい方法は、プロセス状態を変更する通常の API が明示的な プロセスハンドルを受け取るようにすることだそうすれば、同じ API で空のプロセスを設定でき、IPC やデバッグのような別方式とも組み合わせられる
プロセスが
ptrace接続状態で、しかもスレッドなしで開始されるなら、設定段階でシステムコールを強制実行させられる。Linux には「スレッドのないプロセス」という概念すらないので、おそらくダミースレッドが必要になるだろうfork()が安価だという誤解は異様なほど広まっているが、プロセスサイズに対して O(N) であり、昔からずっとそうだその通り、コピーオンライトではある。しかし、プロセスサイズと、それを表現するのに必要なページテーブルエントリ数との間には線形関係がある
Chen のパッチが却下されたのは驚くことではない。あまりに特殊なユースケースで、サポートする価値が低い
シェル開発者の観点では、「開発者たちは、現在の実装のように内部で
fork()とexec()を隠すのではない ネイティブ実装を歓迎する可能性が高い」という結論に同意するfork()は最初に学んだときから概念的にひどく見えた。何か一つの作業、つまりプロセス開始をしたいのなら、それとは別の無関係な作業である現在のプロセスのフォークという 謎めいた呪文を経由しなければならないのはおかしい記事の例のように、一つのプロセスが多数の
gitサブプロセスを起動する状況をどう処理するのが最善なのか気になる。長時間動作する親処理の途中でgitを毎回ゼロから起動し直すのは筋が悪いように思えるが、同じ結果を生む低コストの抽象化は何だろうか?fork()は概念的には単純だ。ほかの層を持ち込まないなら、確実に存在を知っている唯一のもの、すなわち 自分自身をもとにプロセスを開始することになるそうでなければ、プロセスを作り、実行する何かで埋め、実行されるよう配置するという複数の段階が必要になる。あるいは Win32 のように、ファイルシステム、オブジェクトローダー、リンカといった別の層と恒久的に押しつぶして結合するしかない
fork()+exec()モデルはまったく理解できなかった。今では単なる 歴史的特異点だとわかっているが、それでもfork()+exec()が本当に優れているかのように振る舞う人たちがまだいるlibgit2がある。パイプやソケットで何らかのgitdと通信する方式は想像できるが、それがなぜ良いアイデアなのかはわからない。そうでなければプロセスを起動するしかないexec/forkを置き換えにくい理由は、新しいプロセスには通常、設定が必要だからだ。たとえばシグナルハンドラの設定、ファイルディスクリプタのクローズやオープン、名前空間の切り替え、seccompの設定、権限調整が必要になるしかし、そのためのシステムコールは現在のプロセスにしか適用されないため、代替手段が必要になる。記事の提案は、そのための新しい API を作ることだった
私の考えでは、
spawnのような新しいシステムコールが空のプロセスを作り、その中に軽量な ローダーを載せたうえで、任意の設定データを渡せるようにする。ローダーがプロセスを設定し、主プログラムをexec()する方式だこうすればメモリをフォークせずに既存 API を維持できるが、ファイルディスクリプタやその他のものは依然として複製しなければならない
冗談でなかったなら申し訳ないが、
posix_spawn()はすでに存在しており、glibc ではforkは単なるclone()の別名だ元の提案と完全に同じではないにせよ、
fork()/exec()は本当にレガシーに近いforkとexecがコピーオンライトという性質を超えて、持続的で 代数的な動作を示せるなら、より有用になるだけでなく、使っていてもより興味深いものになるだろう。たとえば遅延評価に使えるこの古いAPIについての議論はHacker Newsで多くあり、たとえば https://news.ycombinator.com/item?id=31739794 がある