Historia de la pregunta
Las primeras implementaciones de coroutine eran con pila, asignando megabytes de espacio de pila fijos por cambio de contexto, lo que limitaba la concurrencia a miles de tareas. C++20 introdujo coroutines sin pila que asignan marcos en el heap, sin embargo, la composición recursiva ingenua todavía ponía en riesgo el desbordamiento de pila porque la transferencia asimétrica—devolver void o bool desde await_suspend—forzaba al reanudador a llamar a resume(), construyendo marcos de pila nativa O(N). La transferencia simétrica fue estandarizada para permitir que la coroutine A reanude directamente a la coroutine B, cediendo el marco de pila de A a través de la optimización de llamada final obligatoria.
El problema
Cuando la coroutine A realiza co_await sobre la coroutine B, y B espera a C, la transferencia asimétrica requiere que cada invocación de resume() regrese a su llamador antes de descender más. Con una profundidad de recursión N (por ejemplo, recorriendo 50,000+ nodos de árbol), esto agota la pila nativa a pesar de que cada marco de coroutine reside en el heap, causando SIGSEGV o STATUS_STACK_OVERFLOW.
La solución
await_suspend debe devolver std::coroutine_handle<Promise> (o std::coroutine_handle<>). El compilador trata esto como una llamada final: destruye el registro de activación actual y salta directamente al punto de reanudación del manejador objetivo sin aumentar la pila de llamadas. Este mecanismo garantiza la ejecución con profundidad de pila constante independientemente de la profundidad lógica de anidamiento de 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; } // Asimétrico (malo): void await_suspend(std::coroutine_handle<>) { target.resume(); } // Simétrico (bueno): Optimización de llamada final std::coroutine_handle<> await_suspend(std::coroutine_handle<>) noexcept { return target; } void await_resume() noexcept {} };
Descripción del problema
Mientras desarrollábamos un motor de comercio de alta frecuencia, migramos de I/O asíncrono basado en callbacks a coroutines de C++20 para modelar complejos árboles de precios de derivados. Durante las pruebas de estrés con carteras que contenían opciones sintéticas profundamente anidadas (50,000+ niveles), el sistema falló con desbordamientos de pila a pesar de usar marcos de coroutine asignados en el heap. El culpable fue la implementación inicial de await_suspend que devolvía void, lo que hacía que la pila nativa creciera proporcionalmente a la profundidad del modelo de precios.
Diferentes soluciones consideradas
Solución 1: Aumentar el tamaño de la pila nativa a través de ulimit -s o flags del enlazador.
Los pros requerían cero cambios de código y proporcionaban alivio inmediato durante las pruebas. Los contras incluían el desperdicio de gigabytes de memoria virtual por hilo, no abordando escenarios de recursión no acotada, y creando pesadillas de portabilidad entre Linux y Windows donde los mecanismos de asignación de pila difieren significativamente.
Solución 2: Implementar un bucle de executor de trampa que nunca recursione.
Los pros incluían mantener la sintaxis de coroutine intacta mientras se movía la gestión de pila a un bucle de eventos central. Los contras involucraban penalizaciones significativas de latencia (cientos de nanosegundos por cambio de contexto debido a la dispatch virtual), mayor complejidad de código en el programador, y pérdida de optimizaciones de compilador para la asignación de registros a través de puntos de suspensión.
Solución 3: Adoptar transferencia simétrica al devolver std::coroutine_handle de await_suspend.
Los pros proporcionaban una abstracción sin sobrecosto (ensamblador idéntico a máquinas de estado escritas a mano), manejando naturalmente la recursión no acotada sin crecimiento de pila, y manteniendo una sintaxis de coroutine legible. Los contras requerían soporte de compilador C++20 (inicialmente limitado en algunas plataformas embebidas) y complicaban la depuración porque las trazas de pila aparecían truncadas debido a la eliminación de llamadas finales.
Qué solución se eligió y por qué
Seleccionamos la Solución 3 porque los modelos financieros requerían inherentemente una profundidad de recursión no acotada para cálculos de precios teóricos. El presupuesto de latencia de microsegundos no podía tolerar la sobrecarga de trampolinas, y las restricciones de memoria prohibían la pre-asignación masiva de pila. La transferencia simétrica proporcionó la única solución sin costo que era tanto correcta como eficiente.
El resultado
El motor procesó exitosamente carteras con más de 100,000 niveles de anidamiento sin colapsar. Las métricas de latencia mostraron un rendimiento idéntico a las máquinas de estado optimizadas a mano en C, y el uso de memoria se mantuvo constante independientemente de la profundidad de la recursión. El sistema ha estado en producción durante 18 meses sin colapsos relacionados con la pila.
¿Por qué difiere await_suspend al devolver void de devolver true en términos de tiempo de suspensión del marco de coroutine, y por qué esto es importante para la seguridad de los hilos?
Muchos candidatos suponen que void implica una suspensión inmediata y una transferencia de control. En realidad, devolver void suspende la coroutine actual, pero el control regresa al llamador de resume(), quien luego decide el siguiente paso de ejecución. Devolver true también suspende, pero, de manera crítica, void garantiza que la coroutine esté suspendida antes de que await_suspend devuelva, mientras que el momento preciso de la suspensión con bool puede variar según la implementación. Esta distinción es importante porque acceder a las locales de coroutine después de que await_suspend devuelva void (por ejemplo, desde otro hilo) es seguro solo después de que se alcanza el punto de suspensión. Con la transferencia simétrica (devolviendo un manejador), el marco de pila se destruye inmediatamente al regresar, lo que hace que las locales sean accesibles instantáneamente—los candidatos a menudo introducen condiciones de carrera al acceder a variables capturadas después de iniciar una transferencia simétrica.
¿Cómo interactúa la transferencia simétrica con el manejo de excepciones cuando la coroutine objetivo lanza, y por qué complica esto unhandled_exception en el tipo de promesa?
Los candidatos a menudo pasan por alto que la transferencia simétrica elude el deslizamiento de pila normal a través de la coroutine en espera. Cuando la coroutine A transfiere simétricamente a B, y B lanza una excepción, la excepción se propaga a unhandled_exception de B. Sin embargo, el marco de pila de A ya ha sido reemplazado a través de la optimización de llamada final, lo que significa que A no puede capturar excepciones de B usando try/catch alrededor de la expresión co_await. La excepción, en cambio, se propaga al llamador original de A (el reanudador), posiblemente omitiendo el código de limpieza de A a menos que unhandled_exception en la promesa de A maneje el estado exclusivamente a través del marco asignado en el heap. Los principiantes a menudo suponen que los guardias de pila de RAII se dispararán en A, lo que lleva a fugas de recursos cuando ocurren excepciones en cadenas simétricas.
¿Cuál es la importancia de std::noop_coroutine() en las cadenas de transferencia simétrica, y por qué debe devolverse en lugar de un manejador construido por defecto para indicar finalización?
Un std::coroutine_handle construido por defecto es un manejador nulo que exhibe comportamiento indefinido si se reanuda. Devolverlo desde await_suspend indica "no reanudar nada ahora", dejando la coroutine actual suspendida sin un sucesor y potencialmente colgando el sistema si el programador espera una continuación válida. std::noop_coroutine() devuelve un manejador singleton especial que, cuando se reanuda, regresa inmediatamente a su llamador. Esto es crucial para la terminación: cuando una coroutine hoja finaliza y desea devolver el control a su padre sin reanudación manual, devuelve std::noop_coroutine(). Esto permite que el await_suspend del padre (que transfirió simétricamente al hijo) reciba una "continuación" válida que simplemente regresa, finalizando efectivamente la cadena de manera segura. Los candidatos confunden los manejadores nulos con los manejadores noop, llevando a bloqueos sutiles donde el sistema de coroutine espera eternamente en un objetivo de reanudación nulo.