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

ジグの新しい非同期入出力

概要

  • Zigの新しいI/Oインターフェース導入による設計変更
  • async/await復活と非同期I/O設計の進化
  • コード再利用性と最適性の向上
  • 標準ライブラリによる複数I/O実装サポート
  • 今後のロードマップとリリース計画

Zigの新I/Oインターフェースと非同期設計

  • Zig では新しい Ioインターフェース が導入され、すべてのI/O操作をこのインターフェース経由で実行
  • Io は呼び出し元が提供する設計となり、 Allocator と同様のインジェクション方式採用
  • 旧実装では直接ファイルや標準出力を操作していたが、新実装では Io を引数として渡すことで、具象I/O実装の選択が可能
  • 依存パッケージのコードでも呼び出し元がI/O実装を注入できる柔軟性
  • Io はI/Oのみならず 並行処理 も担い、イベントループとの親和性を確保

並行性と非同期性の違い

  • async/await の導入で、I/O操作の並行実行が明示的に表現可能
  • io.async で非同期タスクを生成し、 Future.await で結果取得
    • 例:2ファイルへの同時書き込みを明示的に表現
  • awaittry を分離して利用することで、リソースリーク防止
  • キャンセル機構 もサポートし、 Future.cancel で未完了タスクの解放が容易

標準ライブラリのI/O実装

  • Io はランタイム多態性を持つインターフェースで、独自実装やサードパーティ製も利用可能
  • 標準ライブラリには以下の実装が用意予定
    • Blocking I/O :C言語同等のシンプルなシステムコールベース
    • Thread Pool :OSスレッドプールによる並列I/O
    • Green Threads :Linuxの io_uring 等を利用したスタックスワップ型非対応環境向け
    • Stackless Coroutines :状態遷移マシンによるコルーチン、 WASM 等で有効

コード再利用性と最適性

  • Zig は関数の「色付け」問題(async/blockingの分離)を回避
  • io.asyncFuture.await を用いることで、1つのAPIで同期・非同期両対応
  • 新設計ではランタイムの実行モデル(blocking/stackless coroutine等)から完全に切り離し
  • Io は非ジェネリックで、 vtable によるディスパッチでコード膨張を抑制
  • 最適化ビルド時は単一実装なら de-virtualize (仮想呼び出し排除)も保証
  • バッファリングはリーダー/ライターインターフェースに内包し、仮想関数呼び出しの最適化を促進

セマンティックI/O操作

  • Writer インターフェースに2つの新プリミティブ
    • sendFile :カーネル空間内でファイル間コピーを実現(POSIX sendfile類似)
    • drain :複数データセグメントを一括書き込み(writev相当)、 splat でデータの繰り返し最適化

今後のロードマップ

  • これらの変更の一部は Zig 0.15.0 で導入予定
  • 残りは次のリリースサイクルで順次マージ
  • 標準ライブラリの大規模な書き換え・再設計が進行中

まとめ

  • 新しい Ioインターフェース で、ZigのI/Oと並行処理がより柔軟かつ最適化
  • async/await の復活による直感的な非同期記述とリソース管理性向上
  • コード再利用性・最適性・セマンティックI/O表現の進化
  • 今後のリリースに向けた着実な設計刷新

Hackerたちの意見

これをもう一度指摘しなきゃいけない気がするんだよね。記事ではこう言ってるしさ。

「この最後の改善で、Zigは関数の色分けを完全に克服した。」 私はこれに反対だな。ここで言及されている有名な「あなたの関数の色は何ですか?」の記事の5つのルールを見てみよう。

  1. すべての関数には色がある まあ、もうasync/sync/red/blueはないけど、IO関数と非IO関数があるよね。
  2. 関数を呼び出す方法はその色による 技術的にはこれが解決されたようだけど、IOをパラメータとして提供する必要がある。非IO関数はそれを必要としない。見た目は普通の関数呼び出しだけど、実際には大きな違いはない。
  3. 赤い関数は別の赤い関数の中からしか呼べない これはまだ当てはまる。IO関数は他のIO関数の中からしか呼べない。技術的には新しいexecutorを渡すこともできるけど、それが本当に望んでいることなのか?そもそも、色分けの問題を解決したとは言えない言語でもこれができるし。
  4. 赤い関数は呼び出すのが面倒 ここでもその精神はまだ当てはまると思う。
  5. 一部のコアライブラリ関数は赤い これは、言語や標準ライブラリでしか実装できないものに関することだと思う。Zigには当てはまらないけど、Rustにも当てはまらないかな。 今、これらのルールには少し手を加える必要があると思うけど、関数の色分けの根本的な問題はコンテキストにある。関数には何らかのコンテキスト(async executor、認証情報、アロケーターなど)が必要なんだ。それを呼び出すためには、コンテキストを提供する必要がある。Zigはこれを本当に解決したわけじゃない。とはいえ、Zigの実装が悪いとは思わない。むしろ、実装からの使用をうまく抽象化していると思う。これはRustが見事に失敗している部分だ。ただ、色分けの問題は本当に克服されたわけではない。

私はこう考える。ioがあれば、技術的には同じインターフェースを持つ別のものを構築できる。例えば、非同期IOランタイムがあれば、ブロッキング(すべてのコマンドを即座に待つ)なioオブジェクトを作れる。特別なことではないよ。非同期関数から同期関数を呼び出すこともできるし。(でも、JavaScriptでは内部でawaitに依存する同期関数を呼び出すのは難しいから、これはまだ何かあるけど。)もう一つ興味深いのは、ブロッキングPOSIX I/Oがプロセスやスレッドの作成を許可する場合、ユーザースペースでそのブロッキングなものから真の非同期ioオブジェクトを構築できることだ。iouringに直接基づくものほど効率的ではないし、古いスタイルになるけど、基本的には動く。どちらの方向に行っても(ioを同期または非同期に変更しても)、呼び出し元は実際には気にしない。呼び出し元はコンテキストが必要だけど、ほとんどの現代アプリは何らかの形の依存性注入に頼っている。よく整理されたアプリは、ZigのPOSIX風の標準ライブラリioではなく、より洗練されたドメイン特化の「環境」(Rocの用語を使うならプラットフォーム効果のセット)から利益を得るだろう。確かにRustもある程度はこれを達成している。非同期ランタイムを別のものに入れ替えても、アプリは問題なくコンパイルされて動くかもしれない。全体的にこれはすごくいいと思う。Richard FeldmannがAndrew Kelleyを「プラットフォームはクールだ」と説得できたのか、Rocからいくつかのアイデアを借りたのか気になるな。

一般的な非同期関数の色分けとの大きな違いは、Ioが非同期性のために特に必要なものではないってことだ。ファイルを読み込んだり、スリープしたり、時間を取得したりするために、IOを行うために必要なものなんだ。これは関数の特別な属性やプロパティではなく、どこにでも置ける値なんだ。実際には、これらの特性が色分けの問題を解決する。

  • 一般的に、関数が「IOを行う」という依存関係を予期せずに持つことは非常にまれだ。実際には、コードベースのほとんどはIoにアクセスできて、純粋な計算を行うリーフ関数だけがそれを必要としない。
  • もし関数がIOを行う必要が出てきた場合、ほとんど確実にそれをパラメータとして受け取る必要はない。多くの言語と同様に、Zigコードではコア状態を管理する一つの型があって、コードベース全体が簡単にアクセスできるのが一般的だ(例えば、Zigコンパイラ自体では、これがCompilation型)。このため、Zigコードは通常、アロケーターを関数呼び出しグラフの下まで明示的に渡すことはない!代わりに、あなたの「汎用アロケーター」はその「アプリケーション状態」型で利用可能だから、基本的にどこからでも取得できる。実際にはIOも同じように機能する。だから、以前は純粋だと思っていたコードパスが実際にはIOを行う必要があることが分かったら、厄介なウイルス的な変更を適用する必要はない。単にmy_thing.ioを取得すればいい。原則的には、まだ関数の色分けが行われているという点には同意する。問題に対する私たちの解決策は、ほとんどすべての関数にIoへのアクセスを与えることで、すべての関数を非同期色にすることだと言えるかもしれない。でも、アロケーターを渡す必要があるという「色分け」と同じで、実際には問題ではない。なぜなら、基本的にいつでも簡単にアクセスできるからだ。経験豊富なZig開発者は、アロケーターを明示的に渡すことが実際には関数の色分けの煩わしさを引き起こさないという意見で一致するだろうし、Ioが特に異なる理由はないと思う。

関数パラメータがそれを色分けするという馬鹿げた主張を除けば、IOを受け取る関数をIOを受け取らない関数の中から呼び出せないという主張は間違いだ。なぜなら、初期化して渡すことができるからだ。

Goもこの「微妙な色付け」の影響を受けてるよね。goroutineを使うときは、キャンセル処理のために必ずコンテキストパラメータを渡さなきゃいけない。多くのライブラリ関数もコンテキストを要求するから、他の関数にも影響が出ちゃう。技術的には、goroutineにコンテキストを使わなくても、context.Backgroundで依存関係をスタブにすることはできるけど、あんまり推奨されてないよ。

ここで、すべての関数を赤(または青?色盲だから、君が決めて)にするトリックを紹介するよ:var io: std.Io = undefined; pub fn main() !void { var impl = ...; io = impl.io(); } ioをグローバル変数に入れとけば、アプリケーション内での色付けを心配しなくて済む。今、君の関数は青、赤、それとも緑?冗談はさておき、Ioインターフェースを使うのには明らかに抵抗があるけど、それは実際の非同期処理の摩擦とは質的に全然違うものだと思う。

でも、関数の色付けの根本的な問題はコンテキストだと思う。 それには反対だな。実際的な視点から見ると、問題はこうだと思う:

  1. コードが再利用できないのは、asyncキーワードが関数を静的に赤に色付けしちゃうから(例えば、PythonのブロッキングRedisクライアントとasyncio-redis)。Zigでは、IOを行いたい関数は青(非非同期)でも赤(非同期)でも、そのパラメータを受け取らなきゃいけないから、その観点から見るとIoの引数は関係ない。
  2. asyncとawaitを使うと、自動的にスタックレスコルーチンにオプトインしちゃって、それを防ぐ方法がない。新しいI/Oシステムでは、内部でasyncを使うライブラリを選んでも、ブロッキングI/Oを行うことができる。これが関数の色付けの本当の問題だと思う。

まあ、もうasync/sync/red/blueはなくなったけど、今はIO関数と非IO関数がある。 でも、色付けの問題は本当に解決されたわけじゃない。そうだね、でもI/Oを行う唯一の方法がIoインスタンスを使うことだったら、Ioは純粋(非Io)関数以外のすべてに影響を与えることになるから、Io関数を呼び出すことができるのは、Io関数を呼び出したくないコンテキスト以外では不可能になる。だから、ある意味で色の問題は軽減されてる。さらに、HaskellのIOモナドみたいなものも得られる(まあ、モナドはないけど、IOインターフェースはある)。悪くないけど、君の言う通りだね。次はZigがモナディックインターフェースを求めて、関数が一つの特別な引数だけ持てるようになって、それを隠せるようになるだろうね。

技術的には新しいエグゼキュータを渡すこともできるけど、それが本当に君が望んでいることなの?なんで新しくしなきゃいけないの?一つのエグゼキュータを使って、それをどこかのファイルでconstとして設定して、IOが必要なすべてのエントリーポイントでその一つを使えばいいじゃん!そうすれば、IOが下に伝播しなくなるよ。

ここで大事なポイントを見逃してるよ。Rustのライブラリを使うなら、async await、tokio、send+syncとか、いろいろ面倒なものが必要になる。同期APIなら、非同期アプリには役立たないし。このIOを渡すアプローチでこの問題が解決されるのが、まさにメインの問題なんだ。これで、ライブラリ内の関数のマルチバージョニングを実装するために、手続きマクロや他の面倒なことを使わなくて済む。結局うまくいかないしね。https://nullderef.com/blog/rust-async-sync/ こんなのは他にも50個くらい検索すれば見つかるよ。正直、協調スケジューリングや高パフォーマンス、オプションのスレッド・パー・コアの非同期がすぐに解決するとは思ってないし、APIもそんなに良くならないと思う。でも、将来的にはそれが解決されることを願ってる。

そのような関数を呼ぶには、コンテキストを提供する必要があります。Zigはこれを本当に解決していないけど、IOを必要とする各関数にIO実装を渡す必要がないから、もっと柔軟だよね。一度初期化関数に渡して、その後オブジェクトやモジュール全体でそのIO実装を使うことができる。これが良いスタイルかどうかは議論の余地があるけど、Zigの標準ライブラリは現在、初期化関数でアロケーターを受け取るコンテナを持っているけど、それは各関数で明示的にアロケーターを受け取る方向に変わっていくところだし。ユーザーは、初期化時にアロケーターを渡す動作を復元するための最小限のラッパーを書く自由があるよ。Odinは、各関数に暗黙のコンテキストポインタを渡す面白い解決策を持ってるけど、コンパイラがコンテキストにアクセスしない呼び出し関数のオーバーヘッドを取り除けるほど賢いかはわからない(すべての呼び出し関数を覗く必要があるからね)。理論的には、Zigの単一コンパイルユニットアプローチがその問題をより良く解決できるかもしれない。

何よりも、実装からの使用をうまく抽象化しているね。これはRustが壮大に失敗しているところだよ。もう少し詳しく説明してもらえる?君の言ってることがよくわからないんだけど。

Zigには一般的に好感を持ってるけど、グリーンスレッド(ファイバー、スタックフルコルーチンとも呼ばれる)に全力投球してるのを見るのはちょっと悲しいね。Rustは1.0の前にパフォーマンスが悪かったからRuntimeトレイト(ZigのIoに相当するもの)を廃止したんだ。言語やOSはこの教訓を何度も痛い目に遭いながら学んできた。

「90年代にファイバーがスケーラブルな同時コードを書くための魅力的なアプローチに見えたかもしれないが、ファイバーの使用経験やOS、ハードウェア、コンパイラ技術(スタックレスコルーチン)の進歩により、もはや推奨される手段ではなくなった。」 もしこれを進めるなら、Zigは「Goと同じくらいの速さ」で終わるかもしれない。本当のパフォーマンス競争者にはなれないだろう。少なくとも、パフォーマンスが重要なケースでは古いstd.fsが残っていてほしいな。

それが単なる選択肢の一つに過ぎないなら、「全力投球」とは言えないし、その選択はライブラリコードではなく実行可能ファイル内でされるんだよね。

実際、Rustがグリーンスレッドを取り除いて、汎用の非同期ランタイムに置き換えたのと同じような利点がある。ここでのポイントは、「非同期なものはIOなもの、IOなものは非同期なもの」ということ。だから、プラグイン可能な非同期ランタイム(tokioなど)を持つことを考えるのではなく、Zigはプラグイン可能なIOランタイムを採用している(これは「どのlibcのサブセットを使いたいですか?」に相当する)。どちらの動きでも、アイデアはランタイムを言語から取り除いてユーザースペースに移しつつ、共通のプラグイン可能なインターフェースを提供して、みんなが共通の基盤を持つことを目指している。

どうして私たちが「グリーンスレッドに全力投球している」と感じたのかは分からないけど、OPの記事ではスタックレスコルーチンに基づく実装を希望していると明確に言っている。

「パフォーマンスは重要だ。私たちはそれを忘れるつもりはない。」 もしファイバーが受け入れられないパフォーマンス特性を持っているなら、広く使われる実装にはならないだろう。この議論で話されていることは、スタックレスコルーチンが「汎用」Io実装を支えることを妨げるものではない。

グリーンスレッドがパフォーマンス悪いっていう主張に混乱してる。高い同時実行性を持つサーバーのトッププラットフォーム3つ(Go、Erlang、Java)は、グリーンスレッドを使ってるか、使う予定だよ。グリーンスレッドはCのFFIに制限があるから、低レベル言語(Rust)が使わないって理解してたけど。Rustも他の制約があるから、パフォーマンスの懸念があるかもしれないね。

僕は一般的にZigのファンなんだけど、グリーンスレッドに全力投球してるのを見るのはちょっと悲しいな。記事を読んでみて、IOインターフェースの実装を書けば、どんなアプローチでも使えるから、グリーンスレッドはそのうちの一つに過ぎないよ。

Zigみたいなシステム言語が、標準IO操作みたいな一般的なものにランタイムポリモーフィズムを要求するのはおかしい気がする。ほとんどの実用的なケースでは、具体的なIO実装が静的に分かるのに、なんでみんなにそのランタイムオーバーヘッドを強いるの?

Zigの哲学は、スピードよりもバイナリサイズを重視するってことだと思う。アロケーターも同じトレードオフがあるし、ArrayListUnmanagedはアロケーターに対してジェネリックじゃないから、すべてのアロケーションがダイナミックディスパッチを使う。実際には、ファイルのアロケーションや書き込みのオーバーヘッドは、間接呼び出しのオーバーヘッドを圧倒するだろう。バイナリサイズに関しては反論できないよね。(それに、誰かが言及する前に言っとくけど、デバーチャライゼーションは神話だから、すまん)

I/Oは、実際にはダイナミックディスパッチのオーバーヘッドがほとんど無視できる場所だと思う。もちろん、I/Oのターゲットによって異なるし、測定が必要だけど、「I/Oバウンド」(対して「CPUバウンド」)プログラムと呼ばれるのには理由がある。

なんでみんなにそのランタイムオーバーヘッドを強制するの? 一つのIOしか使わないシステムには、ダブルインダイレクションのコストを省くコンパイラ最適化があるはずなんだけど…でも、IOやってるんだから、他にボトルネックがあることが多いよね。追加のインダイレクションなんて、たいしたことないと思うけど。

ランタイムポリモーフィズムが悪いわけじゃないよ。タイトなループで分岐を導入したり、コンパイラがインライン化できるものを妨げたりする場合は悪いけど、他にも似たようなことがあるかもね。

io.asyncは非同期性を表現していて(操作が順不同で行われても正しい可能性)、この場合、コードが正しく動作するためには同時実行性が必要だ。これは私にとってのキーポイントだ。非同期イベントループの下にいるかどうかに関わらず、io呼び出しの順序がシーケンシングを意味しないことを指定できる。素晴らしい。非同期が何を意味するかを、io呼び出しが何をするかから分けて考えよう。

新しいioパラメータがあちこちに出てくるのはあまり好きじゃないけど、複数の実装(スレッドベース、ファイバーベースなど)ができるのはいいと思う。ユーザーに実装を知ってもらったり気にさせたりしないのも、アロケーターインターフェースみたいで良いね。全体的に見て、これは勝ちだと思う。特に、オーバーヘッドなしの標準ライブラリ実装があれば、シンクロナスでブロッキングなIO実装になるし。これはZigの「使わないものにお金を払わない」って考え方に合ってる。

「使わないものにお金を払わない」って神話じゃない? すごく小さいチームで規律があるなら別だけど、他の誰かが使うことになるし、その分お金を払うことになるよ。単に「io」を渡すのも、必要なところでIO関数を呼ぶより手間がかかるし。

うん、エフェクトシステムを実装してるんだね。彼らが確立された道を進んでいることを認識しているのかな?

これは、概念的に代数的効果を導入しているってことなのかな?例えば、渡されたioが効果ハンドラーで、スタックスイッチング(または他の非ブロッキング待機手段)を使って非同期性を実現するかどうかは効果ハンドラーの選択ってこと?

僕の考えでは、代数的効果は異なる種類の効果(異なる解釈を持つ)を指定できるようにするんだ。例えば、ファイルを読む、DBクエリを実行する、ネットワークアクセスする、みたいに。一つの「Io」効果だけで全てを許可するのとは違うんだよね。

この同じ概念は「sans io」と呼ばれていて、Rustでの使用について以前に議論されたことがあるよね。 https://www.firezone.dev/blog/sans-io https://sans-io.readthedocs.io/ https://news.ycombinator.com/item?id=40872020

関数の色付けがない解決策、めっちゃ好き!Zig 1.0が待ち遠しいよ。やっと、重労働なしで実際に読んで理解できるシステムプログラミング言語が来るんだ。実際、Zigについてあまり知らなくてもこのブログ記事を完全に理解できたよ。非同期Rustには何度も頭を悩ませたけど、結局諦めちゃった。

このデザインは、以前のデザインからの後退だと思う。以前はコンパイル時のイントロスペクションを使って、実際に非同期かどうか(呼び出し規約)をチェックできたからね。それに、未来を支えるメモリの管理をIoに委譲したくないし、syscallの塊やそれに関連するランタイムを渡して、全てをvtable経由でアクセスするのも避けたい。こういうのはコンパイル時にジェネリックだけで済ませたいんだ。