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

Zigをネットワークプログラムを書くためのお気に入りの言語にした方法

概要

  • Zig言語 での開発体験と Zioライブラリ の紹介
  • Chromaprintアルゴリズム のZig実装をきっかけにZigに興味
  • 非同期I/OGoスタイルの並行処理 をZigで実現
  • Zioは 高速・シンプルな非同期処理協調的ランタイム を提供
  • 今後は NATSクライアントHTTPライブラリ のZio対応を計画

Zig言語との出会いと学習動機

  • Zig言語 は元々オーディオソフトウェア開発向けに設計された低レベル言語
  • Chromaprintアルゴリズム のZig実装をAndrew Kelleyが行ったことにより興味を持つ
  • AcoustIDのインバーテッドインデックス を再実装するためZigを学習
  • Zigでの開発は 高速かつスケーラブル で、従来のC++よりも優れたパフォーマンスを実現
  • 新たな知識習得と プロトタイプ開発 を両立

サーバーインターフェース実装の課題

  • 旧C++版では Qt を利用し非同期I/Oを実現、コールバックベースもサポート充実
  • Goでは ネットワーク処理・並行処理 が容易でプロトタイプ作成が簡単
  • Zigでは 既存のHTTPサーバー は存在するが、 TCPサーバー の実装は困難
  • マルチスレッド を多用しないと効率的なTCPサーバー構築が難しい課題

ZigでのクラスタリングとNATSクライアント

  • NATS をメッセージング基盤としてZigでクラスタレイヤーを実装
  • 独自の Zig NATSクライアント を開発し、Zigのネットワーク機能を深く理解
  • この経験が Zioライブラリ 開発のきっかけ

Zioライブラリの特徴

  • 非同期I/Oと並行処理 をシンプルに実現するライブラリ
  • コールバック不要で Goスタイルの協調的並行処理 をZigで再現
  • Zioタスク は固定サイズスタックを持つスタックフルコルーチン
  • stream.read() などでI/O開始→タスク一時停止→I/O完了後にタスク再開
  • 同期的なコード記述 のまま非同期処理を実現、状態管理が容易

Zioの機能詳細

  • 完全非同期のネットワーク・ファイルI/O 対応
  • 同期プリミティブ (ミューテックス、条件変数等)を協調的ランタイムで提供
  • GoスタイルのChannel、OSシグナル監視などにも対応
  • シングルスレッド/マルチスレッド両対応、タスクのスレッド間移動も可能
  • 低レイテンシ・高負荷分散 を実現

パフォーマンスと互換性

  • シングルスレッドモードで GoやRustのTokioを上回る速度
  • コンテキストスイッチ は関数呼び出し並みの軽さ
  • マルチスレッドモードもGo/Tokioと同等以上の性能
  • 標準のreader/writerインターフェース を実装、外部ライブラリとも連携可能

HTTPサーバー実装例

  • ZigとZioを組み合わせた シンプルなHTTPサーバー
    • 接続ごとに connectionTask をタスクとして生成
    • リクエストヘッダ受信→レスポンス返却→keep_alive判定で切断
    • イベントループ的な serverTask で新規接続を受け付け、タスク生成
    • runtime.runUntilComplete でメイン処理を開始

Zig開発の展望と今後の計画

  • 当初は Zigは高速処理専用のニッチ言語 という印象
  • Zioの登場でZig単体で完結する開発 が現実に
  • 今後は NATSクライアントのZio対応HTTPクライアント/サーバーライブラリ の開発を予定

まとめ

  • ZigとZioの組み合わせ で、非同期・並行処理が簡単かつ高速に
  • GoやRust に匹敵、あるいはそれ以上のパフォーマンス
  • サーバー開発やネットワークアプリ にもZigが有力な選択肢に進化

Hackerたちの意見

NATSクライアントの実装が、汎用の非同期フレームワークレイヤーを抽出するための正しいプロトタイプになる理由は何ですか?面白そうだけど、NATSには詳しくないんだよね。

汎用の非同期プリミティブを作ることに成功したら、元のタスクが何だったかはあんまり関係ないんじゃない?(非同期が必要なものであれば)それが汎用性のあることの暗示だよね?

このレイヤーはNATSクライアントから抽出されたわけじゃなくて、NATSクライアントがフラストレーションの元になってこのものが生まれたんだよね。

ZIOっていう並行Scalaライブラリがあるの知ってる?(https://zio.dev) :-)

今、Zigを取り入れるのは良くないタイミングじゃない?今、I/Oモデルに大きな変革が起きてるみたいだし、落ち着くまでに数年かかる印象があるんだけど、それは間違ってる?

数年なんてあっという間だよ。Zigは十分に使える言語だし、使いたい人は使うし、使いたくない人は使わないよ。

個人的には、それはすごく間違ってると思う。Zigの言語は劇的に変わるわけじゃなくて、新しい、非常に強力なAPIを追加してるんだ。ほとんどのZigの要素がアロケーターを関数のパラメータとして受け取るのと同じように、IOを行いたい関数は、望ましい抽象を提供するオブジェクトを受け入れるようになるから、呼び出し元が理想的な実装を定義できるようになるんだ。つまり、コードをアップグレードしたり改善したりするのが嫌なら、Zigを使わない理由はないよ。今日書いたコードは明日も動くし、明日書くコードは新しいIOインターフェースを持つ可能性が高いけど、それはその標準の抽象を使いたいから。使いたくないなら、既存のコードはそのまま動くよ。今日のように、アロケートしたいけどAllocatorを渡したくないなら、どこからでもstd.heap.page_allocator.allocを呼び出せるしね。でも、その抽象がすごく便利だから、Zigはそれをすごく使いやすくサポートしてるから、みんなその改善されたAPIを提供するコードを書くんだ。ちなみに、0.15.2でほぼ安定している新しいReader/Writer APIに合わせて、すべてのコードをアップグレードするのが心配だったけど、既存のプロジェクトに数行追加するだけで済んだ。新しいAPIのおかげで、すごく良いコードになるから、たくさんの関数をリファクタリングすることを選んでるよ。可読性もパフォーマンスも両方良くなるしね。リファクタリングしなきゃいけないわけじゃないけど、古いAPIは問題なく動くし、新しいAPIは単に使いやすくて、パフォーマンスも良くて、読みやすくて理解しやすい。やりたいからやってるだけで、やらなきゃいけないわけじゃないよ。赤いdiffが一番良いってみんな知ってるし、新しいstd.Io APIは物事をやるのが簡単な方法を提供してる。でも、Zigのすべてのように、自分が書きたいコードを書くこともできるし、それを自分でやりたいなら、それも完全にサポートされてるよ!

ちょっと悪いアイデアかもね。著者のライブラリですら最新のZig IO機能を使ってないし、0.16で大きな変更を計画してるみたい。リポジトリのREADMEから: > さらに、Zig 0.16がstd.Ioインターフェースを持ってリリースされたら、それも実装する予定です。このランタイムで標準ライブラリ全体を使えるようにします。このライブラリとは関係なく、ZigでたくさんのIOをやるつもりなので、0.16を待つつもり。君の直感が別の判断をするかもしれないけど、それもいいよ。

何をしているかによるけど、I/Oに関することで、Zig 0.15で導入されたバッファードリーダー/ライターインターフェースを使うなら、あまり変わらないと思うよ。インターフェースの取得方法に変更が必要かもしれないけど、コードのコア部分はそのままだよ。

「私の印象では、物事が落ち着くのに数年かかると思っていたんだけど、それは間違い?」 2020年初めに数ヶ月Zigを使って(常に壊れる変更にやられて)その印象を持っていたんだ。だから「数年後にまた見てみよう」と決めて離れた。これが永遠にリファクタリングしているプロジェクトの一つになるんじゃないかって直感があったんだ。5年後の今、まだ1.0を待ちながら、状況に応じてGoやCを使っている。決してそこに到達しないとは言わないけど、これは単に早く出荷することよりも、最良の設計判断を優先している活気あるプロジェクトだよ。Cの代替としては、原則的にはその精神が正しい。ただ、エンジニアが永遠に洗練し続けることに対して免疫があるかはわからない。のんびり待つには素晴らしいプロジェクトだと思うよ(=

コンテキストスイッチはほぼ無料で、関数呼び出しと同等だよ。そんなに低く見積もるなら、慎重に数えないとね。でも、コルーチンスイッチは、どんなにうまく実装されていても、必ずリターンスタックの分岐予測を壊すことになる。でも、予測ミスの影響はスイッチのポイントに集中するんじゃなくて、ターゲットコルーチンの実行に広がるんだ。(例えば、CPUの移行でキャッシュを吹き飛ばす影響を測るのにも似た問題がある。)実際、Zigの非同期設計が、(モノモーフィックな)非同期関数が別の関数を呼ぶときにハードウェアの呼び出し/リターンペアを使っているのか、すべてのリターンが間接ジャンプに変換されるのか、よくわからないな。(このオプションは、コンパクトなフレームを持つコルーチンのためにクリーンな設計を提供すると思うけど、CPUにはあまり優しくない。)だから、確実なベンチマークを取るには、(計算負荷の高い)プログラムが常に(例えば)2つのタスクの間でスイッチするのと、スイッチせずに(Zigの「無色」非同期について知っていることが少ないけど)非同期エグゼキュータの下で全く実行しない同等のプログラムの総実行時間を比較する必要があるね。そのタスクは、毎回非自明なコールスタックでyieldする必要もあるし。全体的に見ると、かなり難しそうだね。

実際、Zigのasync設計がハードウェアの呼び出し/戻りペアを使っているかどうかはわからない。Zigはもうかなり前から言語にasyncがないし、OPはユーザースペースでタスクスイッチングを実装したんだ。

確かにその発言は大げさだったけど、「トリビアル」な負荷でテストしてた時(コルーチン間の同期されたピンポン)に、他のソリューションと比べて信じられないような数字が出てきたんだよね。

Zigのことを初めて聞いたのは実はBunのウェブサイトだったんだけど、最近どんどん良くなってるよね。

Zigやってみたいな。数ヶ月前にRustにハマって、Tokioにはすごく感心したから、もしこのライブラリもガベージコレクタに頼らずにGoスタイルの並行処理を提供してくれるなら、楽しめそう。

Goには他では再現できないトリックがあって、無限に成長するスタックみたいなものはガベージコレクタのおかげで実現できるんだ。でも、これに取り組むのは楽しかったし、Zigがこんなに低レベルな言語で高レベルに見えるAPIを実現できるのにはいつも感心してる。

コールバックベースのasyncが標準になった理由がまだ謎なんだよね。これやlibtask[1]のやり方の方がずっとクリーンに見える。Rustの人たちはコールバック付きのasyncを採用したけど、彼らはほぼゼロから始めたから、そんな風にする必要はなかったはず。彼らは俺より賢いから、理由があるんだろうけど、それが何かはわからない。1: https://swtch.com/libtask/

マイクロソフトのエンジニアがC++標準のためにスタックフルとスタックレスのコルーチンについて研究した結果、システムレベルをターゲットにするための「方法」としてこれが選ばれたと思う。メモリオーバーヘッドがかなり少なくて(使った分だけ払えばいい)、エグゼキュータの実装詳細をオフロードできるから、いろんなデザイン選択ができるんだよね。

libtaskみたいなスレッドスタックはサイズがあいまいで、形式化された非同期状態に比べてかなり大きいことが多いね。

中断から始まったと思う。抽象化が少ない方が勝つことが多いね。

ちょっと好奇心からだけど、TCP接続での読み取りが簡単に1ヶ月ブロックすることがあるんだ。I/Oタイムアウトインターフェースはどうなってるの?例えば、読み取りが30秒間ブロックしたときにアプリケーションレベルのハートビートを送信したい場合とか。

それについてはまだ良い答えがないんだ。主にTCPの読み取りはstd.Io.Readerを通じて行われることが期待されていて、タイムアウトには気づいてないからね。私が考えているのは、Pythonのasyncio.timeoutみたいなもので、タイムアウトを開始して通常通りコードを実行させる感じ。タイムアウトが発生したときにI/Oスリープ中なら、起こされて操作がキャンセルされる。こんな感じかな:var timeout: zio.Timeout = .init; defer timeout.cancel(rt); timeout.set(rt, 10); const n = try reader.interface.readVec(&data);

Zioはすでに存在するよ、https://zio.dev/

スタックフルコルーチンは、RAMがあるときに意味があるね。私は主にCとの相互運用性のために、メモリ安全性を重視してZigを組み込み(ARM Cortex-M4、256KB RAM)で使っているよ。呼び出し規約に関する明示性がABIの不一致をコンパイル時にキャッチして、ランタイムのクラッシュを防ぐんだ。実際、私はこのアプローチよりも、Rustのようなカラフルな非同期が好きだな。「同期コードの幻想」は魔法のように感じるけど、大きなコードベースでは何がブロックしているのかわからなくなると、それが落とし穴になるんだよね。