C++ProgrammazioneSviluppatore C++ Senior

Riguardo al tipo di promessa delle coroutine C++20, quale tipo di ritorno specifico da `await_suspend` consente il trasferimento simmetrico senza stack?

Supera i colloqui con l'assistente IA Hintsage

Risposta alla domanda.

Storia della domanda

Le prime implementazioni delle coroutine erano basate su stack, allocando megabyte di spazio stack fisso per ogni cambio di contesto, il che limitava la concorrenza a migliaia di task. C++20 ha introdotto coroutine senza stack che allocano frame nel heap, ma la composizione ricorsiva naïve rischiava ancora di provocare overflow dello stack perché il trasferimento asimmetrico—ritornando void o bool da await_suspend—costringeva il riprenditore a chiamare resume(), accumulando frame dello stack nativo O(N). Il trasferimento simmetrico è stato standardizzato per consentire alla coroutine A di riprendere direttamente la coroutine B, rilasciando il frame dello stack di A tramite l'ottimizzazione obbligatoria delle tail-call.

Il problema

Quando la coroutine A esegue co_await sulla coroutine B, e B attende C, il trasferimento asimmetrico richiede che ogni invocazione di resume() ritorni al proprio chiamante prima di discendere più in profondità. Con una profondità di ricorsione N (ad esempio, attraversando oltre 50.000 nodi di un albero), questo esaurisce lo stack nativo nonostante ogni frame coroutine risieda nel heap, causando SIGSEGV o STATUS_STACK_OVERFLOW.

La soluzione

await_suspend deve restituire std::coroutine_handle<Promise> (o std::coroutine_handle<>). Il compilatore tratta ciò come una tail-call: distrugge il record di attivazione corrente e salta direttamente al punto di ripresa dell'handle target senza far crescere lo stack delle chiamate. Questo meccanismo garantisce un'esecuzione con profondità di stack costante indipendentemente dalla profondità logica di annidamento delle coroutine.

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; } // Asimmetrico (cattivo): void await_suspend(std::coroutine_handle<>) { target.resume(); } // Simmetrico (buono): ottimizzazione della tail-call std::coroutine_handle<> await_suspend(std::coroutine_handle<>) noexcept { return target; } void await_resume() noexcept {} };

Situazione dalla vita reale

Descrizione del problema

Durante lo sviluppo di un motore di trading ad alta frequenza, siamo migrati da I/O asincrono basato su callback a coroutine C++20 per modellare complesse strutture di prezzo dei derivati. Durante il collaudo con portafogli contenenti opzioni sintetiche annidate in profondità (oltre 50.000 livelli), il sistema è andato in crash con overflow dello stack nonostante l'uso di frame di coroutine allocati nel heap. Il colpevole era l'implementazione iniziale di await_suspend che restituiva void, il che causava la crescita dello stack nativo in proporzione alla profondità del modello di prezzo.

Diverse soluzioni considerate

Soluzione 1: Aumentare la dimensione dello stack nativo tramite ulimit -s o flag del linker.

I pro non richiedevano modifiche al codice e fornivano sollievo immediato durante il collaudo. I contro includevano lo spreco di gigabyte di memoria virtuale per thread, non affrontando scenari di ricorsione illimitata e creando incubi di portabilità tra Linux e Windows dove i meccanismi di allocazione dello stack differiscono significativamente.

Soluzione 2: Implementare un ciclo di esecuzione a trampoline che non ricorre mai.

I pro includevano mantenere la sintassi delle coroutine intatta mentre si spostava la gestione dello stack a un ciclo di eventi centrale. I contro comportavano significative penalizzazioni di latenza (centinaia di nanosecondi per cambio di contesto a causa dell'invio virtuale), complessità del codice aumentata nel gestore e perdita di ottimizzazioni del compilatore per l'allocazione dei registri attraverso i punti di sospensione.

Soluzione 3: Adottare il trasferimento simmetrico restituendo std::coroutine_handle da await_suspend.

I pro fornivano un'astrazione senza sovraccarico (assieme identico a macchine a stato scritte a mano), gestivano naturalmente la ricorsione illimitata senza crescita dello stack e mantenere una sintassi delle coroutine leggibile. I contro richiedevano supporto del compilatore C++20 (inizialmente limitato su alcune piattaforme embedded) e complicavano il debugging perché le tracce dello stack apparivano troncate a causa dell'eliminazione della tail-call.

Quale soluzione è stata scelta e perché

Abbiamo selezionato la Soluzione 3 perché i modelli finanziari richiedevano intrinsecamente una profondità di ricorsione illimitata per i calcoli di prezzo teorici. Il budget di latenza microsecondo non poteva tollerare il sovraccarico del trampolino, e i vincoli di memoria proibivano massive allocazioni pre-allocate dello stack. Il trasferimento simmetrico forniva l'unica soluzione senza costi che fosse sia corretta che efficiente.

Il risultato

Il motore ha elaborato con successo portafogli con oltre 100.000 livelli di annidamento senza andare in crash. I benchmark di latenza hanno mostrato prestazioni identiche alle macchine a stato ottimizzate a mano in C, e l'uso della memoria è rimasto costante indipendentemente dalla profondità della ricorsione. Il sistema è in produzione da 18 mesi senza crash legati allo stack.

Cosa spesso mancano i candidati

Perché il ritorno di await_suspend come void differisce dal ritorno di true in termini di temporizzazione della sospensione del frame della coroutine, e perché questo è importante per la sicurezza dei thread?

Molti candidati assumono che void implichi una sospensione immediata e un trasferimento di controllo. In realtà, restituire void sospende la coroutine corrente, ma il controllo ritorna al chiamante di resume(), che decide quindi il prossimo passo di esecuzione. Restituire true sospende anch'esso, ma è fondamentale: void garantisce che la coroutine sia sospesa prima che await_suspend ritorni, mentre la temporizzazione precisa della sospensione con bool può variare a seconda dell'implementazione. Questa distinzione è importante perché accedere a variabili locali della coroutine dopo che await_suspend restituisce void (ad esempio, da un altro thread) è sicuro solo dopo che il punto di sospensione è stato raggiunto. Con il trasferimento simmetrico (restituendo un handle), il frame dello stack viene distrutto immediatamente al ritorno, rendendo le variabili locali inaccessibili istantaneamente—i candidati spesso introducono condizioni di race accedendo a variabili catturate dopo aver avviato un trasferimento simmetrico.

Come interagisce il trasferimento simmetrico con la gestione delle eccezioni quando la coroutine target genera un'eccezione, e perché questo complica unhandled_exception nel tipo di promessa?

I candidati frequentemente non riescono a comprendere che il trasferimento simmetrico bypassa la normale discesa dello stack attraverso la coroutine in attesa. Quando la coroutine A trasferisce simmetricamente a B, e B genera un'eccezione, l'eccezione si propaga a unhandled_exception di B. Tuttavia, il frame dello stack di A è già stato sostituito tramite ottimizzazione delle tail-call, il che significa che A non può catturare eccezioni da B utilizzando try/catch attorno all'espressione co_await. L'eccezione si propaga invece al chiamante originale di A (il riprenditore), saltando potenzialmente il codice di pulizia di A a meno che unhandled_exception nella promessa di A gestisca lo stato esclusivamente tramite il frame allocato nel heap. I principianti assumono spesso che i guardiani dello stack RAII si attivino in A, portando a perdite di risorse quando si verificano eccezioni in catene simmetriche.

Qual è l'importanza di std::noop_coroutine() nelle catene di trasferimento simmetrico, e perché deve essere restituito piuttosto che un handle costruito per impostazione predefinita per indicare il completamento?

Un std::coroutine_handle costruito per impostazione predefinita è un handle nullo che presenta un comportamento indefinito se ripreso. Restituirlo da await_suspend indica "non riprendere nulla ora," lasciando la coroutine corrente sospesa senza un successore e potenzialmente bloccando il sistema se il pianificatore si aspetta un proseguimento valido. std::noop_coroutine() restituisce un handle singleton speciale che, quando ripreso, ritorna immediatamente al suo chiamante. Questo è cruciale per la terminazione: quando una leaf coroutine termina e desidera restituire il controllo al suo genitore senza ripresa manuale, restituisce std::noop_coroutine(). Questo consente a await_suspend del genitore (che ha trasferito simmetricamente al figlio) di ricevere un "continuazione" valida che si limita a restituire, concludendo in modo sicuro la catena. I candidati confondono gli handle nulle con gli handle noop, portando a sottili deadlock in cui il sistema delle coroutine attende per sempre su un target di ripresa nullo.