Historique de la question
Les premières implémentations de coroutines étaient basées sur la pile, allouant des mégaoctets d'espace de pile fixe par changement de contexte, ce qui limitait la concurrence à des milliers de tâches. C++20 a introduit des coroutines sans pile allouant des cadres sur le tas, mais une composition récursive naïve risquait toujours un débordement de pile en raison d'un transfert asymétrique — renvoyant void ou bool depuis await_suspend — obligeant le repreneur à appeler resume(), construisant ainsi des cadres de piles d'appels natifs O(N). Le transfert symétrique a été standardisé pour permettre à la coroutine A de reprendre directement la coroutine B, renonçant au cadre de pile de A par une optimisation de tail-call obligatoire.
Le problème
Lorsque la coroutine A effectue co_await sur la coroutine B, et que B attend C, le transfert asymétrique nécessite que chaque invocation de resume() revienne à son appelant avant de descendre plus profondément. Avec une profondeur de récursion N (par exemple, parcourant plus de 50 000 nœuds d'arbre), cela épuise la pile native malgré chaque cadre de coroutine résidant sur le tas, provoquant un SIGSEGV ou un STATUS_STACK_OVERFLOW.
La solution
await_suspend doit renvoyer std::coroutine_handle<Promise> (ou std::coroutine_handle<>). Le compilateur considère cela comme une tail-call : il détruit l'enregistrement d'activation actuel et saute directement au point de reprise de la poignée cible sans agrandir la pile d'appels. Ce mécanisme garantit une exécution à profondeur de pile constante, quelle que soit la profondeur logique de l'imbrication des coroutines.
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; } // Asymétrique (mauvais) : void await_suspend(std::coroutine_handle<>) { target.resume(); } // Symétrique (bon) : optimisation de tail-call std::coroutine_handle<> await_suspend(std::coroutine_handle<>) noexcept { return target; } void await_resume() noexcept {} };
Description du problème
Lors du développement d'un moteur de trading à haute fréquence, nous avons migré d'E/S asynchrone basée sur des rappels vers des coroutines C++20 pour modéliser des arbres de valorisation de dérivés complexes. Pendant les tests de résistance avec des portefeuilles contenant des options synthétiques profondément imbriquées (plus de 50 000 niveaux), le système s'est planté avec des débordements de pile malgré l'utilisation de cadres de coroutine alloués sur le tas. Le coupable était l'implémentation initiale de await_suspend renvoyant void, ce qui a entraîné une croissance de la pile native proportionnelle à la profondeur du modèle de valorisation.
Différentes solutions envisagées
Solution 1 : Augmenter la taille de la pile native via ulimit -s ou des drapeaux de l’éditeur de liens.
Les avantages nécessitaient zéro modification de code et offraient un soulagement immédiat lors des tests. Les inconvénients incluaient le gaspillage de gigaoctets de mémoire virtuelle par thread, l'incapacité à traiter des scénarios de récursion non bornée, et la création de cauchemars de portabilité entre Linux et Windows où les mécanismes d'allocation de pile diffèrent considérablement.
Solution 2 : Mettre en œuvre une boucle d'exécution de trampoline qui ne rappelle jamais.
Les avantages incluaient le maintien de la syntaxe des coroutines intacte tout en déplaçant la gestion des piles vers une boucle d’événements centrale. Les inconvénients impliquaient des pénalités de latence significatives (des centaines de nanosecondes par changement de contexte en raison du dispatch virtuel), une complexité accrue du code dans le planificateur, et la perte d'optimisations du compilateur pour l'allocation de registre à travers les points de suspension.
Solution 3 : Adopter le transfert symétrique en renvoyant std::coroutine_handle depuis await_suspend.
Les avantages offraient une abstraction sans coût (identique à l'assemblage des machines à état écrites à la main), géraient naturellement la récursion non bornée sans croissance de pile, et maintenaient une syntaxe de coroutine lisible. Les inconvénients nécessitaient un support du compilateur C++20 (initialement limité sur certaines plateformes embarquées) et compliquaient le débogage car les traces de pile apparaissent tronquées en raison de l'élimination des tail-calls.
Quelle solution a été choisie et pourquoi
Nous avons sélectionné la solution 3 car les modèles financiers exigeaient intrinsèquement une profondeur de récursion non bornée pour les calculs de valorisation théorique. Le budget de latence en microsecondes ne pouvait pas supporter les frais généraux de trampoline, et les contraintes mémoire interdisaient une pré-allocation massive de la pile. Le transfert symétrique fournissait la seule solution sans coût qui était à la fois correcte et efficace.
Le résultat
Le moteur a réussi à traiter des portefeuilles avec plus de 100 000 niveaux d'imbrication sans planter. Les benchmarks de latence montraient des performances identiques à celles des machines à état optimisées à la main en C, et l'utilisation de la mémoire restait stable quelle que soit la profondeur de récursion. Le système fonctionne en production depuis 18 mois sans aucun plantage lié à la pile.
Pourquoi le retour de await_suspend à void diffère de celui du true en termes de timing de suspension du cadre de coroutine, et pourquoi cela compte-t-il pour la sécurité des threads ?
De nombreux candidats supposent que void implique une suspension immédiate et un transfert de contrôle. En réalité, renvoyer void suspend la coroutine actuelle, mais le contrôle revient à l'appelant de resume(), qui décide alors de l'étape d'exécution suivante. Le retour de true suspend également, mais de manière critique, void garantit que la coroutine est suspendue avant que await_suspend ne renvoie, tandis que le timing précis de la suspension avec bool peut varier selon l'implémentation. Cette distinction compte car l'accès aux locaux de coroutine après que await_suspend a renvoyé void (par exemple, depuis un autre thread) est sûr uniquement après que le point de suspension soit atteint. Avec le transfert symétrique (renvoyant une poignée), le cadre de pile est détruit immédiatement au retour, rendant les locaux inaccessibles instantanément — les candidats introduisent souvent des courses aux données en accédant à des variables capturées après l'initiation d'un transfert symétrique.
Comment le transfert symétrique interagit-il avec la gestion des exceptions lorsque la coroutine cible lance une exception, et pourquoi cela complique-t-il unhandled_exception dans le type de promesse ?
Les candidats oublient souvent que le transfert symétrique contourne le déballage normal de la pile à travers la coroutine en attente. Lorsque la coroutine A transfère symétriquement à B, et que B lance une exception, l'exception se propage vers unhandled_exception de B. Cependant, le cadre de pile de A a déjà été remplacé via une optimisation de tail-call, ce qui signifie que A ne peut pas attraper d'exceptions de B en utilisant try/catch autour de l'expression co_await. L'exception se propage plutôt vers l'appelant original de A (le repreneur), contournant potentiellement le code de nettoyage de A à moins que unhandled_exception dans la promesse de A ne gère l'état exclusivement à travers le cadre alloué sur le tas. Les débutants supposent souvent que les gardes de pile RAII se déclencheront dans A, entraînant des fuites de ressources lorsque des exceptions se produisent dans des chaînes symétriques.
Quelle est la signification de std::noop_coroutine() dans les chaînes de transfert symétriques, et pourquoi doit-elle être renvoyée plutôt qu'une poignée par défaut construite pour indiquer l'achèvement ?
Une std::coroutine_handle construite par défaut est une poignée nulle qui présente un comportement indéfini si elle est reprise. La renvoyer depuis await_suspend indique "ne rien reprendre maintenant", laissant la coroutine actuelle suspendue sans successeur et potentiellement suspendant le système si le planificateur s'attend à une continuation valide. std::noop_coroutine() renvoie une poignée singleton spéciale qui, lorsqu'elle est reprise, revient immédiatement à son appelant. Cela est crucial pour la terminaison : lorsque une coroutine feuille se termine et souhaite retourner le contrôle à son parent sans reprise manuelle, elle renvoie std::noop_coroutine(). Cela permet au await_suspend du parent (qui a transféré symétriquement à l'enfant) de recevoir une "continuation" valide qui se contente de retourner, mettant effectivement fin à la chaîne en toute sécurité. Les candidats confondent souvent les poignées nulles avec les poignées noop, menant à des blocages subtils où le système de coroutine attend éternellement une cible de reprise nulle.