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

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

概要

Zig 0.16.0で導入される 新しいstd.Io非同期I/Oプリミティブ の概要と使い方を紹介。 async/await、キャンセル、リソース管理 などの基本APIを例を通じて解説。 スレッドベースのIo実装 を使いながら、同期・非同期処理の違いを体験。 エラー処理やリソースリーク対策 の実践的なパターンも説明。 今後のZigアプリ開発で役立つ 標準的な非同期I/O設計 のヒントを提供。


Zig 0.16.0の新しいstd.Io非同期I/O入門

  • Zig 0.16.0で std.Ioの非同期I/O API が刷新
  • 3-4ヶ月後リリース予定、先行してZigtoberfest 2025でデモ実施
  • async/awaitやキャンセル、同期API など、全Zigコードで利用可能なコアAPIを解説
  • まずは 基本から順に発展的な非同期処理 へステップアップ

例0:基本的な同期処理

  • Zigでの 「Hello, World!」的な同期I/O例
  • doWork()関数で1秒スリープしつつ 標準出力に文字列表示
  • 非同期処理は未使用、 I/Oの基本動作の確認

例1:Ioインスタンス導入

  • std.Io実装(Threaded)をmainで初期化
  • Allocatorパラメータ もセットアップし、推奨設計パターンを紹介
  • I/Oやアロケーションが必要なコードはパラメータで受け取る 設計
  • まだ 非同期処理は未使用、I/O実装のセットアップに注力

例2:async/awaitの基本

  • io.async/doWorkで非同期タスク生成
  • future.await(io) で結果を待つ
  • 呼び出しと復帰を分離 できるのがasync/awaitの意義
  • ただし、即座にawaitしているため 挙動は同期と同じ

例3:並列実行の表現

  • 2つの非同期タスクを同時に起動
  • 各タスクに異なる引数(flavor_text)を付与
  • awaitを順次呼び出しても、実際は同時進行
  • 1秒で2つの作業が完了、非同期I/Oのメリットを実感

例4:エラー処理とリソースリーク

  • doWork内で条件によりエラー(OutOfMemory)発生
  • try a.await(io) でエラー発生時、 b.await(io) がスキップされる
  • その結果、 リソースリークが発生 し、デバッグアロケータが検出
  • 典型的な非同期エラー処理の落とし穴

例5:リソースリーク対策の基本形

  • awaitの結果をすべて受け取ってからtryでエラー判定
  • 全タスクのリソース開放が保証される
  • エラーは適切にハンドリング可能、 ただし記述が冗長でフットガン

例6:キャンセルによる最適化と安全性

  • deferでタスクのキャンセル(a.cancel(io))を自動実行
  • エラー発生時に即座にキャンセルが走り、リソースリーク防止
  • cancelはawaitと同じAPIで、両者は冪等
  • 各I/O実装ごとにキャンセルの挙動が定義される

例7:リソース管理の実践例

  • doWorkが成功時にヒープ上に文字列を返却
  • defer if (a.cancel(io)) |s| gpa.free(s) で確実にリソース開放
  • 成功/失敗問わず、リソース管理を簡潔に記述
  • 標準的なtry/returnスタイルで記述可能

例8以降:非同期=並行ではない

  • asynchrony(非同期)はconcurrency(並行)と異なる概念
  • キューやプロデューサ・コンシューマパターン など、現実的な非同期制御例も今後紹介予定

まとめと今後

  • std.Ioの新APIで、より安全かつ効率的な非同期I/Oが可能
  • キャンセルやリソース管理の仕組み により、従来の落とし穴を回避
  • async/awaitの活用で、表現力豊かなZigコード が書ける
  • 今後のZig 0.16.0のアップデートに期待

参考資料

Hackerたちの意見

アンドリュー・ケリーは僕のお気に入りの技術スピーカーの一人で、Zigは素晴らしいアイデアが詰まってる。彼はオープンソースプロジェクトのリーダーとしてもいいお手本みたいだね。

Zigは素晴らしいアイデアでいっぱいなだけじゃなくて、捨てられたアイデアの墓場もあるし、すぐに却下されたアイデアの宇宙もあると思う。Zigは、うまく組み合わさる素晴らしいアイデアがたくさん詰まってるよ。

これが完全にオプションであってほしいけど、現実的にはそうならないだろうな。Rustみたいに、オプショナルな非同期サポートがある言語は、非同期がエコシステム全体に浸透しちゃうからね。色付きの関数があると、そうなるのは予想通りだよ。標準ライブラリはそこまで悪くないけど、前にチェックしたときは、実際にはブロックしないのに非同期関数がたくさんあったんだ。非同期は多くの人にとってうまく機能してるのは分かるし、スレッドを理解できない人が非同期を好むのも理解できる。生産的になれるパターンがあるのは素晴らしいよね!でも、僕には非同期はどうしても合わない。使うのが不安で、もう10年以上試行錯誤してるけど、結局うまくいかないかも。スレッド、ミューテックスロック、チャネル、Erlangスタイルの並行処理、ナーサリー、非同期以外の何でもの方がずっと楽だな。これらは全部理解できるし、実際にそれらを使ってプロダクションシステムも作ったことがある。Zigが1.0に達したときには使えるようになってるといいな。今月初めに学び始めたけど、使うのがすごく楽しいよ。

スレッドを理解できない人が非同期を好むのも理解できる。それはお互いに独立してるよ。スレッドがあってもなくても非同期は使えるし、非同期があってもなくてもスレッドは使える。

同意だな、非同期は本来あるべきよりも人気がありすぎる。少なくとも(私の理解では)Zigは関数に色を付けないから、ブロッキングと非同期ライブラリの間に大きなエコシステムの亀裂は生まれないだろうね。

スレッドの方がずっと楽だな。動画の最初の数分で示されたサンプルコードは、実際には非同期コードを実行するために通常のOSスレッドを使ってるよ ;) これ全体はZigのアロケーター哲学にかなり似てる。アプリケーションがライブラリに渡すためにルートアロケーターを選ぶのと同じように、IOの実装も選んで渡すんだ。ライブラリはIOシステムが非同期をどう実装してるかは気にしないで、アプリケーションから渡されたIO実装を呼び出すだけなんだ。

同じく。asyncは好きじゃないんだ。コードの各行の前に「await」を付けるのが面倒で。最近は(JSで)ワーカースレッドやメッセージパッシング、"atomics" APIを使って遊んでる。並行処理の利点を得られるし、async/awaitの煩わしさがないからね。

スレッドのことは理解してるけど、特定のことにはasyncを使うのが好きなんだ。もしスレッドを使ったウェブサービスがあったら、リクエストごとにスレッドプールの一つのスレッドに割り当てるのかな?IOの多重化がOSスレッドなしでできるのに、OSリソースを無駄にしてる気がする。> 最後にチェックしたとき、crates.ioの多くは実際にはブロックしないもののためのasync関数で埋まってたよ。具体的には?大きなファイルのファイルI/Oは遅いデバイスではブロックするから、asyncのtarball処理には使い道があるよね。sans-IOスタイルで書いて、その上にスレッドやasyncを薄い層として乗せるのがベストだと思う。でも実際には、そこそこ使えるsans-IOコードを書くのは、そこそこ使えるasyncを書くより難しいと感じる。HTTPライブラリのような深い間接依存には理にかなってるけど、アプリにはあまり合わないかな。

スレッドを理解できない人の気持ちは完全にわかるし、asyncを好む人もいるよね。これは奇妙な発言だね。async/awaitは「スレッドが理解できないときのためのもの」じゃなくて、全く別の概念だよ。例を挙げると、JavaScriptにはasync/awaitがあるけど、全てがシングルスレッドで、並列処理はない。async/awaitは基本的にコルーチン/ジェネレーターの下にあるものだよ。「スレッドが理解できない人のためのもの」と言うと、まるでasyncの仕組みを学んでないことに不安を感じているみたいに聞こえるし、ただ座って学ぶ代わりに補おうとしてるみたいに聞こえる。asyncはスレッドやファイバーよりも並行処理を表現するための複雑なモデルかもしれない。それを言うのはいいけど、学んでないのも問題ないし、片方をもう片方より上に置くのはおかしいよ。> stdlibはそんなに悪くないけど、最後にチェックしたとき、crates.ioの多くは実際にはブロックしないもののためのasync関数で埋まってたよ。例を挙げてもらえる?最後にRustを使ったときはそうは思わなかったけど、最近はあまり使ってないんだ。

あなたのasyncに対する問題は、並行性と並列性を混同してることのように聞こえるね。

期待しなくていいよ。関数の色を避けて、IOがasyncかどうかに依存しないライブラリを書くことが、この新しいIO実装の最優先事項の一つなんだ。async/awaitを使いたくないなら、io.asyncを通して関数を呼び出さなければいいだけだよ。

スレッドを理解できない人がasyncを好む Wow。そんなコメントの後に誰かが読み続けると思う?

io.async/io.concurrentなしでシングルスレッド版を書くことができるほどオプショナルだけど、I/Oをやりたいならioパラメータを渡す必要があるよ。ここで「async」と呼ばれているものを、他の言語がasync/awaitと呼ぶものと混同してるよ。全く異なる概念なんだ。この文脈でのasyncは、「この関数をバックグラウンドで実行するけど、できなければ今すぐ実行する」って意味なんだ。

22:06に笑いすぎて椅子から落ちそうになったよ :D

壊れた音声のせい?

しばらくZigを触ってないな、Cの世界に留まってる。クリーンアップ属性(Cのための安価な「defer」)や、サニタイザー、静的解析ツール、ハードウェアレベルでのメモリ安全性のためのメモリタグ拡張(MTE)などがあって、Zig 1.0はまだ数年先だろうし、今の時代にZigに時間をかける強い理由って何だろう?再挑戦すべきか迷ってるんだ。

僕の意見としては、もっと役立つ標準ライブラリが必要だね(今は一番不安定な部分だけど、標準ライブラリのデザイン決定には全て同意してるわけじゃない - でもCのほとんど役に立たない標準ライブラリよりはずっとマシだし - C++の標準ライブラリについては話し始めたら止まらないけど、笑)。統合ビルドシステムとパッケージマネージャー(純粋なC/C++プロジェクトにも最適)、そしてコンパイル時にできること(ストレートなリフレクションやジェネリクスなど)もいいね。Cで慣れ親しんだ小さなデザインの欠陥をたくさん修正してくれるし(C標準でもゆっくり進行中だけど、Zigは今すぐにその修正がある)。それに、C++以外でCコードとの統合が一番いいと思う。例えば、ハイブリッドC/Zigプロジェクトは普通のユースケースで、ほとんど摩擦がない。Cは僕にとってなくならないけど、Zigをいじるのはもっと楽しいよ :)

確立されてない言語、ましてやまだ安定してない言語が、10年やそれ以上メンテナンスするプロダクションコードを書くための強いセールスポイントを持つとは思えないな(もちろん、Cほど確立されてない言語もプロダクションアプリで使われてるけど、Zigみたいなのはみんなに合うわけじゃない)。でも、ちょっと遊びや勉強のために言語を学ぶつもりなら、ZigはCに比べていくつかの大きな「内在的」な利点があると思う。* 表現力が高い(C++と同じくらい表現力があるし)、それでいてとてもシンプルな言語(数日で完全に学べる)。* クロスコンパイルのツールがすごい。* 空間的なメモリ安全性だけでなく、タグ付きユニオンのような形で、他の未定義の動作からの保護も提供してくれる。

人々がasync IOを試すとき、いつも苦労してるように見える。例えば、Rustが所有権の背後にあるスタックベースのパラダイムを崩すから、そこでRustは死ぬんだ。以前は、aio Pythonでウェブサーバーのような小さなアプリを書くのが楽しかったけど、メッセージキューやウェブソケットが関わると特に面白かった。でも、普通の作業にはgunicornを使った方がいい。問題は、従来のasync I/Oソリューションはすべてシングルスレッドで、今の時代にデスクトップに16コアのマシンがあるのに、それは意味がない。チェスのゲームを始めるのに、キング以外の駒を全部捨てるようなもんだ。Javaや.NETのような流行遅れの言語は、質の高いマルチスレッドのランタイムを持ってるから、同時実行性と並列性を管理するための単一のパラダイムを提供してくれる。

Project Loomは特にJavaをとても良くしてくれる。仮想スレッドは、基盤のOSスレッドをブロックせずに「ブロック」できる。コールバックは全く必要ないし、構造化された同時実行性を使って、GoやErlangのようなパターンを実装することもできる。(Clojureから使ってるけど、core.asyncの「スレッド」バージョン(つまりGoスタイル)チャネルと相性がいいんだ。)

Javaや.NETのような流行遅れの言語は、質の高いマルチスレッドのランタイムを持ってるから、同時実行性と並列性を管理するための単一のパラダイムを提供してくれる。実際には、同じスループット、レイテンシ、またはメモリ使用量を提供できない代償を払っているけど。エンジニアリングはトレードオフについてだけど、Javaや.NETがこれを解決したかのように振る舞うのはナイーブだよね。

動画をよく見ていれば、このデザインがioインターフェースによってコードをパラメータ化していて、プラグイン可能な実装を可能にしていることに気づくはずだ。このスタイルで正しく書か

まだ実際には現場で見たことはないけど、いろんなスタジオのエンジニアの技術的な話で聞いたことがある。伝統的なメインスレッドがなくなるデザインに興味があるんだ。代わりに、すべてがジョブになって、メインスレッドとされるものもオーケストレーションスレッドじゃなくて、ただのワーカーになってる。通常は、スレッドを十分に用意して、ロックなしで作業を奪い合うワーカースレッドとして機能するんだ。従来の非同期プログラミングは、重要なメインスレッドに依存しすぎてると思う。でも、それが成功しすぎたせいで、残念ながら私たちはそれにずっと縛られることになるんじゃないかな。キャッシュに優しくない伝統的なオブジェクト指向プログラミングに何年も苦しめられてきたことを思い出すよ。

ネットワークコードをビジネスロジックからどれだけ切り離すかによるのは分かってる。問題はその程度なんだよね。十分なのか、それともただ痛みを和らげるだけなのか?ビジネスロジックに完全なメッセージを渡すか、ストリームを送ると、所有権の流れがずっとクリーンになる。ユニットテストも書きやすくて、何よりメンテナンスが楽になる。変更を避けるために偏った判断をする開発者をたくさん知ってるけど、悪いユニットテストと衝突することになるのを避けて、「私たちのテスト戦略は完璧だ」って言うんだよね。議論するよりも見せる方が簡単だけど、デモを受け入れるにはオープンマインドが必要だよ。

Zigの方向性が混乱してる気がする。シンプルな言語を目指してるのか、複雑なものを目指してるのか?低レベルなのか高レベルなのか?この機能は、高レベルと低レベルの機能が奇妙に混ざっていて、かなり複雑だと思う。IOインターフェースはOOっぽいけど、リスコフの置換原則を破ってる。私にとって、これは関数の色問題を解決するものではなく、むしろ隠しているだけだ。IOインターフェースを持つ関数は、IOパラメータの入力との予期しない相互作用のために、ローカルで推論できない。特に、IOオブジェクトがライブラリの境界を越えて共有されるとき、これは特に厄介だ。内部コードとそのオブジェクトを共有する場合、ライブラリが内部でIOをどのように管理しているかを理解する必要がある。あるコンテキストで動作したコードが、別のコンテキストでは驚くほど動作しないこともある。ライブラリの作者として、期待通りに動作しないIOオブジェクトをどう扱うべきか?この問題を言語レベルで解決しようとするのは根本的に間違ってる気がする。IOのような広範なもののすべての潜在的な使用ケースを事前に予測することはできないから。だから、この方向性を探るべきでないとは言わないけど、もし私のプロジェクトなら、これを別のパッケージに分けて、標準とは呼ばないだろうな。

アロケーターを受け取る関数についても同じことが言えるんじゃない?

これは、他のほとんどの言語が使うasync/awaitの意味でのものではないことに注意する価値がある。別の言語では、コンパイラがasync関数を見ると、それを状態機械や「コルーチン」にコンパイルして、関数がawaitでマークされた指定されたポイントで自分自身を一時停止し、後で再開できるようにする。Zigでは、コンパイラはコルーチンをサポートしていたが、これが削除された。新しいデザインでは、asyncawaitは単なる関数だ。デモで使われているスレッド実装では、awaitは操作が完了するまでスレッドをブロックするだけだ。公平を期すために、投稿の最後には、他に2つのIO実装が計画されていることが説明されている。一つは「スタックレスコルーチン」で、これは従来のasync/awaitに似ている。しかし、これまでの議論からすると、ちょっとバポーワーのように見える。 [1]で議論されたように、andrewrkは通常のasync/awaitキーワードを(再)追加するアイデアを明示的に拒否し、代わりに異なるデザインを望んでいる。23446の問題で追跡されている。しかし、23446の問題では、この機能がどのように機能するか、従来のasync/awaitをどのように改善するか、または関数の色付けをどのように回避するかについての合意がゼロのようだ。計画されているもう一つの実装は「スタックフルコルーチン」で、私が見た限りでは、これにはより計画があり、より有望だが、重要な未知数がある。このデザインの基礎は、グリーンスレッドやファイバーに似ている。低レベルのコード生成は、通常の同期コードと同じで、状態機械の変換はない。代わりに、ライブラリがネイティブのレジスタ状態とスタックを入れ替えることで一時停止を実装する。これは、OSスレッド間の切り替え時にOSカーネルが行うことと同じだ。これ自体は、CのライブラリやGoのような言語のランタイムで以前に何度も実装されている。しかし、スタックをどれだけ割り当てるか分からないという重要な制限がある。事前にスタックを多く割り当てすぎると、OSスレッドとあまり変わらなくなるが、少なすぎるとスタックオーバーフローが簡単に発生する。Goは、必要に応じてスタックのチャンクを割り当てることでこれに対処しているが、それでもコストと動的割り当てへの依存が生じる。andrewrkは、代わりにコンパイラが関数とその呼び出し先が一時停止中に保存する必要がある状態の量に基づいて、必要なネイティブスタックの最大量を計算することを提案している。この場合、スタックはぴったり合うようにサイズを調整できる。ある意味、これはRustのasyncに似ていて、コンパイラがasync関数オブジェクトのサイズを計算する。Zigのアプローチは、asyncを特別扱いするのではなく、すべての関数呼び出しに適用される。その結果、利益はasyncコードのメモリ使用量を超えて広がる。コンパイラは、スタックオーバーフローがないことを静的に保証するので、この機能を使用するすべてのコードの信頼性が向上する。これは特に、通常、高い信頼性が求められ、利用可能なメモリが少ない組み込みシステムで役立つ。今、組み込みでは、GCCの機能(「-fstack-usage」)を使って、似たような計算を行うことがあるが、面倒すぎて人々はしばしば気にしない。だから、これをZigのファーストクラス機能にするのは素晴らしいことだと思う。でも、スタック使用量計算機が一般的でない理由がある。スタック使用量を静的に制限したいなら、まず再帰を禁止する必要がある。さもなければ、関数が再帰する回数を追跡するための何らかの言語メカニズムを追加しなければならない。再帰を禁止するのは、組み込みコードでは一般的だが、ほとんどのコードベースにはかなり面倒だ。再帰を追跡するのは確かに可能で、AgdaやCoqのような証明言語が再帰関数の終了を証明させることを示しているが、そういう言語には「普通の」言語にはない多くのツールがあるから、Zigでそのような機能がどれだけ使いやすいかは不明だ。この問題[2]では、どのように機能するかについて具体的な議論があまりない。次に、動的呼び出し(つまり、関数ポインタへの呼び出し)を禁止する必要がある。なぜなら、呼び出す関数が分からなければ、どれだけのスタックを使うか分からないからだ。これは、静的に知られている関数のセットのみを参照できる「制限された」関数ポインタ型を提案する[3]で、より具体的な設計の対象となっている。しかし、これがどれだけ使いやすく、組み合わせ可能かはまだわからない。少し引いて見ると、個人的には、Zigが他の言語と同じasync/await機能をコピーするのではなく、これらのことを試す意欲があるのは嬉しい。未開発の本当の可能性がある。逆に言うと、今日動作しているのは「async」と「await」が関数名に含まれているスレッドベースのI/Oライブラリだけなのに、勝利を主張するのは少し早いように思える。高性能な実装がどのように機能するかもわからないのに、I/Oライブラリのデザインを確定させるのは早すぎる気がする。とはいえ、スレッドI/Oでうまくいくアプリケーションも多いし、それを真剣な選択肢として受け入れる現代的なI/Oライブラリデザインを見るのは嬉しい。

再帰が何回可能かを追跡すること。 > 再帰を追跡するのは確かに可能だよ。AgdaやCoqみたいな証明言語が再帰関数の終了を証明させることからも分かる。証明言語は関数が再帰する回数を追跡するわけじゃなくて、最終的に終了することだけを気にしてる。再帰のステップ数は入力によって簡単に変わることもあって、関数が定義される瞬間には未知のままだよ。

うわ、例7はちょっと混乱するね。戻ってくる文字列がawaitとcancelの両方で得られる。これはzigの「隠れた制御フローなし」という原則に違反してる気がする。違反してないっていうのも分かるけど、やっぱり違反してる感じがする。でも、非同期コードでその原則の精神をどうやって保つのかは見当がつかないな。

これはzigの「隠れた制御フローなし」という原則に違反してる気がする。ここでのホットテイクは、非同期の仕組み全体が隠れた制御フローだってこと。単純なコールバックが「ウェブスケール」の方法として持ち上げられて以来、気づいた人もいるよ。コールバックの実行やキャンセルのシーケンスは、メインの制御ロジックと並行して動く隠れた暗黙の制御フローを形成する。スレッドよりもデバッグや管理が難しいこともある。でも、Zigが独自のスケジューラーを持つランタイムを追加してバイトコードVMに変わらない限り、あまりできることはないと思う。コルーチンやグリーンスレッドはCやC系の言語で以前に実装されてきたけど、Zigとその哲学にどれだけ簡単にフィットするかは分からないな。

個人的にはこれすごくクールだと思う。一つ気に入ってるのは、他の言語(例えばRoc)で見られる「プラットフォーム」概念が、Zigの「隠れた制御フローなし」というマントラに沿った形で固定されてること。結果的に、自分のコードがフックできる非POSIXのioを作るのが普通になるんだ。ゲームエンジンを書く?ユーザーがスクリプトに注入した効果的な関数のセットとやり取りできるようにするんだ。プラットフォームライター(ゲームエンジンのような)として、基本的にサンドボックスを作ることができる。欠けている部分は、任意のextern C関数を呼び出すアクセスを制御することかも。もしかしたら、その能力はioによって提供されて、呼び出すコードが何をするかについての確実な保証を作る必要があるかもしれない。(デバッグプリントは別の制御されていない効果だね。)