3 ポイント 投稿者 GN⁺ 2024-07-05 | 1件のコメント | WhatsAppで共有
  • FirezoneのRust接続ライブラリ connlib はネットワーク接続とWireGuardトンネルを管理し、sans-IO設計によって高速なテストと高い動作信頼性を得ている
  • プロトコルはソケットを直接扱わず、純粋な状態機械として実装され、イベントループが handle_inputpoll_transmithandle_timeoutpoll_timeout のようなAPIを呼び出す
  • IOの選択を状態機械の外へ押し出すことで、Rust asyncの function colouring の負担を減らし、blocking IO・non-blocking IO・特定のasyncランタイムの選択をアプリケーション側に委ねられる
  • ソケットと時間を抽象化すれば、実際のポートや待ち時間なしに InstantTransmit だけで 時間経過・パケット損失・異常応答 を検証できる
  • その代わりイベントループを自分で管理する必要があるため微妙なバグが生じることがあり、逐次的なワークフローでは状態機械コードが増え、Rustエコシステムの sans-IOライブラリ はまだ限定的である

Firezone connlib が採用したsans-IO構造

  • Firezoneは、Android、macOS、Linuxでスケーラブルな安全なリモートアクセスを実現するためにRustを使っている
  • 各アプリの中心には connlib があり、このライブラリがネットワーク接続と WireGuardトンネル を管理してトラフィックを保護する
  • FirezoneのRustスタックでは tokiotungsteniteboringtunrustls などを使っているが、内部構造は一般的なasync Rustコードとは異なる
    • tokio::spawn の呼び出しがほとんどない
    • すべての通信が1つのUDPソケットに多重化される
    • 複数の層で handle_timeoutpoll_transmithandle_input のようなAPIが繰り返し現れる
  • これらの特徴は sans-IO設計 の兆候であり、プロトコルロジックが直接IOを実行せず、状態と入出力の意図を表現する
  • Pythonエコシステムにはsans-IO専用の ドキュメントサイト があり、Rustでは次のライブラリがこのパターンを使っている
    • quinn: 独立したQUIC実装
    • quiche: CloudflareのQUIC実装
    • str0m: sans-IO WebRTC実装

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 のasync UdpSocket でも標準ライブラリの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リクエストは、SentReceived 状態を持つ状態機械としてモデル化できる
  • 例の状態は次のenumで表現される
    • Sent
    • Received { 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_timeouthandle_inputpoll_transmitpoll_timeout はSTUNだけに特化したものではない
  • ほとんどのネットワークプロトコルはこの形、またはその変形で実装できるため、状態機械の合成 がしやすい
  • グローバルIPを見つけるために5つのSTUNサーバーへ問い合わせるなら、5つの StunBinding を作って順に呼び出せる
    • この場合、STUNメッセージの多重化は適切に実装する必要があり、TransactionId やサーバーアドレスを使える
  • 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 がどう動くべきかを示す 参照状態機械 を実装している
  • この参照状態機械はテストの基準として使われる
  • propteststate 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件のコメント

 
GN⁺ 2024-07-05
Hacker Newsのコメント
  • これは革新であり前進であるかのように語られているが、実際には async/await の言語サポートが入る前は、Rust を含む $lang では非同期をこう処理していた
    Rust の組み込みファームウェア開発で最大の生産性向上は、I/O 操作のたびに状態機械を手で実装し、ローカル変数をカスタム状態へ移し替えるのをやめて、Rust に async/await 構文で代わりにやらせられるようになったときだった
    Rust における async は結局、I/O(await)地点の間にある値を保存してくれる自動状態機械へと展開される

    • 記事の終盤でこの点を扱おうとしていた。実装しているものが大部分 逐次的な I/O 操作なら、このモデルはかなりつらくなる
      ただし、常にそうとは限らない。QUIC、WebRTC、IP のようなパケット中心のユースケースでは、実際の I/O 自体は簡単だ。個々のパケット/データグラムを送受信すればよい
      .await 地点が多くないので、コンパイラが生成してくれるものもあまりない。同時に複数の側面が並行して動く必要があり、それぞれ future/task に入ることになるため、こうした future 全体の状態管理はスパゲッティコードになりやすい
    • その説明は厳密には正しくないと思う。ここで重要なのは、状態機械が直接 I/O を行わないという点だ。常に自分をホストしているイベントループにその仕事を委譲するので、複数の文脈で解釈できる
      ランタイム環境への仮定が減るため、テストしやすく、合成しやすくなる
      理論上は async/await が状態機械を作ってくれる方式でも同じことはできるが、実際にはかなり苦痛で、ほとんどの async/await コードは純粋ではない
      Eff、Koka、Frank のような実験的言語は、こうしたプログラミングスタイルへのサポートが優れている。Haskell の I/O 論議の土台にも、free monad とその変種のような技術への深い投資がある
      最近では Unison が興味深い言語で、いくつもの新しい概念を探求しつつも、中核に拡張可能なエフェクトシステムを置き、この種のコーディングを言語レベルでうまく支援している
    • 1980年代には、アセンブリ言語で I/O をこうやっていた。割り込み駆動の YMODEM 実装をほかにどう書くというのか?
    • その通り。async で唯一気に障るのは、あちこちに .await を書かなければならない点だ。デフォルトで .await が実行され、特別な構文を使ったときだけそうならない、というふうに反転できるとよいのだが
    • 革命的だと持ち上げているわけではない。記事にもこうある。「このパターンは私たちが発明したものではありません! Python の世界には、このための専用ウェブサイトさえあります。」
      それでも実際には、プロトコルライブラリが 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 がどのように罰するのかがよく見えない
    • 私も OOP の世界から Rust に移ってきて、もう6年になる。最初の2〜3年はひどいコードを書いていた
      型パラメータと 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 チャネルがあれば、そこにデータを送るだけでよく、どこかの専用ハンドラが可能ならそのメッセージを送ることもできる

    • 4番は、結果を返すためのチャネルをメッセージと一緒に渡せば実装できる。望むなら送信側を呼び出し地点までずっとブロックしておくこともできる
      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

    • ここでいう「カプセル化された関数」は StunBinding struct のこと。STUN バインディングの機能を表す。単に呼び出せる単一の関数ではなく、イベントループが必要になる
      要点は、StunBinding がライブラリ内にあり得て、アプリケーション側ではそれをプログラムの状態機械に組み合わせて使えるという点。もちろんアプリケーションも sans-IO スタイルで構造化されていることが前提
      リンクされている snownet ライブラリがまさにこれを行っている。ドメインは I/O なしで ICE + WireGuard を組み合わせることで、その上に ACL を組み合わせる connlib ライブラリで使われている
      追記: ビジーウェイトはない。代わりに StunBinding には、poll_timeout を通じて自分が何を待っているかを公開する関数がある。呼び出し側、つまりイベントループがそれをどう実現するかは呼び出し側次第。対応する Instanthandle_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::Coroutine trait を通じてのみ公開されるという厄介な制限がある。そのため、コンパイラが生成した内部状態機械を直接割り当てることができない。状態機械のサイズは見たところコンパイル時定数なのに、そうなっている
      ライフタイムが定義された関数内にだけ留まる単一のコルーチンなら問題ではない。コンパイラがそれを把握し、状態機械をスタックに割り当てられる
      しかしコルーチンの最も有用な適用先は、イベントループ装置のキュー要素だと考えられる。この実装はコルーチンをボックス化しない限り不可能。Vec> はキャッシュフレンドリーなデータ構造ではなく、極めて高い同時実行 I/O で Vec に 100 万個の要素が必要になるとつらさを感じることになる

    • Rust にいつかネイティブなジェネレータ構文ができれば、可能になるかもしれない。async タスクのコンテキスト内に留まりつつデータを「書く」ために yield transmit と書けるだろうから。つまり、すべての socket.writeyield transmit に置き換わるということ
      データを読むときはジェネレータが中断(.await)され、入ってきたデータとともに再開されるのを待つ。nightly にこうした構文があるかは分からないが、おおよそ次のような形になるはず:

      // Made up gen syntax: gen(yield_type, resume_type)
      gen(Transmit, &[u8]) fn stun_binding(server: SocketAddr) -> SocketAddr {
      let req = make_stun_request();

yield Transmit {
server,
payload: req
};

let res = .await; // Made up "suspend and resume with argument"-syntax.

let addr = parse_stun_response(res);

addr  
}
  • スタックの上位では、この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 も似た設計に従っている

    • ざっと調べたところ、OpenSSL には “async job” API がある。ただし、それは I/O をしているように見える。しかも job の作成は非常に高コストなので、job を再利用すべきだとされている
      似ていると思った点は、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 の文脈での大きな利点は、関数の色問題を解決する点だと思う。記事で説明されているように、テストも本当に単純になる
      現在の Rust では、たとえば Tokio async ランタイムを使う WebRTC ライブラリを実装すると、同期 I/O を使う人や、別のランタイム(smol、async-std など)を使う人、あるいは iouring を直接使う人にとっては、利用が非常に面倒になる
      このアプローチを使えば、利用者に I/O の選択を強制しないため、ライブラリをより多くの人にとって有用なものにできる