Geschiedenis van de vraag
Vroegere coroutine-implementaties waren stackvol, waarbij megabytes aan vaste stackruimte per contextwissel werden toegewezen, wat de gelijktijdigheid beperkte tot enkele duizenden taken. C++20 introduceerde stackloze coroutines die frames op de heap toewijzen, maar naïeve recursieve compositie bleef risico's op stackoverloop met zich meebrengen omdat asymmetrische overdracht—het retourneren van void of bool vanuit await_suspend—de hervatter dwong om resume() aan te roepen, waardoor O(N) native call stack frames werden opgebouwd. Symmetrische overdracht werd gestandaardiseerd om coroutine A direct coroutine B te laten hervatten, waarbij A's stackframe via verplichte tail-call optimalisatie werd afgestaan.
Het probleem
Wanneer coroutine A co_await uitvoert op coroutine B, en B wacht op C, vereist asymmetrische overdracht dat elke resume() aanroep terugkeert naar de aanroeper voordat dieper wordt afgedaald. Bij een recursiediepte van N (bijvoorbeeld het doorlopen van 50.000+ boomknooppunten) raakt de native stack uitgeput, ondanks dat elk coroutineframe zich op de heap bevindt, wat leidt tot SIGSEGV of STATUS_STACK_OVERFLOW.
De oplossing
await_suspend moet std::coroutine_handle<Promise> (of std::coroutine_handle<>) retourneren. De compiler beschouwt dit als een tail-call: het vernietigt het huidige activatierecord en springt direct naar het hervatpunt van de doelhandle zonder de call stack te laten groeien. Dit mechanisme garandeert constante stackdiepte-executie ongeacht de logische coroutine-nestdiepte.
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; } // Asymmetrisch (slecht): void await_suspend(std::coroutine_handle<>) { target.resume(); } // Symmetrisch (goed): Tail-call optimalisatie std::coroutine_handle<> await_suspend(std::coroutine_handle<>) noexcept { return target; } void await_resume() noexcept {} };
Probleembeschrijving
Tijdens de ontwikkeling van een high-frequency trading engine, migreerden we van callback-gebaseerde asynchrone I/O naar C++20 coroutines voor het modelleren van complexe derivatenprijsbomen. Tijdens stress testing met portefeuilles die diep geneste synthetische opties bevatten (50.000+ niveaus), crashte het systeem met stackoverflows ondanks het gebruik van op de heap toegewezen coroutine frames. De boosdoener was de initiële implementatie van await_suspend, die void retourneerde, wat leidde tot een proportionele groei van de native stack met de diepte van het prijsmodel.
Verschillende oplossingen overwogen
Oplossing 1: Vergroot de native stackgrootte via ulimit -s of linkerflags.
Voordelen vereisten geen codewijzigingen en boden directe verlichting tijdens tests. Nadelen omvatten het verspillen van gigabytes aan virtueel geheugen per thread, het niet aanpakken van onbeperkte recursiescenario's, en het creëren van portabiliteitsproblemen tussen Linux en Windows waar de mechanismen voor stackallocatie aanzienlijk verschillen.
Oplossing 2: Implementeer een trampoline-executorloop die nooit recursief is.
Voordelen omvatten het behoud van de coroutine-syntaxis terwijl het stackbeheer naar een centrale gebeurtenislus werd verplaatst. Nadelen omvatten aanzienlijke latentieboetes (honderden nanoseconden per contextwissel door virtuele dispatch), verhoogde codecomplexiteit in de scheduler, en verlies van compileroptimalisaties voor registerallocatie tussen opschortingpunten.
Oplossing 3: Adoptie van symmetrische overdracht door std::coroutine_handle te retourneren vanuit await_suspend.
Voordelen boden abstractie zonder overhead (identieke assembly aan handgeschreven statusmachines), handhaven van onbeperkte recursie zonder stackgroei, en behoud van leesbare coroutine-syntaxis. Nadelen vereisten C++20 compilerondersteuning (aanvankelijk beperkt op sommige embedded platforms) en gecompliceerde debugging omdat stacktraces als afgekapt verschenen vanwege tail-call eliminatie.
Welke oplossing werd gekozen en waarom
We kozen Oplossing 3 omdat de financiële modellen inherent onbeperkte recursiediepte vereisten voor theoretische prijsberekeningen. De microseconde-latentiebudget kon de overhead van trampolining niet tolereren, en geheugenbeperkingen stonden grote stackvoorspelling niet toe. Symmetrische overdracht bood de enige nul-kostenoplossing die zowel correct als efficiënt was.
Het resultaat
De engine verwerkte succesvol portefeuilles met 100.000+ genestingsniveaus zonder te crashen. Latentietests toonden identieke prestaties aan als hand-geoptimaliseerde C statusmachines, en het geheugengebruik bleef vlak ongeacht de recursiediepte. Het systeem draait al 18 maanden in productie zonder stackgerelateerde crashes.
Waarom verschilt het retourneren van void door await_suspend van het retourneren van true met betrekking tot de timingen van coroutine frame opschorting, en waarom is dit belangrijk voor threadveiligheid?
Veel kandidaten nemen aan dat void onmiddellijke opschorting en overdracht van controle impliceert. Eigenlijk blijft de huidige coroutine opgeschort bij het retourneren van void, maar de controle keert terug naar de aanroeper van resume(), die dan de volgende uitvoeringsstap beslist. Het retourneren van true schort ook op, maar cruciaal is dat void garandeert dat de coroutine is opgeschort voordat await_suspend terugkeert, terwijl de exacte timing van opschorting met bool kan variëren per implementatie. Dit onderscheid is belangrijk omdat toegang tot coroutine-lokalen nadat await_suspend void retourneert (bijvoorbeeld vanaf een andere thread) alleen veilig is nadat het opschortingpunt is bereikt. Met symmetrische overdracht (het retourneren van een handle) wordt het stackframe onmiddellijk vernietigd bij terugkeer, waardoor lokale variabelen direct ontoegankelijk worden—kandidaten veroorzaken vaak dataraces door toegang tot gevangen variabelen na het initiëren van een symmetrische overdracht.
Hoe interacteert symmetrische overdracht met uitzondering verwerking wanneer de doel coroutine een uitzondering gooit, en waarom compliceert dit unhandled_exception in het belofte type?
Kandidaten missen vaak dat symmetrische overdracht normale stackontspanning omzeilt via de wachtende coroutine. Wanneer coroutine A symmetrisch overdraagt naar B, en B een uitzondering gooit, wordt de uitzondering gepropageerd naar B’s unhandled_exception. Echter, A’s stackframe is al vervangen via tail-call optimalisatie, wat betekent dat A geen uitzonderingen van B kan vangen met try/catch rond de co_await expressie. De uitzondering wordt in plaats daarvan gepropageerd naar A’s originele aanroeper (de hervatter), wat mogelijk A’s opruimcode overslaat tenzij unhandled_exception in A’s belofte toestand exclusief door het heap-geallocateerde frame beheert. Beginners nemen vaak aan dat RAII-stackbewakers zullen afgaan in A, wat leidt tot hulpbronnenlekken wanneer uitzonderingen zich voordoen in symmetrische ketens.
Wat is de betekenis van std::noop_coroutine() in symmetrische overdrachtketens, en waarom moet dit worden geretourneerd in plaats van een standaard geconstrueerde handle om voltooiing aan te geven?
Een standaard geconstrueerde std::coroutine_handle is een null-handle die ongeconditioneerd gedrag vertoont als deze wordt hervat. Het retourneren ervan vanuit await_suspend geeft aan "herstart niets nu", waardoor de huidige coroutine opgeschort blijft zonder een opvolger en het systeem potentieel vastloopt als de planner een geldige voortzetting verwacht. std::noop_coroutine() retourneert een speciale singleton-handle die, wanneer hervat, onmiddellijk terugkeert naar zijn aanroeper. Dit is cruciaal voor beëindiging: wanneer een bladcoroutine eindigt en controle naar zijn ouder wil teruggeven zonder handmatige hervatting, retourneert het std::noop_coroutine(). Dit stelt de ‘await_suspend’ van de ouder (die symmetrisch naar het kind heeft overgedragen) in staat om een geldige "voortzetting" te ontvangen die eenvoudig terugkeert, waardoor de keten veilig wordt beëindigd. Kandidaten verwarren null-handles met noop-handles, wat leidt tot subtiele deadlocks waarbij het coroutine-systeem voor altijd wacht op een null-herstartdoel.