この質問の歴史は、Rust 1.36での std::task::Waker の安定化に遡ります。これは、未来の準備完了を通知するための標準化されたメカニズムを導入しました。それ以前は、非同期フレームワークはボックス化されたクロージャーやカスタム通知トレイトに依存しており、それには割り当てのオーバーヘッドが発生し、Cライブラリとのシームレスな統合が妨げられていました。RawWaker APIは、開発者が生ポインタと関数ポインタテーブル (RawWakerVTable)から Waker インスタンスを構築できるように設計されており、C++の仮想テーブルを反映しつつも、Rust の安全性要件を考慮しています。
問題は、RawWaker の構築が Rust の所有権と借用システムを完全にバイパスすることに起因しています。プログラマーは、4つの重要な不変条件が常に守られていることを手動で確認しなければなりません:データポインタはすべての Waker クローンのライフタイムにわたって有効でなければならず(元のものだけでなく)、4つのvtable関数(clone, wake, wake_by_ref, drop)は、実行者が単一スレッドであったとしてもスレッドセーフである必要があります(SendおよびSync)、そしてclone 関数は同じ根底にあるタスク状態を参照する新しい RawWaker を返さなければなりません。さらに、vtableは extern "C" ABI を使用して、FFI互換性と Rust のバージョン間での安定した呼び出し規約を保証する必要があります。
この解決策は、厳格な unsafe 不変条件の遵守を要求します。データポインタは通常、'static データを参照するか、共有所有権を管理するために Arc にラップされるべきです。vtable関数は正しい参照カウントセマンティクスを正しく実装する必要があります:cloneはカウントを増やし、dropはカウントを減らし、wakeは通知後にカウントを減らします(Waker を消費します)。ABI契約に違反すること(例えば、Rust 呼び出し規約の代わりに extern "C" を使用するなど)は、実行者がこれらのポインタを呼び出したときに未定義の動作を引き起こし、スタックの破損、引数のミスアライメント、または無効なメモリアドレスへのジャンプを引き起こす可能性があります。
use std::sync::Arc; use std::task::{RawWaker, RawWakerVTable, Waker}; struct TaskState { id: u64, } unsafe fn clone_waker(data: *const ()) -> RawWaker { let arc = Arc::from_raw(data as *const TaskState); let _ = Arc::clone(&arc); let _ = Arc::into_raw(arc); // ドロップを避けるためにリーク RawWaker::new(data, &VTABLE) } unsafe fn wake_waker(data: *const ()) { let arc = Arc::from_raw(data as *const TaskState); drop(arc); // Arcをドロップし、参照を解放 } unsafe fn wake_by_ref(data: *const ()) { let arc = Arc::from_raw(data as *const TaskState); // ここにウェイクロジック、そしてリーク let _ = Arc::into_raw(arc); } unsafe fn drop_waker(data: *const ()) { let _ = Arc::from_raw(data as *const TaskState); // 暗黙のドロップがメモリを解放 } static VTABLE: RawWakerVTable = RawWakerVTable::new( clone_waker, wake_waker, wake_by_ref, drop_waker, ); fn create_waker(state: Arc<TaskState>) -> Waker { let ptr = Arc::into_raw(state) as *const (); unsafe { Waker::from_raw(RawWaker::new(ptr, &VTABLE)) } }
Rust 非同期ランタイムがレガシーC++市場データフィードライブラリとインターフェースを持たなければならない高頻度取引システムの開発を考えてみてください。C++ライブラリは void* コンテキストと関数ポインタを受け入れる登録関数を提供し、価格更新が到着するとコールバックを呼び出します。エンジニアリングの課題は、メッセージごとの割り当てオーバーヘッドを導入せずに Waker を作成し、レイテンシ要件がサブマイクロ秒のウェイクタイムを要求するために、C++コールバックメカニズムと Rust 未来を接続することです。
1つの解決策は、Box<dyn Fn() + Send> クロージャーを Waker のデータポインタとして保存することを含みました。このアプローチは Rust の所有権システムを通じてメモリ安全性を提供し、簡単な統合を可能にしました。しかし、市場データの各サブスクリプションに対して許容できないヒープ割り当てレイテンシと、システムのゼロコピーアーキテクチャに違反する仮想ディスパッチオーバーヘッドを引き起こしました。さらに、FFI 境界を越えたボックス化されたクロージャーのライフタイムを管理するのは危険であり、C++ライブラリの非同期クリーンアップが、Rust 側が Waker をドロップする前にダングリングポインタを残す可能性がありました。
代替のアプローチは整数IDをタスクハンドルにマッピングするグローバル静的ハッシュマップを利用し、IDを void* コンテキストとして渡しました。これにより、割り当てが排除され、ウェイク操作の O(1) ルックアップが提供されました。しかし、これにより、タスクがフィードから登録を解除せずに完了した場合にメモリリークの危険が生じ、静的マップは Mutex 同期が必要になり、高市場データスループットの下で競合ボトルネックとなり、事実上すべてのCPUコアでウェイク通知を直列化しました。
選択された解決策は、データポインタがC++コールバックコンテキストと完了フラグを含む Arc<TaskState> を保持するカスタム RawWaker を実装しました。RawWakerVTable 関数は、unsafe extern "C" スンクとして実装され、void* を Arc ポインタに安全に変換し、FFI 境界を越えた正しい参照カウントを保証しました。この設計により、メッセージごとの割り当てが排除され、Arc 構造体の再利用により再利用され、Arc の原子操作を通じてスレッドセーフが維持され、最後の Waker クローンがドロップされたときのみ参照カウントが減少することでメモリ安全性が確保されました。その結果、サブマイクロ秒のウェイク遅延を実現し、メモリ安全性の保証を Rust/C++ 境界を越えて維持し、Miri の未定義動作検出と数百万の同時価格更新を含むストレステストに成功しました。
なぜ RawWakerVTable 関数は実行者が単一スレッドの場合でもスレッドセーフ (Send + Sync) でなければならないのですか?
Waker 型は Clone, Send, Sync を実装しており、実行者のスレッドモデルに関係なく、スレッド境界を越えて移動できるようになっています。未来が Waker を保持し、spawn_blocking タスクや std::sync::mpsc チャネルに渡すと、Waker は作成したスレッドとは異なるスレッドから呼び出される可能性があります。vtable 関数が単一スレッドアクセスを想定している場合、たとえば Rc を使用している場合や同期されていない静的変数を使用している場合、wake() が同時に呼び出されるとデータ競合が発生します。さらに、Tokio や async-std のような非同期ランタイムは、負荷分散のためにタスクをワーカースレッド間で移動できるため、Waker は作成されたサイトとは異なるスレッドでクローンされたりドロップされたりすることがあります。スレッドセーフの要件は、プログラム全体で Waker が共有される方法に関係なく、通知メカニズムが有効であることを保証します。
もし clone 関数が元の vtable とは異なる vtable を持つ RawWaker を返した場合、どのような壊滅的な失敗が発生しますか?
Waker 契約は、すべての Waker のクローンが同じ根底にあるタスクを表し、呼び出されたときに同一に振る舞うことを要求します。もし clone が異なるタスクに関連付けられた別の vtable を指す RawWaker を返す場合、あるいは null 関数ポインタを持つ場合、実行者はタスクを通知するときに誤ったウェイクロジックを呼び出す可能性があります。これは、関連のないタスクを起こす(論理的な破損)か、無効なメモリにジャンプする(セグメンテーションフォルト)ことになります。具体的には、実行者は通常、Waker クローンを内部キューに保存します。イベントが発生したとき、これらの保存されたハンドルで wake() を呼び出します。vtable が一致しない場合、データポインタ(タスクコンテキスト)は誤った関数シグネチャを通じて解釈され、vtable 関数がポインタを誤った型にキャストしたり、誤ったオフセットでフィールドにアクセスしたりすると、即座に未定義の動作を引き起こします。
なぜ extern "C" ABI が vtable 関数に対して必須なのですか?デフォルトの Rust ABI ではなく。
RawWakerVTable は、FFI 互換性と ABI の安定性を保証するために extern "C" 関数ポインタを指定します。Rust の ABI は、コンパイラのバージョンや最適化レベルに対して安定していません。関数シグネチャは、コンパイラ内部、インライン化の決定、またはターゲットアーキテクチャに基づいて変更される可能性があります。extern "C" を使用することで、呼び出し規約がプラットフォームのC標準に従うことが保証され、vtableがCコードと互換性を持ち、関数ポインタのためのコード生成時の未定義の動作を防ぎます。さらに、extern "C" ABI は、特定のレジスタ使用とスタッククリーンアップルールを義務付けており、Waker が言語の境界を越えて安全に渡されることを可能にします。この制約がない場合、動的ライブラリとリンクするか、Rust コンパイラをアップグレードすることで関数呼び出し規約が変更され、実行者が wake() または clone() を呼び出すときにスタックの破損や引数のミスアライメントを引き起こす可能性があります。