2 ポイント 投稿者 GN⁺ 3 시간 전 | 1件のコメント | WhatsAppで共有
  • 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_actiontypeflagsfdnewfdarg の各フィールドで構成される
    • 子プロセスでファイルディスクリプタ 4 を閉じる必要がある場合、typeSPAWN_TEMPLATE_ACTION_CLOSEfd は 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件のコメント

 
GN⁺ 3 시간 전
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() ではないプリミティブが必要だという点には同意するが、性能が最良の論拠なのかはよく分からない
    • この論文も良いし、参考文献 [29] も fork() を含む スケーラブルなインターフェースの微妙な点を扱っていて特に良かった: The Scalable Commutativity Rule: Designing Scalable Software for Multicore Processors https://people.csail.mit.edu/nickolai/papers/clements-sc.pdf
    • 当時の議論はここにある: https://news.ycombinator.com/item?id=19621799 - A fork() in the road (2019-04-10, 178 comments)
    • fork()zygote パターンには素晴らしい
      それほど効率的でエレガントな最適化はなかなか思いつかない
  • 最近、fork されたプロセスでさらに多くの ファイルディスクリプタを閉じる必要があることから生じた、分かりにくいバグを経験した
    私の経験では「現在のプロセスの複製が欲しい」より「完全に新しいプロセスが欲しい」の方がはるかに一般的だが、後者を直接表現する方法がなく、複製してから事後的に修正する形でしか近似できないのは奇妙に感じる

    • 普通はそのプロセスと通信したいので、たとえばファイルディスクリプタのようなものを設定する必要があり、親プロセスの情報を渡さなければならない
    • それは O_CLOEXEC で解決するのでは?
    • 「後者を直接表現する方法」なら、それは posix_spawn の用途では?
    • 「完全に新しいプロセス」とは正確にはどういう意味?
  • fork() は比較的高コストなシステムコールであり、子プロセスのためにメモリを含むプロセス全体の状態をコピーしなければならない。長年にわたり多くの最適化が行われてきたが、本質的にコストの高い作業だ。さらに悪いことに、fork() 呼び出しの直後に exec() が続き、子のために丹念にコピーしたメモリをすべて捨ててしまうことが多い」と言いながら コピーオンライト(copy-on-write) に触れていないのは妙だ
    実際にはメモリ全体をコピーしないようにする最適化なのに抜けている

    • 記事では暗黙に処理していたが、ここでのプロセス状態のコピーとは メモリ管理構造を指す。主にページテーブルと VMA のことだ
      実際のページが指すメモリは共有されるとしても、これらの構造のコピーを格納するために新しいページを確保する必要がある。そしてそれらの構造をすべて走査してコピーすること自体が依然として高コストだ
    • Redis はこのコストが非常に重要になるタイプのプロセスだ。fork() はメモリ自体はコピーしないが ページテーブルは依然としてコピーしなければならない
      数十 GB の RAM を抱えるプロセスなら fork() に長時間かかることがあり、Redis が .rdb ファイルをダンプしたり、バイナリログである AOF を再書き込みしたりするたびに一度発生する
      2012年にもこの作業の高コストを示した記事があった: https://redis.io/blog/testing-fork-time-on-awsxen-infrastruc...
      約 25GB の RAM を使う m2.xlargefork() に 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 として優れているわけではない、という点には同意する
    • まったく逆の考えだ。UNIX 方式モデルの大きな失敗は、プロセス生成時にあまりに多くの 状態が保持されることだ
      たとえば、あるオブジェクトがファイルディスクリプタ番号 4 になるようにする API があり、プログラムを実行してそのプログラムに 4 番ディスクリプタからそのオブジェクトを見つけさせることができる。これはおかしい
      Windows は数多くの欠点があるにもかかわらず fork()+exec() を使わず、代わりに主としてプロセス生成方法に関するオプションを提供している。優雅ではなかったが、方向性は正しかった
    • それを優雅と呼ぶのは、fork()+exec() の歴史における 経路依存性
      fork()+exec() が存在しない別世界なら、そうした「通常の API」の多くは、別プロセスの設定を変更できる明示的な pid 引数を持っていただろう。Fuchsia はおおむねそういう方式だ
      この世界には利点が多い。最も明白なのは、設定エラーを報告するために別個の IPC 仕組みを魔法のように作り出す必要がないことだし、子の属性を調整する管理プロセスを置けるのもかなり有用だ。デバッガは特に喜びそうだ
    • fork() をなくす正しい方法は、プロセス状態を変更する通常の API が明示的な プロセスハンドルを受け取るようにすることだ
      そうすれば、同じ API で空のプロセスを設定でき、IPC やデバッグのような別方式とも組み合わせられる
    • 順序は spawn, configure, exec であるべきだ
      プロセスが ptrace 接続状態で、しかもスレッドなしで開始されるなら、設定段階でシステムコールを強制実行させられる。Linux には「スレッドのないプロセス」という概念すらないので、おそらくダミースレッドが必要になるだろう
  • fork() が安価だという誤解は異様なほど広まっているが、プロセスサイズに対して O(N) であり、昔からずっとそうだ
    その通り、コピーオンライトではある。しかし、プロセスサイズと、それを表現するのに必要なページテーブルエントリ数との間には線形関係がある

  • Chen のパッチが却下されたのは驚くことではない。あまりに特殊なユースケースで、サポートする価値が低い
    シェル開発者の観点では、「開発者たちは、現在の実装のように内部で fork()exec() を隠すのではない ネイティブ実装を歓迎する可能性が高い」という結論に同意する

    • 特定の実装というより、その概念自体には関心があるように見える
  • fork() は最初に学んだときから概念的にひどく見えた。何か一つの作業、つまりプロセス開始をしたいのなら、それとは別の無関係な作業である現在のプロセスのフォークという 謎めいた呪文を経由しなければならないのはおかしい
    記事の例のように、一つのプロセスが多数の git サブプロセスを起動する状況をどう処理するのが最善なのか気になる。長時間動作する親処理の途中で git を毎回ゼロから起動し直すのは筋が悪いように思えるが、同じ結果を生む低コストの抽象化は何だろうか?

    • fork() は概念的には単純だ。ほかの層を持ち込まないなら、確実に存在を知っている唯一のもの、すなわち 自分自身をもとにプロセスを開始することになる
      そうでなければ、プロセスを作り、実行する何かで埋め、実行されるよう配置するという複数の段階が必要になる。あるいは Win32 のように、ファイルシステム、オブジェクトローダー、リンカといった別の層と恒久的に押しつぶして結合するしかない
    • Windows 出身者として、fork()+exec() モデルはまったく理解できなかった。今では単なる 歴史的特異点だとわかっているが、それでも fork()+exec() が本当に優れているかのように振る舞う人たちがまだいる
    • libgit2 がある。パイプやソケットで何らかの gitd と通信する方式は想像できるが、それがなぜ良いアイデアなのかはわからない。そうでなければプロセスを起動するしかない
  • exec/fork を置き換えにくい理由は、新しいプロセスには通常、設定が必要だからだ。たとえばシグナルハンドラの設定、ファイルディスクリプタのクローズやオープン、名前空間の切り替え、seccomp の設定、権限調整が必要になる
    しかし、そのためのシステムコールは現在のプロセスにしか適用されないため、代替手段が必要になる。記事の提案は、そのための新しい API を作ることだった
    私の考えでは、spawn のような新しいシステムコールが空のプロセスを作り、その中に軽量な ローダーを載せたうえで、任意の設定データを渡せるようにする。ローダーがプロセスを設定し、主プログラムを exec() する方式だ
    こうすればメモリをフォークせずに既存 API を維持できるが、ファイルディスクリプタやその他のものは依然として複製しなければならない

    • 幸い、誰かがタイムマシンでこの文章を見て POSIX.1-2001 に追加しておいてくれたようだ :)
      冗談でなかったなら申し訳ないが、posix_spawn() はすでに存在しており、glibc では fork は単なる clone() の別名だ
      元の提案と完全に同じではないにせよ、fork()/exec() は本当にレガシーに近い
  • forkexec がコピーオンライトという性質を超えて、持続的で 代数的な動作を示せるなら、より有用になるだけでなく、使っていてもより興味深いものになるだろう。たとえば遅延評価に使える

  • この古いAPIについての議論はHacker Newsで多くあり、たとえば https://news.ycombinator.com/item?id=31739794 がある