※本記事は、Andrew Kelley氏による講演「Don't Forget To Flush」の内容を基に作成されています。本講演はSystems Distributed '25(https://systemsdistributed.com )にて発表されたものです。Andrew Kelley氏はZigプログラミング言語の創設者であり、Zig Software Foundation(https://ziglang.org/zsf/ )を率いています。氏の詳細については公式サイト(https://andrewkelley.me/ )をご覧ください。本記事では講演の内容を要約しております。なお、本記事の内容は原著作者の見解を正確に反映するよう努めていますが、要約や解釈による誤りがある可能性もありますので、正確な情報や文脈については、オリジナルの講演動画(https://www.youtube.com/watch?v=f30PceqQWko )をご覧いただくことをお勧めいたします。また、講演に関するディスカッションはTigerBeetleのSlackコミュニティ(https://slack.tigerbeetle.com/invite )でも行われています。
1. スタックメモリとヒープメモリの統一的理解
1-1. スタック割り当ての仕組みとOSによる事前確保
Kelley: すべてのプログラムは動作するためにメモリを必要とします。プログラムの実行中に任意の量のメモリをOSに要求することができますが、OSはそれを与えてくれるかもしれないし、与えてくれないかもしれない。あるいは与えてはくれるものの、実際に使おうとした瞬間にプロセスを殺しにくるかもしれないし、別のランダムなプログラムを犠牲にして空き領域を確保しようとするかもしれない。最悪の場合、システム全体をクラッシュさせることすらあります。
Kelley: こうした問題を軽減するためにプログラムが使う優れた手法のひとつが、スタック割り当てです。アイデアはシンプルで、あらかじめ一度だけメモリを確保しておき、ほとんどの処理にはそれを使い回すというものです。このパターンはあまりにも一般的なため、OSに直接組み込まれています。OSはあなたのコードが1行も実行される前に、すでにこの割り当てを済ませています。この確保されたメモリ領域がスタックです。使い勝手は非常に良く、必要なメモリ量を事前に把握できているかぎり、スタックを使えばよい。把握できていない場合は、ヒープ割り当てが必要になります。少なくとも、私たちは通常そのように考えています。
1-2. スレッド生成時にスタックはヒープ割り当てされるという逆説
Kelley: しかし、ちょっと待ってください。2つのことを同時にやりたい場合はどうなるでしょうか。複数のことを並行して処理するには、スレッドかプロセスが必要です。そしてスレッドを生成するために最初にやることは何か。新しいスレッドのスタックをヒープ割り当てすることです。これはいったい、スタックメモリなのでしょうか、それともヒープメモリなのでしょうか。答えは両方です。すべては一つであり、一つはすべてです。そしてこれは、複数の処理を子プロセスで行う場合でも同様です。たとえOSが代わりにやってくれているとしても、それは動的に確保されたメモリに他なりません。
1-3. この視点がスタックバッファサイズ設計に与える影響
Kelley: この考え方は単なる思考実験ではありません。スタック上のバッファサイズをどう決めるかという実践的な問いを、根本から問い直すことを迫るものです。たとえば、スタック上に大きなバッファをドカンと確保するコードを見たことがある方は多いと思います。それ自体は問題ないように見える。しかし、そのコードがより大きなシステムの一部として動いているのだと理解した瞬間、そのバッファサイズは重要な意味を持ち始めます。良いスタックバッファサイズとはいったい何なのか。それを掘り下げるには、まず並行処理モデルについて話さなければなりません。
2. 並行処理モデルの全体像とスケジューリングの本質
2-1. シングルスレッドブロッキングIOとコンパイル時スケジューリング
Kelley: まず基本となるのが、シングルスレッドのブロッキングIOです。これはほとんどの人が思い浮かべるベースラインであり、初心者に教える際に使うモデルでもあります。コードが上から下へ順番にすべてを実行していく。頭の中でも無理なく想像できる、シンプルな構造です。
Kelley: このモデルを「新鮮な目」で見直すと、実は私たちはすでにスケジューリングの決定をしていることに気づきます。たとえば、2つの処理の順番を入れ替えてからリコンパイルすることができます。これはコンパイル時のスケジューリング決定です。つまり、シングルスレッドブロッキングIOであっても、スケジューリングという概念から完全に自由なわけではないのです。
2-2. マルチスレッドとOSへのスケジューリング委譲の問題点・スレッドプール
Kelley: 次に登場するのがスレッドです。複数の処理ロジックが文字通り同時に走ることになり、自然選択による人間の進化が備えてくれた思考能力を、はるかに超えた領域に踏み込むことになります。それでも私たちは前進します。
Kelley: 第2のスレッドを導入した瞬間、スケジューリングがアプリケーションの一部になります。素朴なアプローチとしては、並行してこなすべき作業が生まれるたびに新しいスレッドを生成することが考えられます。しかしこの場合、スケジューリングのすべての決定をOSに丸投げすることになります。問題は、現代のOSがしばしば馬鹿げたスケジューリング決定を下すことです。これはアプリケーションがOSよりもドメイン固有の知識を持っているという事情もありますが、主たる原因は、現代のOSがアプリケーションにそうした知識を伝えたりOSのスケジューリング決定に直接影響を与えたりするためのプリミティブを提供できていないことにあります。
Kelley: この問題を緩和するために一般的に使われるのがスレッドプールです。同時に使用するリソース量を制限するために、あらかじめ固定数のスレッドを生成しておきます。通常はコンピュータの論理CPUコア数と同じ数だけです。そしてすべての作業をそのスレッド群だけにマルチプレックスします。スレッドプールに8という数字を明示的に指定した瞬間、アプリケーションはスケジューリングの責任を引き受けることになります。x個のタスクとy個のスレッドがあり、xがyより大きければ、どの順番でタスクをキューに積むかを決めなければなりません。これはパフォーマンスに甚大な影響を与えることがあります。特に、あるタスクが後続のタスクを生み出す可能性がある場合はなおさらです。ソフトウェアはOSと同じように、またOSが動くハードウェアと同じように、パイプラインとして考えることができます。上も下も、同じ構造が繰り返されています。
2-3. make -jを用いた過剰スレッド生成の会場実験
Kelley: スレッドプールを使わない有名な例がmake -jです。会場の皆さんに聞いてみましょう。make -jを使ったことがある人は手を挙げてください。かなりの数の手が挙がりました。では、それでシステムがクラッシュしなかった人は手を下ろしてください。笑いが起きました。これがスレッドプールなしで際限なくスレッドを生成した場合に何が起きるかを如実に示しています。
2-4. シングルスレッドノンブロッキングIOとコールバック地獄
Kelley: 次に登場するのが、シングルスレッドのノンブロッキングIOです。ここでepoll・kqueue・io_uring・IO completion portsといったAPIが登場します。これらはいずれも、IO処理の完了を待たずにOSにIO操作を依頼するための仕組みです。これだけでは、対応するIOが完了したときに呼び出される関数が次々と積み重なる、いわゆるコールバック地獄に陥ります。
Kelley: この戦略もまた、アプリケーションがスケジューリングの責任を担うことを意味します。OSがアプリケーションを起こすたびに、実行すべきコールバックがいくつも存在します。そしてそれをどの順番で実行するかという決定が必要になります。これもまた、特にCPU集約的な処理が混在する場合にパフォーマンスへ劇的な影響を与えることがあります。
2-5. M:Nハイブリッドモデル(グリーンスレッド・ファイバー)とyieldの実装
Kelley: パフォーマンスを一切妥協しないためには、M:Nハイブリッドモデルが必要です。グリーンスレッドやファイバーとも呼ばれます。スレッドプールとノンブロッキングIOを組み合わせたこのモデルの核心は、アプリケーション内でyieldを実装できることにあります。
Kelley: yieldの実装は具体的にはこのような形になります。9命令のコードで、現在のプロセスがOSに報告することなく別のスレッドに切り替わります。特定のレジスタを保存・復元することでこれを実現しています。WebAssemblyのようにそれが使えないアーキテクチャ上であっても、スタックレスコルーチンというプログラマーが手作業でも技術的には常に可能な変換によってyieldを実装することができます。もちろん、コンパイラがやってくれる方がはるかに便利ですが。
2-6. すべての並行モデルに共通する本質:スケジューリング決定からは逃れられない
Kelley: yieldするとき、どのタスクにyieldすべきでしょうか。これは修辞的な質問です。答えは明らかで、それはスケジューリングの決定です。これが私の言いたい核心です。アプリケーションを書くとき、私たちはスケジューリングの決定から逃れることができません。シングルスレッドブロッキングIOでさえ、新鮮な目で見れば、コンパイル時にすべてのスケジューリング決定を前もって行っていることがわかります。スレッドプールを使えばアプリケーションがスケジューリング責任を担い、ノンブロッキングIOでも同様にコールバックの実行順を決めなければならない。どのモデルを選んでも、スケジューリングからは逃げられないのです。この認識を持った上で、次に関数の「色」問題について話しましょう。
3. 関数の「色」問題とロジックを並行モデルから切り離す設計思想
3-1. async関数と通常関数の分離がもたらす困難(Function Colors)
Kelley: さて、目を覚ましてください。Lua以外のいくつかの言語には、async関数と通常関数という概念があります。すべての関数は通常関数を呼び出せますが、async関数を呼び出せるのはasync関数だけです。問題は、async関数はyieldできますが通常関数はyieldできないという点にあります。ここに分断が生まれます。
Kelley: この問題が生じるのは、yieldがスタックレスコルーチンを通じて実装される場合に限られます。先ほど述べたように、この戦略は関数をステートマシンに変換することを意味します。それによって関数の呼び出し規約が変わり、通常関数からは呼び出せないという制約が生まれます。asyncを伝染させなければならない、つまりasync関数を呼び出すすべての関数もasyncにしなければならないという問題です。
Kelley: 2種類の関数があってお互いを自由に呼び出せないというのは、プログラミングとして非常に不快です。難解な理由から互いを呼び出せない複数種類の関数を持ちたいと思う人など誰もいません。これはまさにオタク的な苦行です。
3-2. LuaとZigによる回避策・JavaScriptへの批判
Kelley: この種の関数の色付けは、注意深いプログラミング言語設計によって回避できます。たとえばLuaは、その動的な性質を活かしてこれらの関数の型を区別することを避けています。Zigは、すべてが同じコンパイル単位にあることを活かして、どの関数がasync呼び出し規約を必要とするかを自動的に推論します。そしてJavaScriptは、言い訳のしようがありません。
3-3. 並行モデルに依存しないコード記述の可能性(ファイルハッシュ計算の例)
Kelley: ただし、回避できないものもあります。それは、あるロジックが特定の並行処理モデルと相性が良いかどうかという問題です。たとえば、2つの異なるファイルを読み込んでそれぞれのハッシュのハッシュを報告するコードを考えてみましょう。このロジックは特定の並行処理モデルを必要としません。シングルスレッドブロッキングで書いて、2つの呼び出しの順番を手動で入れ替えても結果は変わりません。
Kelley: 同じロジックをノンブロッキングIOのアプリケーションに参加させるには、待機中に別のタスクに切り替えるようにすれば良い。あるいはスレッドを使った並行処理モデルに切り替えることもできます。このロジックを、動作する並行処理モデルに依存しない形で書けたら素晴らしいと思いませんか。それはこのような形になるでしょう。startとfinishの実装を変えるだけで、まったく同じコードがシングルスレッドブロッキング・スレッドプール・M:Nモデル、あるいは私たちが思い描くどんな並行処理モデルにおいても最適に動作できます。
3-4. ロジックの並行モデル互換性は自己制約ではなくコードの本質的な性質である
Kelley: 一方で、シンプルなサーバーのロジックは一部の並行処理モデルとしか使えません。シングルスレッドブロッキングを使えば、1つのクライアントの応答を待っている間、他のすべてのクライアントとのやり取りがブロックされてしまいます。このロジックはそのモデルと根本的に相性が悪いのです。同様に、スレッドプールを使った場合、接続クライアント数がスレッド数以上になると、一部のクライアントがレイテンシのスパイクやハングを経験します。ただし、ノンブロッキングな実装であればどれでも使えますし、1クライアント1スレッドという素朴なスレッド方式も使えます。
Kelley: では、このロジックをすべての並行処理モデルで再利用できるように書こうとすべきでしょうか。もしそうすれば、ある並行処理モデルの上に別の並行処理モデルを冗長に実装することになるだけです。たとえばサーバーを修正しようとして、どれか1つのクライアントを待つ間ブロックしないようにpollを導入したとします。おめでとうございます、アプリケーションスタックの1層上でシングルスレッドノンブロッキング並行処理を自前で実装したことになります。上も下も、同じ構造が繰り返されます。
Kelley: 私が言いたいのは、関数の色問題とは違って、この2つのコードスニペットの違いは任意の自己制約ではないということです。それはロジック自体の本質的な性質なのです。ではどうすれば良いのか。ロジックをIOから切り離すのです。ファイルシステム、ネットワーク、タイマー、同期プリミティブ、そしてasync/await、現在のスレッドをブロックしうるものすべて、CPUを大量に使う処理のかたまりも含めて、それらを切り離す必要があります。Zigはすでに、動的なメモリ確保をしたいすべての関数にアロケータを渡すことを求めています。それと比べれば、IOパラメータを渡すことなど朝飯前です。
4. IOパラメータを明示的に渡す設計がもたらすメリット
4-1. 関数の純粋性の可視化とコンパイル時IOの実現可能性
Kelley: ここで少し背景をお話しします。私はZigの開発において、誰もが1.0のタグ付けをするよう圧力をかけてくる中で、この10年間それを拒んできました。おかげで私は標準ライブラリを編集し、皆のコードを壊し、自分が天才なのか狂人なのかを実際に試すことができます。冗談めかして言いましたが、これを皆に押し付けると決める前に、このアイデアを評価するために膨大な努力を注いできたことは明言しておきます。そのメリットは冗談ではありません。
Kelley: まず最初のメリットは、関数の純粋性が非常にわかりやすくなることです。パラメータの見た目として最も多く目にすることになるであろう構造を思い浮かべてください。ここで2つのポイントがあります。1つ目は、IOパラメータを持たない関数はIOを行わないということです。コードが何をするのかを理解しようとするとき、これはすでに非常に有用な情報です。2つ目は、IOパラメータを持っている場合でも、呼び出し元がその効果を制御するという意味で、関数は純粋であり得るということです。たとえば、コンパイル時に動作するIOの実装を作ることが可能になります。そうなれば、IOを行うコードをコンパイル時に実行する能力まで手に入ることになります。
4-2. フリースタンディングターゲット(組み込み・自作OS)への応用
Kelley: 次のメリットはフリースタンディングターゲットへの応用です。これは組み込みプログラミングと、自分のホビー用OSを作っている人たちすべてを指します。組み込みプログラミングは真剣に受け止めやすいユースケースでしょう。ただ、私はここ15分ほどOSの振る舞いについて不満を述べてきたわけですから、そういった分野で取り組んでいる人たちをできる限り応援したいと思っています。ここでのポイントはもちろん、IOをインターフェースとして使えば、サードパーティのコードがフリースタンディングターゲット上でも再利用可能になるということです。ただし、開発者がそのアプリケーション向けのインターフェース実装を作ることが前提になります。
4-3. リークチェック・デバッグツール・依存性注入・カスタムスケジューリング
Kelley: 次はリークチェックです。Zigはすでにメモリ割り当てに対してこれを行っていますが、ファイルディスクリプタ・ネットワークソケット・その他のリソースについても同様にリークを検出できたら便利だと思いませんか。
Kelley: 続いてデバッグについてです。スレッドサニタイザーを使っている方は会場にもかなりいましたね。スレッドサニタイザーはレース条件を検出するための便利なツールです。同様のツールをOSのスレッドレベルではなく、IOインターフェースのAPIレイヤーで実装することができます。そちらの方が大幅にパフォーマンスが高く、バグにつながりうる可能性のある組み合わせをより多く素早くテストできます。実行順序をファジングテストできたら素晴らしいと思いませんか。そのコンセプトだけで会社が1つ作れるかもしれません。
Kelley: 次は依存性注入です。2012年を覚えていますか。覚えていない?もうそんなに経ちましたか。あの頃、依存性注入が大流行していました。暗号通貨もなければ、AIも当然ありませんでした。あったのは依存性注入だけで、それだけで皆が盛り上がっていた時代です。冗談はさておき、ここでのポイントは、IOインターフェースに対してコードを書けば、そのインターフェースを偽装してより簡単にユニットテストができるということです。場合によっては非常に便利です。
Kelley: そしてカスタムスケジューリングです。他の人がIOインターフェースに対してコードを書けば、そのロジックはスケジューリングについて何も主張しなくなります。つまり、サードパーティのコードを自分のアプリケーションで動かしながら、そのサードパーティのメンテナーが自分のアプリケーションについて何も知らなくても、最適な動作を得ることができるのです。
4-4. キャンセル処理とdeferブロックによる優雅なキャンセル伝播の実証
Kelley: 次にキャンセル処理について説明します。操作を開始した後で、その結果や効果に関心がなくなったと判断することができます。これはasync/await機能をプログラミング言語に直接組み込もうとすると非常に複雑になりがちな話題です。しかし実際には、標準ライブラリのユーザーランドで実装する方がうまく機能します。
Kelley: 具体的なコードで説明します。try文でエラーが発生すると、制御フローが上に移動し、deferブロック内の式が実行されます。つまりエラーが発生するたびに、他のすべての進行中の操作がキャンセルされ、それらはキャンセルエラーを返して終了します。これはZigにおける典型的なエラー処理の方法と非常にうまく噛み合います。未処理のキャンセルエラーがスタックを上に伝播していくにつれて、途中にある進行中の操作にもキャンセルが伝わり、最小限のボイラープレートでタワーのように積み重なった操作群をエレガントにキャンセルすることができます。
4-5. 並行モデル非依存コードの実証:スレッドプールとM:Nモデルの切り替え例
Kelley: もちろん最大のメリットは、ここまでずっと積み上げてきた話の核心です。並行処理モデルに依存しないロジックを書けるようになり、「再利用可能なコード」という言葉の意味が別の次元に広がります。これはすでに今日動作する概念実証として存在しています。セットアップコードを見てみましょう。今はスレッドプールの実装を初期化していますが、そこをコメントアウトして下のコードに切り替えるだけで、以下のすべての例がM:N並行処理モデルのもとでも、サンプルコードをまったく変更せずに動き続けます。
Kelley: 簡単な例でasync/awaitを体験してみましょう。このcalculate_sum関数はIOを何も行いませんが、asyncで表現されているため、IOの実装がそのロジックを並列に実行することを選ぶことができます。もっとCPU集約的な処理を想像すれば、並列化による恩恵はさらに大きくなります。
4-6. Go互換のチャンネル・select実装がIOインターフェースで実現できるという観察
Kelley: さらに、Goのチャンネルとセマンティクスとしてほぼ等価なキューの実装も持っています。selectの実装も用意しています。GoのSelectキーワードにインスパイアされたと言えるかもしれません。実際のところ、このIOインターフェースを使えば、ガベージコレクションを除いてすべてのGoプログラムとセマンティクス的に等価なZigプログラムを書くことができます。皆さん、GoのランチがZigにとってかなりおいしく見えてきたと思いませんか。私はお腹が空いてきました。
5. 非同期時のバッファサイズ問題とストリームの命名
5-1. async使用時にスタックがヒープ割り当てになる影響と適切なバッファサイズのトレードオフ
Kelley: 並行処理モデルの話が一通り終わったので、最初の問いに戻りましょう。先ほど、複数のことを同時に行う場合、スタックメモリはヒープ割り当てになると述べました。つまりasyncを使う場合、呼び出すすべての関数を収めるのに十分な大きさのメモリチャンクを割り当てなければなりません。これはスタック上にバッファを好き放題に確保するわけにはいかないことを意味します。コードの再利用性を最大限に高めるためには、適切なバッファサイズを見つけなければならないのです。
Kelley: そのトレードオフはこうです。バッファが大きすぎると、並列処理の速度と量の両方が制限されます。逆にバッファが小さすぎると、操作をバッチにまとめることができず、パフォーマンスが著しく低下します。大きすぎず、小さすぎず。この問いへの答えは、後ほどストリーミングIOインターフェースの設計を詳しく説明したあとで、改めて整理します。
5-2. 主要言語のストリームAPIの概観(C・Go・Rust・Node.js・Python・C++・Java・C#)
Kelley: これでパート3、ストリーミングIOインターフェースの設計に入る準備ができました。すべてのプログラミング言語にはこの概念があります。再利用可能な抽象化を書くための基本的なAPIです。CではFILE*、Goではbufio.Readerとbufio.Writer、Rustではio::Readとio::Writeトレイト、Node.jsではstream.Stream、Pythonではio.BufferedReaderとio.BufferedWriter、C++ではstd::istreamとstd::ostream、Javaではjava.io、C#ではSystem.IO.BufferedStreamです。大体わかってもらえたと思います。
5-3. input stream・writer・reader・source・sinkの命名の混乱とオーディオ用語採用の提案
Kelley: さて、命名について少し話さなければなりません。私たちが名前をつけなければならないものがあります。それはストリームです。インプットストリームと呼んでみましょう。インプットストリームとは、インプットするストリームですよね。ではこのバッファは何でしょうか。アウトプットストリームのインプットです。ではライターとリーダーで試してみましょう。ライターとはどういうものか。書き込みを行うストリームです。ではライターは何をするか。バッファから読み取ります。そしてリーダーはバッファに書き込みます。なるほど。
Kelley: だからこそ、標準的な名前がどうであれ、私はオーディオの用語を使うべきだと思います。ソースからバッファを受け取り、シンクにバッファを渡す。これで曖昧さがなくなります。ライターやリーダーという言葉が引き起こす混乱を一掃できるのです。
6. バッファリングの重要性とvtable境界の設計原則
6-1. 1バイトずつ書き込む場合の約1000倍のパフォーマンス差の実測
Kelley: バッファリングがいかに重要かを示すために、「Hello World」を表示するシステムコールをバッファリングあり・なしで比較してみましょう。1バイトずつ書き込む場合、システムコールの回数は14回になります。一方バッファリングありであれば1回で済みます。当然1回の方が速い。では何倍速いでしょうか。会場の皆さんに聞いてみると、14倍という声が上がりました。しかし実際のところ、このチャートが示すように、バッファがCPUのL1キャッシュに収まると仮定した場合、バッファリングなしは約1000倍遅くなります。これは明らかに重要な差です。
Kelley: バッファリングが重要なのと同様に、間接呼び出しを避けることも重要です。CPUが間接呼び出しをより遅く実行するからというだけでなく、コンパイラの最適化に対してより不透明になるからです。そこでvtableについて話す必要があります。
6-2. vtable境界の概念:インターフェース側(vtable上)と実装側(vtable下)の区別
Kelley: どのプログラミング言語においても、各インターフェースには構造体があり、そこにはフィールドとメソッドが含まれます。重要なのは、それらのフィールドの1つ以上がvtableと呼ばれるものになるという点です。vtableとは、特定のインターフェースを満たすために設定された、実行時に決まる関数ポインタの集合です。構造体のフィールドとメソッドはコンパイラに対して透明であり、インライン化・抽象的解釈・検査・最適化の対象になります。一方、関数ポインタは実行時に決まるため、コンパイラは通常それらをブラックボックスとして扱わなければなりません。
Kelley: ここに重要な区別があります。vtableの上側と下側です。ホットパスはvtableの境界より北側、つまりインターフェース側に置きたいのです。これを実際のコードで説明します。
6-3. バッファリングを実装側に置いた場合:バイトごとの間接呼び出しが残る問題
Kelley: 1バイトずつ書き込む同じコードを2つの例で見てみましょう。どちらもバッファリングありで、同じセットアップコードを使います。このサンプル関数は真の意味で再利用可能です。1つのx86機械語コードのチャンクを生成しながら、すべての可能なシンクのインスタンスをサポートします。文字通り同じ機械語コードを、異なる実行時の関数と組み合わせて使えるという意味での再利用可能性です。
Kelley: まず、実装側にバッファリングを置いた場合を見てみましょう。インターフェース自体はバッファリングを行いません。これは単なる関数ポインタの例です。バッファリングを行うのは実装側です。関数ポインタはその書き込み関数を指しており、バイトをバッファに書き込み、バッファが一杯になったら最終的な出力を呼び出します。詳細を理解しようとしなくてもかまいません。ただ、バッファリングが実装側にあるということだけ理解してください。生成された最適化済み機械語コードを見ると、四角で囲まれたバッファリングロジックに加えて、すべての1バイトの書き込みに対して間接呼び出しが入っています。これが問題です。
6-4. バッファリングをインターフェース側に移動した場合の最適化効果の実演
Kelley: 一方、同じロジックをインターフェース側に移動してみましょう。バッファを構造体の内部に持ち、ロジックをvtableの上側に移します。コンパイラが削除しました。待ってください。もう一度あの例を出してください。Marinaさん、私のトークのタイトルは何でしたっけ?「Flushing」です。なんてこった。flushし忘れました。これは実際にトークの準備中に起きたことです。
Kelley: 修正したので、これが実際の出力です。このコードは可能な限り安価な操作だけを行っています。単純なレジスタ操作とメモリへの書き込みです。さらに、オプティマイザーフレンドリーなので、コンパイラはすべての書き込みを単一の操作にまとめることができました。AへのWrite、BへのWrite、CへのWrite、DへのWrite、それ以降も同様です。これらの緑色の行は、バイトをバッファに直接書き込んでいるだけで、余分な処理は一切ありません。
6-5. 発表中にflushし忘れるリアルな失敗の実演("Don't Forget To Flush"の由来)
Kelley: ただし、これはズルをしています。私たちが検討しようとしているのは再利用可能なコードです。ですからオプティマイザーにこの余分な情報を与えるわけにはいきません。サンプル関数を直接エクスポートするようにしましょう。そうすると、コードは少し悪くなります。このコードは先ほどのほど素晴らしくはありませんが、それでも優れています。間接関数呼び出しを、劇的に安価な操作と引き換えにしました。しかも、動的にロードされたものも含めて任意のシンクのインスタンスを同じ機械語コードでサポートするという性質は失っていません。
6-6. 再利用可能コードとして書く場合のコンパイラ最適化の限界と現実的な落とし所
Kelley: このコードが高レベルで何をしているかを説明すると、1バイトを書き込み、可能な限り安価な操作をいくつか行い、高価なコードをスキップして、すべての書き込みに対してそれを繰り返しています。これは間接関数呼び出しのパフォーマンスをいつでも上回ります。しかしこれが生み出す課題は、ロジックをインターフェース側に移せば移すほど、インターフェースの柔軟性が失われるということです。これは根本的なトレードオフです。ロジックが実行時に決まるか、コンパイル時に決まるかという性質そのものが、それぞれCPUオプティマイザーやメンテナンス負荷に対してどれだけフレンドリーかを決定します。
Kelley: ここでの重要な観察は、物理法則との取引内容を理解するということです。コードの再利用性を得る代わりでなければ、パフォーマンスや静的解析のしやすさを手放してはいけません。経験の浅いプログラマーが、直接関数を呼び出せばいいだけなのにインターフェースや関数ポインタを使うケースを何度も見てきました。そこから何も得ていない。コードを人間にとっても機械にとっても読みにくくするだけです。この考えを念頭に置きながら、次に主要な言語のIOストリームインターフェースを調べていきましょう。
7. 主要言語のIOインターフェース設計の比較・評価
7-1. glibc(FILE*):vtable上に多数の機能を持つが複雑
Kelley: それでは各言語のIOストリームインターフェースを詳しく見ていきましょう。vtableの上側と下側に何を置いたか、つまりインターフェース側と実装側にそれぞれ何を置いたかを確認していきます。その後、私がZig標準ライブラリの新しいIOストリームをどのように設計したかをお見せします。
Kelley: まずglibcです。何ですかこれは。不吉な予感がします。では代わりに、より現代的なlibcを見てみましょう。こちらの方がまだ読みやすいですが、それでも多くのことが起きています。一言で言えば、libcは複数のバッファ・シーク・クローズ・ロック・ロケール・クッキー、そしてもちろんvoid* _IO_2_1_stdout_がゼロでなければならないなど、vtableの上側に大量のものを置いています。実装を見せるつもりはありませんが、少なくともバッファリングは関数ポインタを呼び出す前に行われていることは確認できます。同じサンプルコードをCに翻訳してコンパイルすると、コンパイル単位をまたいでいるためにputcへのインライン化ができません。残念ですが、少なくとも間接呼び出しとシステムコールは避けられています。
7-2. Go:インターフェースにバッファなし・Stack Overflowの模範回答が最悪コードを生む実例
Kelley: 次により現代的でクラスタが少ない言語に移りましょう。Goです。インターフェースを見てみると、バッファがありません。Goのプログラマーはbufio.Writerのインスタンスを受け渡すことになっているようです。では現代のインターネットに聞いてみましょう。再利用可能な関数ではbufio.Writerを受け取るべきか、それともio.Writerを受け取るべきか、どちらがよりイディオマティックでしょうか。Stack Overflowでまさに私の質問を見つけました。Webインスペクターを使ってクッキーバナーを消したところ、答えが出てきました。これはひどいアドバイスです。もしこれに従えば、ここで挙げているすべての例の中で断然最悪のコードが生成されることになります。
Kelley: いかにこの提案が見当外れかを示すために、それが生成するコードを簡単に見てみましょう。Goに移植したサンプルコードと、生成されたコードがこちらです。可能な限り最悪のことをした上に、各バイトを文字列オブジェクトとしてヒープ割り当てまでしています。なんということでしょう。では私の意見通りにやってみましょう。bufio.Writerを使ったコードです。バッファリングありで1バイトずつ書き込む場合のコードを生成しました。これはCのコードと同程度の品質です。おそらくGoコンパイラがそれらの関数をインライン化しない理由は、コンパイルを速くしたいからでしょう。積極的なインライン化を避けているのです。妥当なトレードオフではあります。
7-3. Rust:Writeトレイトはバッファを認識しているがトレイトがフィールドを持てない限界
Kelley: 次にRustを見てみましょう。このインターフェースは比較するとかなり興味深いです。ベクタを書き込めるのが気に入っています。flushも入っています。つまりこのインターフェースはバッファリングを認識しています。しかしRustのトレイトはフィールドをサポートしていません。そのため、バッファリングは残念ながら実装側に置かれることになります。BufWriterという型がありますが、ジェネリクスなので、それを使うにはサンプル関数もジェネリクスにしなければなりません。こちらがサンプルコードです。ジェネリクスや脱仮想化のからくりを防ぐためにクレートにコンパイルします。各バイトへの間接関数呼び出しがすべて残っています。
Kelley: ただしこれを調査する中で、Rustが脱仮想化において非常に優秀であることに気づきました。クレートを使わないすべての例で最適化を達成していました。しかしそれはズルです。この分析はストリームの実装が実行時に決まる場合を対象としています。ですからクレートを経由した場合の振る舞いがこの比較において重要なものです。
7-4. C++:標準ライブラリの可読性への批判とCと同等のアセンブリ出力
Kelley: 最後にC++です。このゴミ捨て場のような標準ライブラリの中から実際のインターフェースを探し出すほどの給料はもらっていません。とにかく試してみましょう。こちらがコードです。結果はほぼCと同じになりました。C++のソースコードが生成するx86-64の機械語コードの方が、C++のソースコード自体よりもはるかに読みやすいというのは驚くべきことです。
7-5. 主要言語のいずれもバッファリングをインターフェース側に置けていないという共通の結論
Kelley: 結論として、これらの主要言語のいずれもバッファリングをインターフェース側に置くことができていません。すべて実装側に置いています。そしてそれは理解できます。私は2月からこの問題に取り組んでいますが、まだ終わっていません。30,000行以上の差分を手で打ち込んでいますが、まだコンパイルすら通っていないし、テストを通過するにはほど遠い状態です。IOストリームのAPIを変更すると、膨大な量のコードを作り直さなければなりません。単純なリファクタリングという話ではありません。コアとなる制約や考慮事項が変わり、ストリームを操作するすべてのものの内部状態に影響を及ぼします。すでにJSON・HTTP・TLS・リンカ・Zstandard圧縮・LZMA・Inflate・Zipファイル・Gitプロトコルフェッチ・多数のハッシュアルゴリズムといった大きな部分を書き直さなければなりませんでした。これらはすべて、Zigのパッケージマネージャーで使われている重要なコードです。だからこそインターフェースを慎重に考え、正しく設計することが重要なのです。
8. ZigのIOインターフェース再設計の現状・開発プロセスと実証例
8-1. 30,000行以上の差分・7回以上のAPI設計やり直しというプロセスの実況
Kelley: インターフェース設計がいかに反復的なプロセスであるかを少し実演してみましょう。バッファを受け取るシンプルなwrite関数を持つインターフェースがあります。皆さんにも馴染みのある形です。しかし、複数のデータバッファを1回のシステムコールで送れるwritevやsendmsgのような機能を活用したいとします。それをインターフェースに追加してみましょう。追加しました。ではユニットテストを実行してみます。まだやることがたくさんあるようです。9時間後。まだすべてを修正し終えていませんが、その過程で重要なことに気づきました。
Kelley: vtableの関数がバッファを含むすべての状態にアクセスできるようにすれば、リングバッファなしでの圧縮・展開のような高度なユースケースも処理できることがわかったのです。なるほど。ではその変更を加えてみましょう。今のところ順調です。また何もかもが壊れました。このプロセスをすでに7回繰り返し、そのたびに代償を払ってきました。ブランチをランドする前にもう一度起きないとは断言できません。しかし今の時点でわかっていることをお伝えします。
8-2. API変更が波及する広範なコンポーネント(JSON・HTTP・TLS・リンカ・圧縮・ハッシュ・Git・Zipなど)
Kelley: IOストリームのAPIを変更するということは、単純なリファクタリングで済む話ではありません。コアとなる制約や考慮事項が根本から変わり、ストリームを操作するすべてのものの内部状態に影響を及ぼします。すでにJSON・HTTP・TLS・リンカ・Zstandard圧縮・LZMA・Inflate・Zipファイル・Gitプロトコルフェッチ・多数のハッシュアルゴリズムといった広範なコンポーネントの大部分を書き直さなければなりませんでした。これらはすべてZigのパッケージマネージャーで使われている重要なコードです。だからこそインターフェースを慎重に考え、正しく設計することが重要なのです。
8-3. writev/sendmsg追加時の破壊的影響とvtable全状態アクセス付与による気づき
Kelley: 今お見せした反復プロセスの中で、特に重要な気づきが2つありました。1つ目はwritev・sendmsgの追加です。複数のデータバッファを1回のシステムコールで送れるこれらの機能をインターフェースに加えた途端、既存のすべての実装が壊れました。9時間かけて修正作業を進める中でも、すべてを直しきれなかった。しかし2つ目の気づきは、その苦労の中から生まれました。vtableの関数がバッファを含む内部状態のすべてにアクセスできるようにすると、内部リングバッファなしでの圧縮・展開のような高度なユースケースまで自然に処理できるようになることに気づいたのです。この発見がさらなるAPI変更を促し、また何もかもが壊れるという繰り返しになりました。
8-4. 現行WIPブランチの設計概要:vector・splat・sendfile・drop・中間バッファなし直接書き込み
Kelley: 現在の作業中ブランチの状態をお見せします。注目すべき概念がいくつかあります。まずシンクはvectorとsplatをまとめて送ることをサポートしています。splatとは最後のバッファをn回繰り返すことを意味します。これをひと言で要約すると、memsetを論理的にシンクのチェーン全体に送信しながら、そのメモリを冗長に書き出したりコピーしたりすることなく済ませられるということです。
Kelley: 次に、シンクはオープンしているファイルからのデータ送信をサポートしています。これにより、条件が整った場合にカーネル内でのファイルディスクリプタ間の直接転送、つまりsendfileが可能になります。ソース側では、データを保存せずに破棄することができます。これがより効率的な場合があることは想像に難くないでしょう。またソースは中間バッファを介さずに直接シンクに書き込みます。
8-5. zig stdコマンドのHTTPサーバーを用いた4層ストリームチェーンでの実証
Kelley: 実世界でこれらのAPIをどのように使うか、具体的な例を見てみましょう。zig stdというコマンドを実行すると、オンデマンドのHTTPサーバーが立ち上がります。WebAssemblyモジュールをソースからその場でコンパイルし、それをHTMLやJavaScript、そしてZigのソースコードのtarballとともに提供します。WebAssemblyのコードがそのtarballを使って、ブラウザ上でドキュメントをその場で生成するという仕組みです。ブラウザでURLをクリックすると、このような標準ライブラリのドキュメントをブラウズしたり検索したりできるウィンドウが開きます。
Kelley: ではここで、ブラウザがソースのtarballをロードするためのHTTPリクエストを詳しく見てみましょう。このtarballにはすべてのファイルが含まれており、それをそのままブラウザに送信しています。サーバープロセスはストリームのチェーンをセットアップします。一方の端にはブラウザとの接続があり、それをHTTPサーバーレスポンスでラップします。おそらくchunked transfer encodingでしょう。そこにtarballストリームをつなぎ、さらにファイルストリームからパイプします。つまりこのストリーミングIOインターフェースの4つのインスタンスが連結されているのです。
Kelley: ほとんどの言語でこれを実装すると、余分なバッファリング・memcopy・システムコールが大量に発生します。しかし私が考案したストリームAPIを使えば、ここでシステムコールの最適性を達成できます。HTTPのマルチパートチャンクの一部であるにもかかわらず、sendfileを使ってファイルデータを直接ネットワークソケットに転送することすら可能です。zig stdのコマンドラインインターフェースにとっては大した意味はありませんし、あくまでも概念実証に過ぎません。しかしこのサーバーでの効率性は、レイテンシや帯域幅のすべてが重要な本番グレードのサーバーでの効率性に直結します。
9. 結論:良いスタックバッファサイズとは何か
9-1. 実装の偶発的複雑さを最小化するためのshort write / short readの戦略的活用
Kelley: これだけの議論を積み重ねてきて、ようやく冒頭の問いに答える準備が整いました。良いスタックバッファサイズとは何か。まずやりたいことは実装を単純化し、偶発的な複雑さを避けることです。そのために戦略的にshort writeとshort readを活用します。これはストリームの実装側で書き込みを行う際に、入力のうち限られた一部だけを処理すれば良いということです。必要であればゼロを返してもかまいません。何かを並び替えるだけで済む場合はゼロを返し、次の呼び出しで次の大きなバッファを処理すればいい。実装をシンプルにすることが何よりも優先されます。
9-2. TLSの最大暗号化フレームサイズ採用による実装簡素化の実体験
Kelley: 具体的な例を挙げましょう。TLS、つまりTransport Layer Securityです。スレッドローカルストレージでもトップレベルステートメントでもありません。TLSのバッファサイズとして私が選んだのは、最大暗号化フレームサイズです。この選択によって実装ロジックを劇的に単純化することができました。削除した大量の赤いコードは誰も見る必要はありません。皆がこの苦しみから解放されました。バッファサイズを最大暗号化フレームサイズに合わせることで、フレームの境界を跨ぐ複雑な処理が不要になり、実装が根本的にシンプルになったのです。
9-3. エンドポイントでのキャッシュライン犠牲とシステムコールバッチ化のトレードオフ
Kelley: そして最後に、エンドポイント、つまり実際の書き込みや読み取りが発生する場所では、少数のキャッシュラインを犠牲にしてシステムコールのオーバーヘッドを避けるというトレードオフを取ります。実際の書き込み・読み取りにおいては、キャッシュラインをいくつか犠牲にする代わりに、システムコールをバッチにまとめます。その最適なバランスを見つけることで、チェーン全体にわたるすべてのバッファリングの問いに答えが出るのです。
Kelley: そして何より、flushを忘れずに。ありがとうございました。
