Die Rückgabe von std::coroutine_handle aus await_suspend ermöglicht symmetrische Übertragung, eine Form der garantierten Tail-Call-Optimierung (TCO). Wenn await_suspend void zurückgibt, muss die Korroutine zur ihren Aufrufer zurückkehren, bevor die nächste Korroutine fortgesetzt wird, was einen geschachtelten Aufruf-Stack erzeugt, der linear mit der Kettenlänge wächst. Durch die Rückgabe eines Handlers gibt der Compiler einen direkten Sprung (jmp-Anweisung) zum Fortsetzungsunkt der Zielkorroutine aus, wodurch das aktuelle Aktivierungsprotokoll wiederverwendet wird und die Stapeltiefe konstant O(1) bleibt, unabhängig von der Kettenlänge.
struct SymmetrischeÜbertragung { std::coroutine_handle<> nächste; // Tail-call optimiert: kein Stack-Wachstum std::coroutine_handle<> await_suspend(std::coroutine_handle<>) { return nächste; } void await_resume() {} bool await_ready() { return false; } };
Wir haben eine Echtzeit-Audioverarbeitungs-Engine für professionelle Musikproduktionssoftware entwickelt. Das System verwendete C++20-Korroutinen, um eine Pipeline von über 500 digitalen Signalverarbeitungs-(DSP)-Effekten (Filter, Kompressoren, Hall) darzustellen, die miteinander verkettet waren. Während von Lasttests stürzte die Anwendung mit einem Stack Overflow ab, als komplexe Effekt-Racks geladen wurden, obwohl jeder einzelne Korroutine einen minimalen lokalen Zustand hatte.
Lösung 1: Void zurückgebende await_suspend mit direkter Fortsetzung Die erste Implementierung verwendete void await_suspend(std::coroutine_handle<>) und rief intern nächste.resume() auf. Dieser Ansatz bot einen intuitive, sequentiellen Codefluss und einfache Fehlersuche durch standardisierte Stapelspuren. Allerdings verschachtelte jeder resume()-Aufruf innerhalb der Aussetzung der vorherigen Korroutine, was ungefähr 16 KB pro Stufe verbrauchte und den 8 MB-Thread-Stack nach nur 500 Stufen erschöpfte.
Lösung 2: Arbeitswarteschlange mit asynchroner Planung Wir zogen in Betracht, die direkte Verkettung durch eine zentrale Aufgabenwarteschlange zu ersetzen, in der jede Korroutine die nächste Stufe als Arbeitsobjekt einreichte und sofort aussetzte. Dies gewährte einen konstanten Stackverbrauch, indem die Rekursion in Iteration umgewandelt wurde. Der Nachteil war eine erhebliche Leistungsverschlechterung: dynamische Zuweisungen für Warteschlangen-Knoten, Cache-Durchmischung durch Thread-Konkurrenz und Verlust der Cache-Lokalität zwischen den Pipelinestufen, was unseren Anforderungen an eine Unter-Millisekunden-Latenz widersprach.
Lösung 3: Symmetrische Übertragung über coroutine_handle Wir haben await_suspend umgestaltet, um direkt den std::coroutine_handle der nächsten Stufe zurückzugeben. Das signalisierte dem Compiler, TCO durchzuführen und die Stapelrahmen zusammenzuführen. Die Lösung bewahrte die null-Kosten-Abstraktion von Korrutinen und stellte sicher, dass der Speicherverbrauch O(1) blieb. Das Hauptproblem war das Lebenszyklusmanagement: Sobald der Handle zurückgegeben wurde, wurde die aktuelle Korroutine ausgesetzt, und der Zugriff auf this oder lokale Variablen nach dem Rückgabepunkt führte zu undefiniertem Verhalten.
Gewählte Lösung und Ergebnis Wir haben Lösung 3 übernommen. Nach der Umgestaltung konnte die Pipeline erfolgreich 512 aufeinanderfolgende Effekte mit nur 4 KB Stapelspeicher verarbeiten, wodurch Abstürze beseitigt und eine deterministische Echtzeitleistung aufrechterhalten wurde. Die Änderung erforderte sorgfältige Code-Überprüfungen, um sicherzustellen, dass im await_suspend keine Logik nach der Rückkehr existierte, führte aber zu einer robusten, skalierbaren Architektur.
Warum erfordert die symmetrische Übertragung die Rückgabe von std::coroutine_handle, anstatt co_await in der nächsten Korroutine innerhalb von await_suspend zu verwenden?
Die Verwendung von co_await innerhalb von await_suspend würde erfordern, dass die wartende Korroutine zuerst vollständig ausgesetzt wird, um dann später fortgesetzt zu werden, was zwangsläufig das Zurückkehren zur Laufzeit und das Wachsen des Stack beinhaltet. Die direkte Rückgabe des Handlers ermöglicht es dem Compiler, die Fortsetzung als Tail-Call zu behandeln, während co_await einen asymmetrischen Aussetzpunkt erzeugt, der den Rahmen des Aufrufers bewahren muss, um ihn später fortzusetzen.
Wie beeinflusst die symmetrische Übertragung die Ausnahmesicherheit, wenn die fortgesetzte Korroutine wirft, bevor sie ihren endgültigen Aussetzpunkt erreicht?
Wenn die symmetrisch übertragene Korroutine wirft, entfaltet sich die Ausnahme konzeptionell durch den await_suspend-Rahmen, aber da die ursprüngliche Korroutine bereits als ausgesetzt markiert ist, muss ihr Rahmen während der Stack-Entfaltung zerstört werden. Dies erfordert, dass der Compiler komplexe Ausnahmenbehandlungstabellen erzeugt, die das Versprechen und die erfassten Parameter der ausgesetzten Korroutine zerstören. Kandidaten übersehen oft, dass benutzerdefinierte promise_type-Zuweiser die teilweise Konstruktion korrekt behandeln müssen, um Risiken von Doppelzerstörung-Fehlern während der Ausnahmebehandlung zu vermeiden.
Was verhindert die Verwendung von symmetrischer Übertragung bei der Implementierung eines Generators, der Werte aus einer rekursiven Datenstruktur zurückgibt?
Generatoren verlassen sich auf co_yield, um die Kontrolle an den Aufrufer zurückzugeben, während sie ihren Zustand beibehalten. Symmetrische Übertragung übergibt bedingungslos die Kontrolle an eine andere Korroutine und kehrt niemals zum ursprünglichen Aufrufer zurück, bis die gesamte Kette abgeschlossen ist. Daher müssen Generatoren asymmetrische Aussetzungen verwenden (Rückgabe von void oder true aus await_suspend), um es dem Verbraucher zu ermöglichen, den yielded-Wert zu empfangen und möglicherweise den Generator später fortzusetzen, anstatt eine irreversible Übertragung an eine andere Korroutine zu erzwingen.