Sans-IO: ネットワークサービスに効果的なRustの秘密
(firezone.dev)- FirezoneのRust接続ライブラリ
connlibはネットワーク接続とWireGuardトンネルを管理し、sans-IO設計によって高速なテストと高い動作信頼性を得ている - プロトコルはソケットを直接扱わず、純粋な状態機械として実装され、イベントループが
handle_input、poll_transmit、handle_timeout、poll_timeoutのようなAPIを呼び出す - IOの選択を状態機械の外へ押し出すことで、Rust asyncの function colouring の負担を減らし、blocking IO・non-blocking IO・特定のasyncランタイムの選択をアプリケーション側に委ねられる
- ソケットと時間を抽象化すれば、実際のポートや待ち時間なしに
InstantとTransmitだけで 時間経過・パケット損失・異常応答 を検証できる - その代わりイベントループを自分で管理する必要があるため微妙なバグが生じることがあり、逐次的なワークフローでは状態機械コードが増え、Rustエコシステムの sans-IOライブラリ はまだ限定的である
Firezone connlib が採用したsans-IO構造
- Firezoneは、Android、macOS、Linuxでスケーラブルな安全なリモートアクセスを実現するためにRustを使っている
- 各アプリの中心には
connlibがあり、このライブラリがネットワーク接続と WireGuardトンネル を管理してトラフィックを保護する - FirezoneのRustスタックでは
tokio、tungstenite、boringtun、rustlsなどを使っているが、内部構造は一般的なasync Rustコードとは異なるtokio::spawnの呼び出しがほとんどない- すべての通信が1つのUDPソケットに多重化される
- 複数の層で
handle_timeout、poll_transmit、handle_inputのようなAPIが繰り返し現れる
- これらの特徴は sans-IO設計 の兆候であり、プロトコルロジックが直接IOを実行せず、状態と入出力の意図を表現する
- Pythonエコシステムにはsans-IO専用の ドキュメントサイト があり、Rustでは次のライブラリがこのパターンを使っている
async Rustとfunction colouringの負担
- Rustのasync関数は他のasync関数の中でしか呼べないため、呼び出しチェーン全体がasyncに変わる function colouring の制約が生じる
- この制約は、実行を中断して後で再開できることが関数のAPI契約に含まれると、コンパイル時に強制するものだ
- スタックの深い場所にある1つのasync関数のために、呼び出し経路上の他の関数も
.awaitのためasyncになることがある - 実際のasync処理は通常、呼び出しスタックの最も下で発生する
- ソケットへの書き込み
- ファイルの読み取り
- 時間の経過を待つこと
- 多くのasync関数は自分で非同期処理をするのではなく、他のasync関数に依存するためasyncになる
- Firezoneの
connlibはNAT traversalのために ICE を使い、STUNでserver-reflexive candidate、つまりグローバルアドレスを見つける - STUN bindingは、UDPパケットをサーバーへ送り、サーバーが見たIPとポートを含むUDP応答を受け取る単純なプロトコルである
- 同じSTUNの例は、
tokioのasyncUdpSocketでも標準ライブラリのblocking IOでも、ほぼ同じように書ける - ライブラリとしてSTUN機能を提供するには、async版とblocking版のどちらかを選ぶか、両方を含めるという重複が生じる
- サンプルコードは firezone/sans-io-blog-example にある
sans-IOの核心はポリシーとIO実装の分離
- sans-IOの核心は、オブジェクト指向の 依存性逆転の原則 に近い
- 「何をするか」を決めるポリシーコードは、「どうやるか」を担う実装詳細に依存すべきではない
- ネットワークメッセージを送ると決めるコードが、実際のソケット送信コードに直接依存すると、上位のコードまでasyncかblocking IOかの選択に縛られる
- STUNの例ではポリシーコードは同じでも、
tokio::UdpSocketの上に構成すればasyncになり、std::net::UdpSocketの上に構成すればblocking IOになる - sans-IOでは
UdpSocket::sendを直接呼ぶ代わりに、送信の意図を表す 抽象化 を作る - 例の
Transmitには次の情報が含まれる- 宛先
SocketAddr - 送信する
payload
- 宛先
- プロトコルコードはソケットへ直接書き込まず、
Transmitを放出する - 実際の
UdpSocket::sendまたはsend_toの呼び出しは イベントループ が担う - sans-IOコードは、Rustの
Futureがランタイムにpollされないと進まないのと同じく、イベントループに駆動される必要がある
STUN bindingを状態機械に変える
- STUN bindingリクエストは、
SentとReceived状態を持つ状態機械としてモデル化できる - 例の状態は次のenumで表現される
SentReceived { address: SocketAddr }
StunBindingは現在の状態と、送信待ちのTransmitキューを持つ- 主要APIは役割が明確である
handle_input:UdpSocket::recvの結果として入ってきたパケットを状態機械に渡すpoll_transmit: 状態機械が出力しようとするTransmitをイベントループが取り出すpublic_address: 受信したグローバルアドレスを参照する
- この構造では、プロトコルロジックはIOなしにプログラムの動作だけをモデル化する
- イベントループは
poll_transmitで送るべきパケットがあればソケットへ送り、なければソケットから読んだデータをhandle_inputに渡す - イベントループはSTUNがrequest-responseプロトコルであるという詳細を知らなくても動作する
- UDPは信頼できないプロトコルなのでパケットが失われることがあり、STUNはこれを緩和するため再送タイマーを必要とする
時間も抽象化する
- ネットワークプロトコルで現在時刻が必要になるのは、多くの場合、ある基準時点から どれだけ時間が経ったか を確認するときである
- リクエスト送信後に5秒が経ったか
- 最後のkeep-aliveから30秒が経ったか
- このような場合、実際のwall clock時間は不要で、前の時点との
Durationだけが必要になる - Rustの
Instantは現在時刻そのものを公開せず、2つのInstantの間のDurationを測れるようにする - sans-IO状態機械は時間ベースの動作のために2つのAPIを持てる
poll_timeout: イベントループが次のwake-upタイマーを予約すべき時点を返すhandle_timeout: タイマーが期限切れになったことを状態機械に知らせる
- 例では、最後の応答を受け取ってから5秒が過ぎたら新しいbinding requestを送るように状態機械を拡張している
handle_inputはパケットとともに現在のInstantを受け取り、State::Received { address, at }の形で保存する- イベントループはソケット受信とタイマー満了をあわせて処理し、その後
poll_timeoutの結果に応じてタイマーを再設定する
合成とAPIの柔軟性
StunBindingの主要APIであるhandle_timeout、handle_input、poll_transmit、poll_timeoutはSTUNだけに特化したものではない- ほとんどのネットワークプロトコルはこの形、またはその変形で実装できるため、状態機械の合成 がしやすい
- グローバルIPを見つけるために5つのSTUNサーバーへ問い合わせるなら、5つの
StunBindingを作って順に呼び出せる- この場合、STUNメッセージの多重化は適切に実装する必要があり、
TransactionIdやサーバーアドレスを使える
- この場合、STUNメッセージの多重化は適切に実装する必要があり、
- Firezoneの
snownetはICEとWireGuardを組み合わせ、さまざまなネットワーク環境で動作するIPトンネルをアプリケーションに提供する snownetはsans-IO WebRTCライブラリstr0mと、ほぼsans-IOなWireGuard実装boringtunの上に構築されている- Firezoneに必要なのはWebRTCスタック全体ではなく、RFC 8445 を実装した
IceAgentだけである str0mがsans-IO方式であるため、IceAgentだけを取り出して既存コードの状態機械に合成しやすいsnownetのconnectionはIceAgentとWireGuardトンネルを保持し、入ってきたメッセージをどちらかへ渡す
イベントループを自分で書く利点
- sans-IOコードはシステム状態を表すだけで副作用を起こさないため、イベントループが状態を問い合わせ、実行し、新しい入力を渡す必要がある
- この構造はボイラープレートに見えるかもしれないが、イベントループを自分で書けることで 細かな制御 が可能になる
- アプリケーションは次のような実行方法を自分で選べる
sendmmsgでパケット送信時のシステムコール数を減らす- 複数のプロトコルを1つのソケットに多重化する
- ライブラリ作者は、asyncランタイム論争やソケットオプションAPIの提供よりも、プロトコル機能の実装に集中できる
str0mはネットワークインターフェースの列挙をIOの関心事とみなし、アプリケーションに委ねている- その代わり、ソケットアドレスをICE candidateとして現在の状態に追加するAPIだけを提供する
- Firezoneはこの構造を利用し、接続が作られる前にTURN candidateを事前収集して、接続確立の遅延を減らす最適化を実装している
- ICEでは、双方がcandidate、つまりソケットを収集した後、その間の接続性をテストする
高速なテストとエッジケースの検証
- sans-IOコードは本質的に副作用がないため、単体テストに向いている
- ソケットと時間が抽象化されているので、テストで実際のポートを開いたり時間を待ったりする必要がない
- 5分後の動作をテストしたいなら、変更した
Instantを関数に渡して状態変化を検証すればよい - Firezoneは、
snownetが5分後にidle connectionを閉じるかをテストする実例を提供している - データ転送も実際のソケットを通さず、一方の
Transmitを取り出して他方の状態機械のhandle_inputに渡せばよい - Firezoneは
connlibがどう動くべきかを示す 参照状態機械 を実装している - この参照状態機械はテストの基準として使われる
proptestの state machine testing を使い、CIごとに数千のシナリオを決定的にサンプリングして実行し、参照状態機械とconnlibの実際の状態を比較する- IOがなければ、次のような失敗や異常動作も簡単にテストできる
- パケットが失われて応答を受け取れない場合
- 不正な応答を受け取る場合
- サーバーまでのRTTが非常に長い場合
- 動作するIPv6インターフェースがない場合
- IPv6インターフェースしかない場合
- プロトコル実装と実際のIO副作用を分離すると、エラーの検出と処理が状態機械の入力処理の一部になる
Rustとsans-IOの相性がよい理由
- Rustは、どのコンポーネントや関数が値を所有するかを明示するよう強制する
UdpSocketから読むときは、実際のバイトを受け取る領域として&mut [u8]を提供しなければならない- 値の所有者だけが、その値をmutableにしたり他の関数へ一時的なmutable referenceを渡したりできる
- この明示的な所有権と可変性のモデルは、borrow checkerのようなRust機能の基盤である
- sans-IO設計の状態機械APIはすべて同期関数であり、IOや時間待ちでblockしない
- 状態機械は単なるデータ構造なので、
&mut selfで状態変化を表しやすく、borrow checkerを活用してコードのsoundnessを確保できる - 逆にasync Rustでは、
&mutはより扱いにくく感じられることがある - Rustのasync関数は
Futureを実装するデータ構造にコンパイルされる tokioのようなランタイムでFutureをspawnするには、このデータ構造が'staticである必要があるため、&mutのような参照を含められないFutureの外部状態を変更するには、通常は次のいずれかを使うArc<Mutex<T>>のような参照カウントポインタとmutex- 複数のtaskをspawnし、channelでつなぐactor方式
- どちらの方式にもランタイムオーバーヘッドがある
- lockは競合を生む可能性がある
- channelによるメッセージ送信はコピーを必要とする
- 複数のtaskがランタイム内で非決定的な順序で実行されると、race conditionやdeadlockにつながることがある
- sans-IOプロトコルコードはtaskをspawnしないため、状態変更には
&mut selfだけが必要である - taskやthreadがなければ
Mutexのような同期プリミティブは不要で、channelがなければデータコピーの必要も減る - Firezoneは、sans-IOへ移行してから、channelの相手側や閉じたchannel、どのコードが
Mutexをlockしているかを追う作業が減り、コード理解が容易になったと見ている
欠点と適用範囲
- sans-IOは万能な解決策ではない
- イベントループを自分で書くと強い制御権を得られる一方で、最初は見つけにくい 微妙なバグ が生じることがある
- たとえば状態機械の
poll_timeoutの戻り値が前進しないと、イベントループがbusy loopに陥る可能性がある - 逐次的なワークフローはより多くのコードを要求する
- Rustのasync関数は各
.await点が別の状態への遷移である状態機械にコンパイルされるため、開発者はnon-blocking IOと逐次コードを簡単に一緒に書ける - sans-IOでは、こうした段階を自分で状態機械としてモデル化しなければならない
StunBindingのようなrequest-responseプロトコルは難しくないが、より大きな逐次ワークフローを表現すると退屈になりがちである- Rustコミュニティではsans-IO設計はまだ広く普及していない
- ほとんどのライブラリはsans-IOではなく、blocking IOまたはnon-blocking IOを実装している
boringtunは内部でInstant::nowを呼び出しており、一部に純粋でない部分がある。関連issueは cloudflare/boringtun#391 にある
まとめ
- sans-IOコードは最初こそ見慣れないが、慣れるとRustの状態機械モデリング手法と非常によく合う
- エラーを他の入力と同じように扱うことを強制する構造であり、ネットワーキングコードの書き方とも相性がよい
- async Rustには別の書き方もあり、structured concurrencyはsans-IOとここで扱ったasync Rustアプローチの中間に位置する
- structured concurrencyについては、withoutboatsの Let futures be futures を参照できる
1件のコメント
Hacker Newsのコメント
これは革新であり前進であるかのように語られているが、実際には async/await の言語サポートが入る前は、Rust を含む
$langでは非同期をこう処理していたRust の組み込みファームウェア開発で最大の生産性向上は、I/O 操作のたびに状態機械を手で実装し、ローカル変数をカスタム状態へ移し替えるのをやめて、Rust に async/await 構文で代わりにやらせられるようになったときだった
Rust における async は結局、I/O(
await)地点の間にある値を保存してくれる自動状態機械へと展開されるただし、常にそうとは限らない。QUIC、WebRTC、IP のようなパケット中心のユースケースでは、実際の I/O 自体は簡単だ。個々のパケット/データグラムを送受信すればよい
.await地点が多くないので、コンパイラが生成してくれるものもあまりない。同時に複数の側面が並行して動く必要があり、それぞれ future/task に入ることになるため、こうした future 全体の状態管理はスパゲッティコードになりやすいランタイム環境への仮定が減るため、テストしやすく、合成しやすくなる
理論上は async/await が状態機械を作ってくれる方式でも同じことはできるが、実際にはかなり苦痛で、ほとんどの async/await コードは純粋ではない
Eff、Koka、Frank のような実験的言語は、こうしたプログラミングスタイルへのサポートが優れている。Haskell の I/O 論議の土台にも、free monad とその変種のような技術への深い投資がある
最近では Unison が興味深い言語で、いくつもの新しい概念を探求しつつも、中核に拡張可能なエフェクトシステムを置き、この種のコーディングを言語レベルでうまく支援している
.awaitを書かなければならない点だ。デフォルトで.awaitが実行され、特別な構文を使ったときだけそうならない、というふうに反転できるとよいのだがそれでも実際には、プロトコルライブラリが I/O を直接行っているケースをあまりにもよく見かける :-(
この問題領域をずっと頭の中で転がしていたのだが、自分が考えていた方向性と非常によく合うアプローチだ。ただし、記事の脚注 3 にあるように、まだ手を入れるべき部分はある
こう考えるようになったきっかけは、関数の色の議論と偶然の発見だった。VT100 ライブラリを作っていたとき、単体テストが非常に難しかったのだが、実質的に
parser::new(stdin())をしていたからだった。3回目か4回目の書き直しの途中で、何気なくパーサをparser::push(data)に変えたところ、そのとき Rust が、私が「カプセル化への執着」と呼ぶようになったエンタープライズ OOP 的アンチパターンを罰していたのだと気づいた今では I/O だけでなく、至るところでこのパターンとその弊害が見える
皮肉なことに、この解法は大学以前の教育でも、大学初期でも学ぶ内容だ。コンピュータの最も単純な説明は、入力を受け取り、データを処理/変換し、出力を出す機械だというものだ。関数の色の議論と関係がある理由は、色を気にしなければならないのは入力と出力だけで、コアロジックはたいていデータ変換だからだ
当たり前の話ではあるが、関数の色の「論争」の規模を見ると、多くの人がまずカプセル化で問題を解くように訓練されているため、この事実を見落としたり忘れたりしているということだろう。関数型の人たちは、この点ではかなりにんまりしていそうだ
Rust は私にとって、学ぶ旅というよりも、削ぎ落として学び直す旅に近かった。良いパターンなので、今後採用するつもりだ
追記: 関連コード: https://codeberg.org/jcdickinson/termkit/src/branch/main/src...
parser::push(data)に変えたという部分と、Rust がエンタープライズ OOP 的アンチパターンを罰したという部分をもう少し説明してもらえる?Rust を学び始めたばかりの超初心者としては、そのパターンの明白な問題が何なのか、Rust がどのように罰するのかがよく見えない
型パラメータと trait があちこちにあり、struct を機能を提供するクラスのような構造として乱用していた
Rust は、可能な限り型パラメータと独自 trait 定義を避けると、よりしっくりくる
不変条件を保つという意味でのカプセル化は良い。この「parse, don’t validate」という記事を思い出す: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...
この設計は、専用ハンドラにデータを送るためにチャネルを使う方式と比べるとどうなのか? チャネルを使う場合にはいくつか問題があった
(1) 追いにくいクモの巣状のコードが頻繁に生まれる
(2) ネットワークに送れるメッセージへ変換可能なメッセージ型を自分で実装しなければならない
(3) 関心のある、または許可されたエンティティに送信者を明示的に渡さなければならない
(4) チャネルのメッセージ送信が失敗したかどうかは分かるが、そのメッセージのネットワーク送信が失敗したかどうかは分からない
それでもかなり便利ではある。たとえば
ws_handlerチャネルがあれば、そこにデータを送るだけでよく、どこかの専用ハンドラが可能ならそのメッセージを送ることもできるsans-IO はアプリケーションにも使えるが、特にライブラリで有用だと感じる。ライブラリでは利用者に I/O 方式を強制しなくなるので、ずっと有用になる
Rust では同期 I/O と非同期 I/O の間にすでにエコシステムの分断があり、さらに異なる非同期ランタイムまであるので、この点は重要
ただし述べられている通り問題はある。たとえばアクター/チャネルは切断され得る。またバックプレッシャーを持たせるには必ず有界チャネルでなければならない。さらにコピーが必要なので、高いスループットを達成するのが難しくなることがある
あわせて見るとよいもの: モナド、特に Free(r) モナドとエフェクトシステム[0]
ロジックと実行を分離するというアイデアは、Haskell エコシステムではすでに十分に扱われてきた大きなテーマ
追記: 時間関連の処理が必要なときに出てくる
tokio::select!呼び出しをどうカプセル化したのかには触れていなかった。外側のコードが async である必要なしにループコードを async にするために、tokio::Runtimeを持ち回っているのだろうか?追記2: カプセル化されたライブラリがそうしていることを示そうとしていたのではなく、外側のアプリケーションが async コンテキストでバインディングを使えることを示そうとしていたのかもしれない
sans-IO スタイルで、何らかの動作やタイマーを待たなければならないカプセル化された関数をどう実装できるのかが、より気になった。あるいは期待される答えはビジーウェイトなのか、実質的に
block_in_placeのような方式でビジーウェイトの代わりをしてくれる独自の async ランタイムインスタンスを持ち回ることなのかもしれない[0]: https://okmij.org/ftp/Computation/free-monad.html
StunBindingstruct のこと。STUN バインディングの機能を表す。単に呼び出せる単一の関数ではなく、イベントループが必要になる要点は、
StunBindingがライブラリ内にあり得て、アプリケーション側ではそれをプログラムの状態機械に組み合わせて使えるという点。もちろんアプリケーションも sans-IO スタイルで構造化されていることが前提リンクされている
snownetライブラリがまさにこれを行っている。ドメインは I/O なしで ICE + WireGuard を組み合わせることで、その上に ACL を組み合わせるconnlibライブラリで使われている追記: ビジーウェイトはない。代わりに
StunBindingには、poll_timeoutを通じて自分が何を待っているかを公開する関数がある。呼び出し側、つまりイベントループがそれをどう実現するかは呼び出し側次第。対応するInstantでhandle_timeoutが呼ばれると、適切な動作が起こるおお、thomaseizinger だ!
rust-libp2p の内部を見たことがあるので、記事を読んでいる途中でこのパターンが非常に見慣れたものに感じられたが、偶然ではなかったようだ
Firezone はよさそう。すべてをつなげ!
その通りで、rust-libp2p と似ている点がある。ただし、そこでは実際のストリームや接続が依然として
Futureに似た構造の中に入っているため、より絡み合っていて、ここでの sans-IO のように厳密には分離されていない順次ワークフローはより多くのコードを要求する、というくだりがある。Rust の async 関数は状態機械にコンパイルされ、各
.await地点は別の状態への遷移を表す。そのため開発者は順次コードとノンブロッキング I/O を一緒に使いやすい。async がなければ、複数のステップを表すために自分で状態機械を書かなければならないasync と sans-IO を組み合わせることを試した人はいるだろうか? 少なくとも概念的には、sans-IO を理解するヘルパーを await する async 関数を書けば、全体が優れた sans-IO インターフェースを持つ struct 内部の状態機械にコンパイルされ、非 async コードからも簡単に呼び出せるはずに思える
自分で試したことはないが、予想される主な問題は、良い使い勝手と
Pinの扱いだと思うRust には、述べられているユースケースをある程度扱えるジェネレータ/コルーチンがあるが、現在は非常に不安定な機能
残念ながら現在の形のコルーチンには、
std::ops::Coroutinetrait を通じてのみ公開されるという厄介な制限がある。そのため、コンパイラが生成した内部状態機械を直接割り当てることができない。状態機械のサイズは見たところコンパイル時定数なのに、そうなっているライフタイムが定義された関数内にだけ留まる単一のコルーチンなら問題ではない。コンパイラがそれを把握し、状態機械をスタックに割り当てられる
しかしコルーチンの最も有用な適用先は、イベントループ装置のキュー要素だと考えられる。この実装はコルーチンをボックス化しない限り不可能。
Vec>はキャッシュフレンドリーなデータ構造ではなく、極めて高い同時実行 I/O でVecに 100 万個の要素が必要になるとつらさを感じることになるRust にいつかネイティブなジェネレータ構文ができれば、可能になるかもしれない。async タスクのコンテキスト内に留まりつつデータを「書く」ために
yield transmitと書けるだろうから。つまり、すべてのsocket.writeがyield transmitに置き換わるということデータを読むときはジェネレータが中断(
.await)され、入ってきたデータとともに再開されるのを待つ。nightly にこうした構文があるかは分からないが、おおよそ次のような形になるはず:// Made up
gensyntax: gen(yield_type, resume_type)gen(Transmit, &[u8]) fn stun_binding(server: SocketAddr) -> SocketAddr {
let req = make_stun_request();
yield Transmit {
server,
payload: req
};
スタックの上位では、この2つはかなり相性が良い。ノンブロッキング I/O、つまり async は、ソケット I/O と時間を同時に待ちやすくしてくれる。ブロッキング I/O でもソケットに読み取りタイムアウトを設定すればできるが、async のプリミティブを使うほうが少し簡単
自分もこの2つをどう組み合わせるかをずっと考えていた。行き着いた問題の1つは、async 関数が不透明型としてコンパイルされる点。なので、コンパイラにステートマシンをコード生成してもらう機能を使うのが難しい、あるいは不可能になる。生成された後は、そのステートマシンとやり取りできないからだ。これはある意味、借用チェッカーも壊してしまう
たとえば複数の段階、つまり複数の
awaitポイントを持つ async タスクがあり、そのうち1区間だけが共有データ構造への可変参照を必要とするとしよう。これをasync関数として表現した瞬間、その可変参照は生成されたFuture型にキャプチャされ、その型は全段階にわたって存在する。結果として Rust は、そうしたタスクを2つ以上同時に実行することを許可しないこういう状況でよくある助言は「可変参照をできるだけ短くキャプチャせよ」だが、async ではそれができない。async 関数を複数に分割するのも煩雑になり、そもそもすべてを1つの関数として表現したかった目的をある程度損なってしまう
以前、HTTP/1.1 プロトコルを I/O のための
.awaitポイントを持つ Sans-IO ステートマシンとしてエンコードしようとしたが、あまり先には進めなかった。ただしその I/O は async ランタイムに waker を登録するのではなく、ユーザーが自分で I/O を実行できるよう制御を返す方式だった。.awaitが「下」ではなく「上」に解放するものだと考えられるHTTP/1.1 の文脈では、async コードはユーザーが呼び出し動作をどう望むかについての一種の設計図になった。当時は
no_stdとアロケータなしの環境で必ず動作させようとしていて、Boxによる動的ディスパッチ、つまりアロケータが必要な部分を避ける方法を見つけられず、断念したhttps://news.ycombinator.com/item?id=40879547
async fn stun形式で書いた例がある。完全に動作するコードはこちら: https://gist.github.com/joshka/af299be87dbd1f64060e47227b577...よくできている!状態を公開すれば、どんな async 関数でも純粋にできる。ユーザーはステートマシンを次の状態へ押し進めるだけでよい
以前 OpenSSL を async Rust にバインドしようとしたことがあるが、その async API も似た設計に従っている
似ていると思った点は、job としてスケジュールされる作業そのものが、どのように実行されるかから独立しているということだろうか?
この例 [0] を見ると、その async API は Rust の future にかなり近く見える
job の中で “wait context” にアクセスでき、特定の条件で中断でき、実行を継続するための wake をトリガーできる
[0]: https://www.openssl.org/docs/man1.1.1/man3/ASYNC_is_capable....
これは単にコルーチンの代わりにコールバックを使う一般的な非同期 I/Oだ
記事と一部のコメントを読んだ感じでは、ヘキサゴナルアーキテクチャ、あるいはポート/アダプターアーキテクチャのスタイルを再発明しているように聞こえる
ここから何を持ち帰ればいいのかよく分からない。語られている内容はすべて、すでに基本的なネットワークプログラミングだ
より高レベルの配線作業に集中し、状態管理に過度に入り込んでいるように見えるが、これは好みの問題にすぎず、ネットワーキングとは関係がない
記事から学んだ最も興味深い点は、Cloudflare が公開 STUN サーバーを運用しているという事実だ。だがそれもあまり役には立たない。STUN プロトコルの「良く」「有用な」バージョンは、NAT 列挙を可能にする
change requests機能をサポートしていた最初のバージョンだからだ。その後の STUN バージョンでは、仕様に貢献した Cisco エンジニアたちの「役立つ提案」のおかげで、その機能は削除された現在の Rust では、たとえば Tokio async ランタイムを使う WebRTC ライブラリを実装すると、同期 I/O を使う人や、別のランタイム(smol、async-std など)を使う人、あるいは iouring を直接使う人にとっては、利用が非常に面倒になる
このアプローチを使えば、利用者に I/O の選択を強制しないため、ライブラリをより多くの人にとって有用なものにできる