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

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

2025年7月13日原文(kristoff.it)

概要

  • 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が残っていてほしいな。

Hacker Newsで議論の続きを見る