質問への回答
非同期の未来が待機ポイント(例えば、トキオの select! で兄弟ブランチが完了したとき)で停止している間にドロップされると、その Drop 実装が同期的に実行され、保持されているリソースが破棄されます。問題は、未来が非同期でのクリーンアップを必要とするリソースを所有している場合に発生します。例えば、TcpStream をフラッシュしたり、プロトコルクローズフレームを送信したり、データベーストランザクションをコミットしたりする際です。なぜなら、Drop トレイトは非同期のコンテキストを提供しないからです。未来が状態を部分的に変更してから(例えば、ファイルバッファの半分を書き込んで)、最終化する前にキャンセルされた場合、同期の Drop はクリーンアップ操作の完了を .await できず、システムが不整合な状態になったりリソースがリークしたりする可能性があります。アーキテクチャ的な解決策は、ドロップガードパターンを取り入れることです。これは、リソースをガード構造体でラップし、その Drop 実装が同期的なフォールバッククリーンアップをスケジュール(ブロッキングリスクを受け入れる)するか、リソースを切り離されたクリーンアップタスクに移行させることで、重要な不変条件(例えば、一時ファイルの削除)が最終的に強制されることを保証します。
実生活からの状況
私たちは、高スループットのメディア取り込みサービスを開発しました。ここで、tokio::spawn が並行ファイルアップロードを処理しました。各アップロードタスクは、一時ファイルにチャンクを書き込み、外部プロセスを介してウイルススキャンを実施し、最終的に検証済みのファイルを永続的なストレージバケットに原子的に移動しました。要件は厳格でした:クライアントが切断された場合(ウイルススキャンと原子的移動の間で select! によるタスクキャンセルを引き起こす)、ディスクスペースの枯渇を防ぐために、一時ファイルを直ちに削除しなければなりませんでした。
解決策 1: Drop での同期クリーンアップ。 std::fs::File とパス文字列をラップする TempFileGuard 構造体を実装しました。Drop 実装では、std::fs::remove_file を同期的に呼び出して一時ファイルを削除しました。利点: コードはシンプルで、スタックの巻き戻しやキャンセル中に実行が保証されました。欠点: std::fs::remove_file はブロッキングシステムコールです。Tokio ランタイムのワーカースレッド上で実行されると、高いディスク負荷の下でスレッドをミリ秒単位でブロックし、他のタスクを飢えさせ、非同期の非ブロッキング契約に違反しました。さらに、一時ファイルがネットワークファイルシステム (NFS) にある場合、ブロックが数秒に及び、壊滅的なレイテンシバブルを引き起こす可能性があります。
解決策 2: スポーンされたクリーンアップタスク。 ガードの Drop で、パス文字列をキャプチャし、非同期的に tokio::fs::remove_file を実行する切り離された tokio::task をスポーンしました。利点: これにより、ランタイムへの制御がすぐに返され、レイテンシが保たれました。欠点: ランタイムがすでにシャットダウン中または極端な負荷の下にあった場合、クリーンアップタスクは実行されない可能性があり、リソースリークにつながりました。さらに、このパターンでは、ガードがランタイムに対する Clone ハンドルを保持する必要があり、構造体のライフタイムが複雑になり、ランタイムがガードよりも前にドロップされた場合には使用後の解放の可能性がありました。
解決策 3: 同期的フォールバックを備えた明示的なキャンセルトークン。 tokio_util::sync::CancellationToken を利用し、アップロードロジックをキャンセルのチェックに構造化しました。キャンセルされた場合、一時ファイルがあるサイズ未満であれば同期削除を試み、そうでなければ、専用のバックグラウンドクリーンアップスレッド(std::thread を使用して生成)にキューイングしました。ガードの Drop は、パニックの稀なエッジケースのみを処理し、最後の手段として同期削除を使用しました。選択された解決策: 私たちはオプション 3 を選択しました。小さなファイルのための同期的パスと遅い操作のためのバックグラウンドスレッドをバランスさせ、Tokio ワーカーをブロックしないようにしました。その結果、10,000 の同時キャンセルによる負荷テスト中に一時ファイルのリークはゼロとなり、バックグラウンドスレッドが NFS のレイテンシペナルティを吸収したため、p99 レイテンシは安定した状態を維持しました。
候補者が見落としがちなこと
Drop 実装の内部で block_on を呼び出して非同期クリーンアップを実行することが、ほとんどの非同期ランタイムにおいて根本的に健全でないのはなぜですか?
Drop 内で block_on を呼び出そうとすると、再入可能性の危険が生じます。Drop はスタックの巻き戻し中または未来がキャンセルされたときに同期的に呼び出されます。現在のスレッドが Tokio (または async-std)ランタイムのワーカースレッドである場合、block_on は新しい未来の完成に向けてリアクタを駆動しようとします。しかし、ランタイムはすでに現在のタスク(ドロップされるもの)にスレッドを解放するのを待っています。これによりデッドロックが発生します:block_on はクリーニング未来をポーリングするためにリアクタの進行を待ちますが、リアクタはスレッドが block_on 内でブロックされているため進むことができません。さらに、Tokio のようなランタイムは、このシナリオを防ぐためにネストされた block_on 呼び出しが検出されると明示的にパニックを引き起こします。正しいアプローチは、クリーンアップを同期的に(瞬時である場合)行うか、専用のスレッドにオフロードすることです。決してデストラクタ内から非同期のエグゼキュータをブロックしてはいけません。
Future::poll メソッドのデザインが、なぜキャンセルが待機ポイントのみで発生できることを本質的に制限し、それがクリティカルセクションデザインにとってなぜ重要であるのか?
Future::poll メソッドは同期的であり、Poll::Ready または Poll::Pending を迅速に返す必要があります;実行中に Yield することはできません。await ポイントは、poll が Pending を返すときに状態を遷移させるコンパイラ生成の状態マシンへの構文糖です。エグゼキュータ(または select! マクロ)は、未来がアクティブに実行されていないとき(特に、Pending を返し、制御を解放しているとき)にのみその未来をドロップできます。したがって、キャンセルは poll 呼び出しに関連して原子的です。これが重要なのは、2 つの await ポイントの間のコード(「クリティカルセクション」)が非同期ランタイムの観点から完全に、またはまったく実行されることが保証されるからです。しかし、未来が await を超えて MutexGuard を保持している場合(Rust は標準 Mutex では禁止していますが tokio::sync::Mutex では許可しています)、キャンセルによって共有データが不整合な状態に置かれる可能性があります。候補者は、キャンセルが Live 変数の全てに Drop を実行する正確なそのサスペンションポイントの前にデータ構造の不変条件を回復する必要があることを保証しなければならないことを見落としがちです。
std::pin::Pin の文脈において、select! で使用される未来が Unpin または明示的にピン留めされる必要があるのはなぜか、そしてこれが部分的なドロップ中にメモリの不安全性をどのように防ぐのか?
select! は複数の未来をランダムにポーリングします。もし未来が !Unpin(例えば、自己参照ポインタや侵入的リストリンクを含む)であれば、最初の poll の後にそれを移動するとポインタが無効になります。Pin は未来のメモリアドレスが安定することを保証します。select! は未来が Unpin(移動を許可)であるか、すでに特定のメモリ位置に Pin されたものでなければなりません(スタックまたはヒープ)。ブランチが完了すると、select! は他の未来をドロップします。未来が Unpin であれば、ドロップグルーに移動されます。Pin されていれば、元のメモリアドレスでドロップされます。メモリ安全性の保証は、Pin が未来に対してドロップが呼び出される元のメモリアドレスで呼び出されることを保証することに由来し、ポーリングされた後に自己参照型の未来が移動された場合に生じる使用後の解放やダングリングポインタの問題を防ぎます。候補者は、Pin がポーリングだけでなく、キャンセルされた未来の破壊セマンティクスにも影響を与えることを見落としがちです。