Zigの新しい非同期I/O
(kristoff.it)- Zig に新しい非同期 I/O インターフェースが導入され、I/O 実装方式を呼び出し側が直接選択して注入できるようになった
- 新たに設計された Io インターフェース は、非同期性と並列性 を同時にサポートし、コード再利用性と最適化に重点を置いている
- Blocking I/O、イベントループ、スレッドプール、グリーンスレッド、スタックレスコルーチンなど、さまざまな標準ライブラリ実装を提供予定
- 新しい API により、future のキャンセルやリソース管理、バッファリングや細かな入出力動作が可能になる
- 既存の関数カラーリング問題を解決し、1つのライブラリで 同期/非同期運用 の両方を最適化できるようになる
概要
Zig は最近、新しい非同期 I/O インターフェースを設計し、I/O 処理の柔軟性と並列性のサポート を重視する方向へ進化している。今回の変更では、従来の async/await パラダイムを分離し、実際のプログラム作成者がより多様な I/O 戦略を採用できるように設計されている。
新しい I/O インターフェース
従来は I/O 関連オブジェクトをコード内で直接生成して使用していたが、今後は Io インターフェースを呼び出し側が注入 する方式に変更される。
- この方式は Allocator パターンに似ており、呼び出し側が I/O の具体実装を選択して注入する
- 外部パッケージのコードにも一貫した方法で I/O 戦略を適用できる
主な変更点
- Io インターフェースは今後 並行性(concurrency) の処理も担当 する
- コードが並行性を正しく表現していれば、Io の実装に応じて 並列性(parallelism) の提供 も可能
コード例
- 並行性のない(直列の)コードと、
io.asyncとawaitにより並列化の可能性を表現したコードの2種類を比較- 直列コード: 2つのファイルに順番に保存し、並列化の機会を活かせない
- 並列コード: future を活用したファイル保存により、非同期イベントループでより効率的に動作
await と try の組み合わせ
awaitとtryを一緒に使うと、1つの future でエラーが発生した際に他の future のリソースを返却できない問題があるdeferとfuture.cancelによって、適切なキャンセルとクリーンアップを明示できる
Future.cancel API
Future.cancel()とFuture.await()は idempotent(何度呼び出しても副作用がない)- すでに完了した future に
cancelを呼ぶとリソースだけが解放され、未完了の処理はerror.Canceledを返す
標準ライブラリの I/O 実装
Io インターフェースはランタイム多相性ベースのインターフェースであり、独自実装もサードパーティパッケージの実装も利用できる。Zig の標準ライブラリは、さまざまな種類の I/O 実装を提供する予定である。
- Blocking I/O: 従来の C スタイルの blocking 入出力をそのまま利用し、追加オーバーヘッドはない
- スレッドプール: Blocking I/O を OS スレッドプールに分散し、一部の並列性を導入。ネットワーククライアントなどでは最適化が必要
- グリーンスレッド: Linux の
io_uringなどの非同期システムコールを活用し、OS スレッド上で複数の軽量スレッドを処理。プラットフォーム対応が必要(まずは x86_64 Linux) - スタックレスコルーチン: 明示的なスタックを必要としない状態機械ベースのコルーチン。WASM など一部プラットフォーム互換を目的とする。Zig コンパイラのプロパティブコンベンション再導入が必要
設計目標
コード再利用性
非同期 I/O の最大の課題はコード再利用性であり、他言語では blocking/async 関数が別々に存在するためコードが分断される問題がある。Zig の方式では
- 1つのライブラリが同期モードと非同期モードの両方を効果的にサポートできる
- async/await が「関数カラーリング」現象を取り除き、Io システムを通じてランタイムでもさまざまな実行モデルに依存しない
結論として、関数カラーリング問題を完全に解決する
最適化
- 新しい Io インターフェースは、非ジェネリックな vtable ベースの仮想呼び出し方式で実装される
- 仮想呼び出しはコード膨張を抑えられる一方、実行時にわずかなオーバーヘッドがある。最適化ビルドでは Io 実装が1つであれば de-virtualization(仮想呼び出しの除去)が可能
- 複数の Io 実装を使う場合は仮想呼び出しを維持する(コード重複防止のため)
バッファリング戦略
- 従来は各実装(reader/writer)がバッファリングを担当していたが、今後は Reader と Writer インターフェース層でバッファリングを行う
- バッファ flush 以外では仮想呼び出し経路を通らないため、最適化しやすい
意味論的 I/O 操作
Writer インターフェースは、特定の最適化操作のために2つの新しいプリミティブを提供する。
- sendFile: POSIX
sendfileに着想を得たもので、ファイルディスクリプタ間のデータ移動をカーネル内で処理する。メモリコピーを最小化 - drain: ベクトル化 write と splatting をサポート。複数のデータセグメントをまとめて送信でき、
writevシステムコールへ変換可能。splatパラメータにより最後の要素を繰り返し利用できる(圧縮などのストリームで活用)
ロードマップ
この変更の一部は Zig 0.15.0 から導入されるが、ライブラリの大規模な改修が必要なため、全面導入は次回リリースを待つ必要がある。SSL/TLS、HTTP server/client などの主要モジュールも新しい Io システム向けに再設計される予定である。
FAQ
Q: Zig はローレベル言語なのに、なぜ async が重要なのか?
- Zig は堅牢性、最適化、再利用性を志向している
- Non-blocking 入出力 を標準化することで、他ライブラリやサードパーティコードも全体の I/O 戦略に合わせて調整でき、再利用性を確保できる
Q: 今後、パッケージ作者はすべてのコードで async を活用しなければならないのか?
- いいえ。すべてのコードが並行性を表現する必要はない
- 一般的な逐次コードでも、ユーザーが選んだ I/O 戦略に合わせて動作する
Q: どんな実行モデルでもプラグインするだけで必ず正しく動作するのか?
- ほとんどの場合は yes
- ただし、コード上のプログラミングミス(例: 同時実行要件を満たしていない)では正常に動作しない
実行例とあわせて、非同期性と並列性の違い、正しい動作フロー設計の必要性にも触れている。
結論
Zig は新しい Io インターフェースの導入により、入出力戦略の選択の柔軟性、コード再利用性、最適化の可能性 を大きく高めた。これにより、非同期/同期ベースの関数記述の制約なしに、開発者は並行性・並列性の構造をより明確に表現し、各種プラットフォームや実行モデルにも効果的に対応できるようになる。
1件のコメント
Hacker Newsのコメント
この点をもう一度指摘したい。記事では Zig が function coloring 問題を完全に解決したかのように述べているが、私は同意しない。有名な "What color is your function?" の 5 つのルールを改めて考えると、Zig では async/sync/red/blue のような色分けはないとしても、結局は IO 関数と非 IO 関数の 2 つのケースしか存在しない。関数の呼び出し方が色によって変わる問題は技術的に解決したとしても、依然として IO が必要な関数には IO を引数として渡す必要があり、不要な関数は受け取らない。結局、本質は変わっていないように感じる。IO 関数は IO 関数からしか呼べず、これも coloring 問題から抜け出せていない。もちろん新しい executor を渡すこともできるが、それが本当に望んでいるものなのかは疑問だ。Rust でも同じようなことはできる。色付きの関数呼び出しが煩雑だという点も同じだ。いくつかの中核ライブラリ関数が colored である、という部分は Zig/Rust のどちらにも当てはまらない。coloring 問題の本質は、コンテキスト、つまり async executor や auth、allocator などを必要とする関数が、呼び出し時に必ずそのコンテキストを提供されなければならないことにある。Zig が本当にこの部分を解決したとは言いがたい。ただ、Zig の抽象化は非常によくできていて、Rust にはこの点で不足がある。しかし function coloring 問題そのものは依然として残っている
典型的な async function coloring との核心的な違いは、Zig の
Ioが単に非同期処理のための特別な値ではなく、ファイル読み込み、スリープ、時刻取得など、あらゆる IO のために必然的に必要な値だということだ。Ioは関数の属性ではなく、どこにでも置ける普通の値だ。実際、この特性のおかげで coloring 問題は解決されたように見える。ほとんどのコードベースでは IO はすでにどこかのスコープにあり、本当に純粋な計算関数だけが IO を必要としない。もしある関数が突然 IO を必要とするようになっても、多くの場合はmy_thing.ioからそのまま取って使える。Rust のようにすべての関数に Allocator を渡す必要がないので煩わしさがない。つまり、コードパスが変わって IO を行う必要が生じても、関数ごとに変更を波及させる必要なくその場で使える。原理的には function coloring が残っているという点には同意するが、実際にはほぼすべての関数が async-colored になったようなものなので、実用上の問題はほとんどない。実際、Zig 開発者たちは Allocator を明示的に渡すことが function coloring 的な煩わしさを生むとは考えていない。Ioも同様に大きな問題にはならないと思う重要なポイントが触れられていない気がする。Rust ライブラリを使うときは、必ず async/await、tokio、send+sync といった条件を満たさなければならず、sync API だと async アプリでは役に立たないのが現実だ。一方で、Zig の IO 受け渡し方式はこの問題を根本から解決する。そのおかげで、苦労して procedural macro やマルチバージョンを無理やり実装する必要がなくなるし、実際そうした方式自体がライブラリのマルチバージョン問題をうまく解決できているわけでもない。Rust における async/sync 混在問題についてはさまざまな議論があり、次のリンクにも説明がある https://nullderef.com/blog/rust-async-sync/。今後 Zig が cooperative scheduling、高性能 async、スレッド・パー・コア async のような部分までうまく解決してくれることを願う
私は圏論の専門家ではないが、結局こうしたコンテキスト管理の道を進んでいくと IO モナドにたどり着く。この文脈では Context は暗黙的に存在することもできるが、コンパイラの助けをきちんと得るにはシステムの中で実体として表に出す必要がある。そしてシステムプログラミング言語の野望はみな Async やコルーチンの墓場に埋もれてきたが、Andrew がある意味で IO モナドを再発見してきちんと実装したのは世代の希望だ。現実世界の関数には色がある。明確な移動ルールを設けるか、あるいは C++ の
co_awaitや tokio のようにどんどん複雑になる道に進むしかない。これこそが "The Way" だと思うすべての関数を赤くする(あるいは青くする)簡単なトリックがある
ioをグローバル変数にして使えば coloring を心配する必要はなくなる。冗談ではあるが、確かにIoインターフェースを使わなければならないという意味で多少の摩擦はある。ただ、これは async/await を使うときに生じる実質的な friction とは本質的に別の問題だ。私の見るところ、function coloring 問題の核心は、async キーワードによる静的な色付けのためにコード再利用が不可能になることだ。Zig では関数を async にするかどうかにかかわらず IO を引数として受け取るので、その観点では coloring 自体が無意味だ。第二に、async/await を使うとスタックレスコルーチン、つまりコンパイラが制御するスタック切り替えを強制されるが、Zig の新しい IO システムは内部的に async を使っていても Blocking IO として動作させることができる。こういう点こそが実質的な function coloring 問題だと思うGo も「微妙な coloring」問題を抱えている。goroutine を使うときは、キャンセル処理のために常に context 引数を渡す必要があり、多くのライブラリ関数も context を要求するため、コード全体が汚染される。技術的には context を使わなくてもよいが、
context.Backgroundを適当に渡すのは推奨される方法ではないsans-io という概念は Rust などで以前から議論されていて、参考リンクは https://www.firezone.dev/blog/sans-io、https://sans-io.readthedocs.io/、https://news.ycombinator.com/item?id=40872020 だ
私は function coloring の問題点は、スタック上で処理しようがスタックを unwind しようが、結局どちらか一方が残ることにあると思う。Zig は coloring 問題の解決を主張しているが、IO 実装として依然として blocking/thread pool/green thread を使えるようにしている。しかし、こうした blocking IO はそもそも問題ではなかった。グローバル状態を使わない慣習を守れば、ほぼすべての言語でこの程度のことは可能だ。stackless coroutine はまだ未実装で、「残りの部品を描けば完成」みたいな感じだ。もし本当に汎用的な関数呼び出しを望むなら、方法は 2 つあると思う
すべての関数を async にして、引数の 1 つで同期実行するかどうかを決めるようにする(性能低下あり)
各関数を 2 回コンパイルして状況に応じて選んで呼び出すようにする(コードサイズ増加と関数ポインタ処理の難しさがある)
私はコアチームではないが、ユーザーや実利用者が semiblocking 実装を十分に試し、API を安定化させたあとで、まさにその解法、つまりスタックジャンプベースの本物のコルーチン挿入を適用する計画だと聞いている。現在の LLVM のコルーチン状態機械コンパイラには libc や malloc に依存する問題がある。Zig の新しい io インターフェースは userland async/await をサポートしているため、将来きちんとした frame jumping ソリューションが入っても移植しやすく、デバッグもしやすい。コルーチンが難しければ io API も小さな修正で持ちこたえられるようにしてあり、stackless coroutine を急ぎすぎるつもりはない
C#/.NET の
ValueTask<T>も似た役割を果たす。同期的に終わればオーバーヘッドがなく、必要なときだけTask<T>として使える。コードは通常awaitしておけばよく、実行時にランタイムやコンパイラが同期/非同期を自動で選ぶZig は好きだが、green thread(fiber、stackful coroutine)に集中しているのを見ると少し残念だ。Rust も 1.0 以前に似たような Runtime trait を性能問題から廃止している。実際、OS、言語、ライブラリはこうしたアプローチの弊害を何度も学んできており、関連資料もある https://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p1364r0.pdf。fiber は 90 年代にはスケーラブルな並行処理として注目されたが、現代では stackless coroutine や OS/ハードウェアの進化などにより推奨されなくなっている。もしこのまま進むなら Zig は Go に近い性能の限界にぶつかり、本当の意味での高性能競争相手にはなれないだろう。
std.fsには性能が必要なケースで残っていてほしい私たちが green thread(fiber)に「全振り」しているという印象は誤解だ。元記事でも stackless coroutine ベースの実装を期待していることが明示的に述べられており、関連提案もある https://github.com/ziglang/zig/issues/23446。性能は重要であり、fiber が性能面で期待外れなら汎用的には使われないだろう。この記事で議論されている内容は、stackless coroutine がデフォルトの
Io実装になることを妨げないgreen thread の性能が悪いという主張には疑問がある。主要な並行サーバープラットフォームである Go、Erlang、Java はいずれも green thread を使っている、あるいは使おうとしている。green thread は C FFI との互換性の問題から、より低レベルな言語、たとえば Rust などでは適さないかもしれないが、性能そのものが常に問題だとは言い切れない
複数の選択肢の 1 つにすぎないのだから、「all-in」とは見なせないと思う。どの実装を選ぶかは実行ファイル側で決まり、ライブラリコード側では決まらない
Rust が green thread を削除して async runtime に置き換えた選択と似た効果を、Zig も狙っている。核心は
async=IO, IO=asyncという直感を公式化したことだ。Rust は tokio などの pluggable async runtime を提供し、Zig は pluggable IO runtime を提供する方向だ。結局のところ、言語からランタイムを切り離し、ユーザー空間で差し込めるようにしつつ、皆が共通インターフェースを共有するのが方向性だ資料(P1364R0)は論争的だったし、私は特定のアプローチを排除するために動機づけられた主張だと思う。議論資料としては https://old.reddit.com/r/cpp/comments/1jwlur9/stackful_coroutines_faster_than_stackless/、https://old.reddit.com/r/programming/comments/dgfxde/fibers_arent_useful_for_much_any_more/f3bmpww/ なども参考になる
Zig のようなシステム言語で、一般的な標準 IO 操作にまでランタイム多相性を強制するのはやや不自然に感じる。ほとんどの実運用では IO 実装は静的に確定できるのに、なぜランタイムオーバーヘッドを強いるのか疑問だ
IO における動的ディスパッチのオーバーヘッドは、実際にはほとんど無視できる程度だと思う。IO の対象によっては違うだろうが、結局 IO が CPU ボトルネックではない場合のほうがはるかに多い。だからこそ IO-bound と呼ばれる
「なぜ全員にランタイムオーバーヘッドを強制するのか?」という問いに対しては、ほとんど 1 種類の io しか使わないシステムでは、コンパイラが double indirection(間接参照)のコスト自体を最適化して消す意図なのだと思う。そして IO はどうせ別のボトルネックがあるので、参照が 1 段増える程度の負担はほとんどない
Zig の哲学としてはバイナリサイズをより重視する傾向がある。Allocator にもまったく同じトレードオフがあり、たとえば
ArrayListUnmanagedは allocator について generic ではないので、各割り当てごとに dynamic dispatch が発生する。実際には、ファイル割り当てや書き込みのコストが間接呼び出しのオーバーヘッドをはるかに上回る。こういうバイナリサイズへのこだわりが Zig らしさだ。ちなみに devirtualization(動的呼び出しを静的に置き換える最適化)は迷信だランタイム多相性それ自体が本質的に悪いわけではない。tight loop で分岐が増えるとか、コンパイラがインライン最適化をできないとか、そういう状況でなければ問題にはならない
新しい io パラメータがあちこちに露出するのはあまり好きではないが、複数の実装(thread ベース、fiber ベースなど)を簡単に使え、しかもユーザーに実装を強制しない点(Allocator インターフェースのように)はとても気に入っている。全体としてかなりの改善だし、複数の stdlib 実装の中に追加オーバーヘッドなしの同期/ブロッキング io 実装が提供されるなら、「使わないものにコストを払わない」という Zig の哲学にもそのまま合致する
Zig では
io.asyncは非同期性、つまり処理順序が保証されないかもしれないが結果は正しい、ということを表すだけで、並行性を意味するわけではない。つまり async と io 呼び出しの意味を分離したことが核心だ。この設計は非常に賢いと思うIO インターフェースのおかげで、言語レベルの vfs(Virtual File System)を作れる点が気に入っている
Zig を学ぶために簡単な ssh サーバーを作ってみた。今回の IO/イベントループ構造のおかげで、コードの流れをずっと理解しやすくなった。Andy に感謝したい
記事がとてもよく書けていて、非常に興味深く読んだ。特に WebAssembly への示唆が楽しみだ。WASI を userspace で使うこともできるし、Bring Your Own IO も可能な構造だなんて、本当に面白いと思う