await_suspendからstd::coroutine_handleを返すことにより、対称転送が可能になり、これは保証された末尾呼び出し最適化(TCO)の一形態です。await_suspendがvoidを返すと、コルーチンランタイムは次のコルーチンを再開する前に呼び出し元に戻る必要があり、ネストされた呼び出しスタックがチェーンの長さに応じて線形に成長します。ハンドルを返すことにより、コンパイラはターゲットコルーチンの再開ポイントに直接ジャンプする(jmp命令)ことを行い、現在のアクティベーションレコードを再利用し、チェーンの長さに関係なく一定の**O(1)**スタック深度を維持します。
struct SymmetricTransfer { std::coroutine_handle<> next; // 末尾呼び出し最適化:スタック成長なし std::coroutine_handle<> await_suspend(std::coroutine_handle<>) { return next; } void await_resume() {} bool await_ready() { return false; } };
私たちはプロフェッショナル音楽制作ソフトウェア用のリアルタイムオーディオ処理エンジンを開発しました。このシステムは、500以上のデジタル信号処理(DSP)エフェクト(フィルター、コンプレッサー、リバーブ)のパイプラインを表現するためにC++20コルーチンを使用しました。ストレステスト中、アプリケーションは複雑なエフェクトラックを読み込む際にスタックオーバーフローでクラッシュしましたが、各コルーチンはわずかなローカルステートを持っていました。
解決策1:直接再開するvoid返却のawait_suspend 最初の実装では、**void await_suspend(std::coroutine_handle<>)を使用し、内部でnext.resume()を呼び出していました。このアプローチは直感的で逐次的なコードフローを提供し、標準のスタックトレースによるデバッグが容易でした。しかし、各resume()**呼び出しは前のコルーチンのサスペンションロジック内にネストされ、ステージごとに約16KBを消費し、500ステージだけで8MBのスレッドスタックを使い果たしました。
解決策2:非同期スケジューリングによる作業キュー 私たちは、各コルーチンが次のステージを作業項目として提出し、すぐにサスペンドする中央集権のタスクキューに直接チェーニングを置き換えることを考えました。これにより、再帰を反復に変換することでスタック使用が一定になることが保証されました。欠点は、キューのノードに対する動的割り当て、スレッド競合によるキャッシュスラッシング、パイプラインステージ間のキャッシュ局所性の喪失など、パフォーマンスの大幅な低下でした。これはサブミリ秒のレイテンシ要件に違反しました。
解決策3:coroutine_handleによる対称転送 私たちはawait_suspendをリファクタリングし、次のステージのstd::coroutine_handleを直接返すようにしました。これにより、コンパイラにTCOを実行させ、スタックフレームを圧縮することができました。この解決策はコルーチンのゼロコスト抽象を維持しつつ、O(1)のメモリ使用量を確保しました。主なリスクはライフタイム管理に関わり、ハンドルが返されると現在のコルーチンがサスペンドされ、戻りポイント以降にthisまたはローカル変数にアクセスすると未定義の動作が発生します。
選択した解決策と結果 私たちは解決策3を採用しました。リファクタリング後、パイプラインはわずか4KBのスタックスペースで512の連続するエフェクトを正常に処理し、クラッシュを排除し、決定論的なリアルタイムパフォーマンスを維持しました。この変更には、await_suspend内に戻り後のロジックが存在しないことを確認するための慎重なコードレビューが必要でしたが、堅牢でスケーラブルなアーキテクチャが得られました。
対称転送には、なぜawait_suspend内で次のコルーチンにco_awaitを使うのではなく、std::coroutine_handleを返す必要があるのですか? await_suspend内でco_awaitを使用すると、待機しているコルーチンは最初に完全にサスペンドされ、後で再開される必要があるため、必然的にランタイムに戻ることになり、スタックが成長します。ハンドルを直接返すことで、コンパイラは再開を末尾呼び出しとして扱うことができ、co_awaitは後で再開するために呼び出し元のフレームを保持する必要がある非対称サスペンションポイントを生成します。
再開されたコルーチンが最終サスペンドポイントに達する前に例外を投げる場合、対称転送は例外安全にどのように影響しますか?
対称転送されたコルーチンが例外を投げた場合、概念的には例外がawait_suspendフレームを巻き戻しますが、元のコルーチンはすでにサスペンドにマークされているため、そのフレームはスタックの巻き戻し中に破棄される必要があります。これには、サスペンドされたコルーチンのプロミスやキャプチャされたパラメータを破棄するための複雑な例外処理テーブルを生成する必要があります。候補者はしばしばカスタムpromise_typeアロケータが部分的な構築を正しく処理する必要があることを見落とし、例外の巻き戻し中に二重破壊バグのリスクがあります。
再帰データ構造から値を生成して返すジェネレーターを実装する場合に、対称転送を使用できない理由は何ですか?
ジェネレーターは、状態を維持しながら呼び出し元に制御を戻すためにco_yieldに依存しています。対称転送は無条件に別のコルーチンに制御を渡し、全体のチェーンが完了するまで元の呼び出し元に戻ることはありません。したがって、ジェネレーターは非対称サスペンションを使用する必要があります(await_suspendからvoidまたはtrueを返す)呼び出し元が生成された値を受け取り、後でジェネレーターを再開できるようにするために、別のコルーチンへの不可逆的な転送を強制するのではなく。