質問の歴史
初期のコルーチン実装はスタックフルで、コンテキストスイッチごとにメガバイトの固定スタックスペースを確保し、これにより同時実行性は数千のタスクに制限されていました。C++20は、フレームをヒープに割り当てるスタックレスコルーチンを導入しましたが、 naiveな再帰的合成は、非対称転送(await_suspendからvoidまたはboolを返すこと)がリズーマーがresume()を呼び出すことを余儀なくさせ、O(N)のネイティブコールスタックフレームを構築するリスクを抱えていました。対称転送は、コルーチンAがコルーチンBを直接再開できるように規格化され、必須の尾呼び出し最適化を通じてAのスタックフレームを放棄します。
問題
コルーチンAがコルーチンBでco_awaitを行い、BがCを待機していると、非対称転送では各resume()呼び出しが呼び出し元に戻り、その後さらに深く降下する必要があります。再帰の深さN(例:50,000以上のツリーノードを横断)の場合、各コルーチンフレームがヒープ上に存在するにもかかわらず、ネイティブスタックが枯渇し、SIGSEGVまたはSTATUS_STACK_OVERFLOWを引き起こします。
解決策
await_suspendはstd::coroutine_handle<Promise>(またはstd::coroutine_handle<>)を返さなければなりません。コンパイラはこれを尾呼び出しとして扱います:現在のアクティベーションレコードを破棄し、コールスタックを増やさずにターゲットハンドルの再開ポイントに直接ジャンプします。このメカニズムは、論理的なコルーチンのネストの深さに関わらず、一定のスタック深度での実行を保証します。
struct Task { struct promise_type { Task get_return_object() { return Task{std::coroutine_handle<promise_type>::from_promise(*this)}; } std::suspend_always initial_suspend() { return {}; } std::suspend_always final_suspend() noexcept { return {}; } void return_void() {} void unhandled_exception() {} }; std::coroutine_handle<> h; }; struct SymmetricAwaiter { std::coroutine_handle<> target; bool await_ready() const noexcept { return false; } // 非対称(悪い):void await_suspend(std::coroutine_handle<>) { target.resume(); } // 対称(良い):尾呼び出し最適化 std::coroutine_handle<> await_suspend(std::coroutine_handle<>) noexcept { return target; } void await_resume() noexcept {} };
問題の説明
高頻度取引エンジンを開発する際、複雑なデリバティブ価格決定ツリーのモデリングのために、コールバックベースの非同期I/OからC++20コルーチンに移行しました。深くネストされた合成オプション(50,000以上のレベル)を含むポートフォリオでのストレステスト中、システムはヒープに割り当てられたコルーチンフレームを使用しているにもかかわらず、スタックオーバーフローでクラッシュしました。原因は、await_suspendがvoidを返す最初の実装であり、これが価格モデルの深さに比例してネイティブスタックを増加させました。
考慮された異なる解決策
解決策1:ulimit -sやリンカーフラグを介してネイティブスタックサイズを増加させる。
利点は、コード変更なしで直ちにテスト中の緩和が提供されました。欠点には、スレッドごとにギガバイトの仮想メモリを無駄にし、無制限の再帰シナリオに対処できず、LinuxとWindowsの間でスタック割り当てメカニズムに大きな違いがあるため、ポータビリティの悪夢を生み出すことが含まれました。
解決策2:再帰を行わないトランポリン実行ループを実装する。
利点には、スタック管理を中央イベントループに移行しながらコルーチン構文を保持できることが含まれました。欠点には、仮想ディスパッチによるコンテキストスイッチごとのレイテンシペナルティが数百ナノ秒に達すること、スケジューラのコード Complexityが増加すること、サスペンションポイント間のレジスタ割り当てに関するコンパイラの最適化が失われることが含まれました。
解決策3:await_suspendからstd::coroutine_handleを戻すことで対称転送を採用する。
利点は、ゼロオーバーヘッドの抽象化(手書きのステートマシンと同一のアセンブリ)、スタック成長なしで無制限の再帰を自然に処理し、読みやすいコルーチン構文を維持できることが含まれます。欠点は、C++20コンパイラのサポートが必要(当初、いくつかの組み込みプラットフォームで制限あり)で、尾呼び出しの排除によりスタックトレースが切り詰められるため、デバッグが複雑になることが含まれました。
どの解決策が選択され、なぜ
金融モデルが理論価格計算に対して無制限の再帰深さを本質的に必要としていたため、解決策3が選択されました。マイクロ秒のレイテンシ予算はトランポリンのオーバーヘッドを許容できず、メモリの制約は大規模なスタックの前割り当てを禁止しました。対称転送は、正確かつ効率的な唯一のゼロコストの解決策を提供しました。
結果
エンジンは、クラッシュすることなく100,000以上のネストレベルを持つポートフォリオの処理に成功しました。レイテンシベンチマークは、手動最適化されたCステートマシンと同一のパフォーマンスを示し、メモリ使用量は再帰の深さに関わらず平坦に保たれました。このシステムは、スタック関連のクラッシュなしに18か月間本番で稼働しています。
なぜawait_suspendがvoidを返すことが、trueを返すこととコルーチンフレームのサスペンションタイミングにおいて異なるのか、そしてなぜそれがスレッドセーフ性にとって重要なのか?
多くの候補者は、voidが即時のサスペンションと制御の移行を意味すると考えています。実際には、voidを返すことは現在のコルーチンをサスペンドしますが、制御はresume()の呼び出し元に戻り、その後次の実行ステップを決定します。trueを返すこともサスペンドしますが、重要なのは、voidはawait_suspendが返る前にコルーチンがサスペンドされることを保証し、boolとのサスペンションの正確なタイミングは実装によって異なる可能性があります。この違いは、await_suspendがvoidを返した後にコルーチンのローカルにアクセスすることが(他のスレッドから)安全なのは、サスペンションポイントに達した後だけであるため、重要です。対称転送で(ハンドルを返すこと)、スタックフレームは返されるとすぐに破棄され、ローカルは即座にアクセスできなくなります。候補者は、対称転送を開始した後にキャプチャされた変数にアクセスすることでデータ競合を引き起こすことがあります。
対称転送が例外処理とどのように相互作用するか、ターゲットコルーチンが例外をスローした場合、なぜこれがプロミスタイプでのunhandled_exceptionを複雑にするか?
候補者は、対称転送が待機しているコルーチンを通して通常のスタックの解放をバイパスすることを見逃すことがよくあります。コルーチンAがBに対称転送を行い、Bが例外をスローすると、例外はBのunhandled_exceptionに伝播します。しかし、Aのスタックフレームはすでに尾呼び出し最適化を介して置き換えられているため、Aはco_await式の周りでtry/catchを使用してBからの例外をキャッチできません。例外は代わりにAの元の呼び出し元(リズーマー)に伝播し、Aのクリーンアップコードをスキップする可能性があります。これは、Aのプロミスがヒープに割り当てられたフレームを介して状態を管理している場合を除いてです。初心者はRAIIスタックガードがAで発火することを仮定し、対称チェーン内で例外が発生した場合にリソースリークを引き起こします。
対称転送チェーンにおけるstd::noop_coroutine()の重要性は何か、なぜ完了を示すためにデフォルト構築されたハンドルではなく、それを返さなければならないのか?
デフォルト構築されたstd::coroutine_handleはヌルハンドルであり、再開した場合には未定義の動作を示します。これをawait_suspendから返すことは「今、何も再開しない」ということを示し、現コルーチンが後続者なしにサスペンドされたまま残り、スケジューラが有効な継続を期待している場合にシステムがハングしてしまう可能性があります。**std::noop_coroutine()**は特別なシングルトンハンドルを返し、再開されると即座に呼び出し元に戻ります。これは終了にとって重要です:葉コルーチンが終了し、手動の再開なしに親に制御を戻したいとき、それはstd::noop_coroutine()を返します。これにより、子に対して対称転送を行った親のawait_suspendが有効な「継続」を受け取り、安全にチェーンを効果的に終了することができます。候補者はヌルハンドルをnoopハンドルと混同し、コルーチンシステムがヌルの再開ターゲットで永遠に待ち続ける微妙なデッドロックを引き起こすことがあります。