RustProgrammingRustデベロッパー

**async-trait**クレートは、ネイティブコンパイラサポート以前に**trait**定義内で**async fn**をどのようにエミュレートし、このエミュレーションにはどのような特定のランタイムコストがかかりますか?

Hintsage AIアシスタントで面接を突破

質問への回答

async-traitクレートは、手続きマクロを利用して、async fnメソッドをPin<Box<dyn Future<Output = T> + Send + 'static>>を返す同期メソッドに変換します。この変換により、asyncブロックから生成された具体的な将来の型が消去され、vtableを介る動的ディスパッチが可能になり、トレイトがオブジェクトセーフであることを保持します。特定のランタイムコストは、各メソッド呼び出し時に将来を格納するためのBoxのヒープ割り当てと、dynトレイトオブジェクトディスパッチに関連する間接関数呼び出しのオーバーヘッドを含みます。さらに、'staticバインドにより、将来が非静的データを借用することが防止され、キャプチャされた参照はすべて所有されるか、'staticライフタイムを持つ必要があります。

実生活の状況

私たちのエンジニアリングチームは、接続ハンドラの動的ロードのためのプラグインアーキテクチャを必要とする高性能TCPサーバーを構築していました。async fn handle(&mut self, stream: TcpStream)を持つConnectionHandlerトレイトが必要でしたが、Rustのバージョン1.70ではトレイト内でネイティブasync fnがサポートされていませんでした。

async fnの代わりにimpl Futureの返り値のあるジェネリックトレイトを使用することで、ヒープ割り当てなしでゼロコストの抽象化を提供し、モノモルフィゼーションを介して積極的なコンパイラ最適化が可能でした。しかし、このアプローチは根本的に動的ディスパッチを妨げ、異なるハンドラを**Vec<Box<dyn ConnectionHandler>>**に格納することや、ランタイムで共有ライブラリから動的にロードすることを不可能にし、私たちのプラグインアーキテクチャの核心を形成しました。

async-traitクレートを採用することで、ネイティブasync fnと同じクリーンな構文を提供しつつ、Box<dyn ConnectionHandler>による動的ディスパッチをサポートしました。主な欠点は、将来をボックス化するためのメソッドごとの必須ヒープ割り当てと、'staticライフタイムの要件が非静的データの借用をawaitポイントをまたいで防ぐため、追加のデータクローンが強いられる可能性があることです。

マクロを使用せずに、Pin<Box<dyn Future>>を返すことでトレイトを手動で実装することで、Sendバインドを完全に制御し、手続きマクロコンパイルのオーバーヘッドを排除しました。残念ながら、これには非常に冗長なボイラープレート、Pin::new_uncheckedを使用した手動のunsafeピン留め操作が必要で、awaitポイントをまたぐ複雑なライフタイム制約を扱う際に非常にエラーが発生しやすく、開発速度が著しく低下しました。

最終的に、私たちは、サーバーが主にI/Oバウンドであったことを考慮し、各メソッドごとのヒープ割り当てのオーバーヘッドが容認できると判断したため、async-traitクレートを解決策として選択しました。また、エルゴノミクスの利点により、開発速度が大幅に向上しました。プラグインシステムは**Box<dyn ConnectionHandler>**とシームレスに連携し、再コンパイルなしでモジュールのホットスワッピングを可能にし、私たちのアーキテクチャの要件を満たしました。

コードベースをRust 1.75に移行した後、async-traitを動的ディスパッチが必要ないトレイトにおいてネイティブasync fnに系統的に置き換え、同じクリーンなAPIサーフェスを維持しつつ、各呼び出しのヒープ割り当てを排除しました。パフォーマンスプロファイリングでは、レガシーバージョンにボクシングオーバーヘッドが存在していたものの、ネットワーク遅延と比較して無視できる程度であることが確認されたため、初期の技術的決定が正当化されました。

候補者がしばしば見逃すこと

async-traitが将来を**'staticであることを要求する理由と、この制約がawait**ポイントをまたぐ借用にどのように影響するかは何ですか?

'staticバインドは、async-traitが将来をBox<dyn Future + Send + 'static>に消去するために生じ、Rustにおけるトレイトオブジェクトは、すべての可能な実行コンテキストを包含する定義されたライフタイムを持つ必要があります。実行者が将来をスレッド境界を越えて無期限に保持するか、内部キューに格納する可能性があるため、コンパイラは将来がキャプチャしたすべてのデータを所有するか、'static参照のみを保持することを要求します。これにより、スタックローカル変数をawaitポイントをまたいで借用することが防止されます。候補者は、これはトレイトオブジェクト向けの型消去の根本的な制限であり、クレートの著者によって課された任意の制約ではないということをしばしば見逃します。

Pin<Box<dyn Future>>返り値の型がマルチスレッドエグゼキュータにおけるSend要件とどのように相互作用し、基礎となる将来がSendでない場合にどのようなコンパイルエラーが発生しますか?

async-traitは、自動的にボックス化された将来にSendバインドを追加します(Pin<Box<dyn Future + Send + 'static>>)の指定を追加し、実行中にタスクをスレッド間で移動させる可能性のある作業窃取エグゼキュータ(例:Tokio)との互換性を確保します。将来がSendであるためには、asyncブロックによってキャプチャされたすべてのデータがSendを実装する必要があります。将来がRcや生ポインタのようなSendでない型をキャプチャすると、コンパイラは、将来がスレッド間で安全に送信できないため、!Sendを実装しているとするエラーを生成します。候補者は、Sendバインドがマルチスレッドコンテキストにおけるスレッド安全性に不可欠であり、async-traitがこのバインドをデフォルトで課すことによってランタイムデータ競合を防いでいることをしばしば見逃します。たとえ理論的に単一スレッドの実行者であったとしても。

Rust 1.75で安定化したネイティブasync fnトレイトと、オブジェクトセーフティと動的ディスパッチに関するasync-traitエミュレーションの根本的なアーキテクチャの違いは何ですか?

ネイティブasync fnトレイトは、各実装に特有の不透明なimpl Future型を返すReturn Position Impl Trait In Traits (RPITIT)を利用しています。このアプローチはゼロコストであり、モノモルフィゼーションを介して静的にディスパッチされますが、impl Traitがvtableエントリに必要な具体的な型を隠すため、トレイトはオブジェクトセーフでなくなります。したがって、ネイティブasync fnを使用して**Box<dyn Trait>**を作成することはできません。

対照的に、async-traitは将来をPin<Box<dyn Future>>に即座にボックス化することにより、オブジェクトセーフティを達成します。これはサイズが既知であり、vtableに格納でき、ヒープ割り当てのコストで動的ディスパッチを可能にします。候補者は、2つのアプローチを混同し、ネイティブasync fnが自動的にBox<dyn Trait>をサポートする、またはasync-traitがオブジェクトセーフティと割り当て戦略に関するアーキテクチャの違いなしに単なる構文糖衣であると仮定することがよくあります。