Devolver std::coroutine_handle desde await_suspend permite la transferencia simétrica, una forma de optimización de llamadas de cola (TCO) garantizada. Cuando await_suspend devuelve void, el tiempo de ejecución de la corutina debe regresar a su llamador antes de reanudar la siguiente corutina, creando una pila de llamadas anidada que crece linealmente con la longitud de la cadena. Al devolver un controlador, el compilador emite un salto directo (instrucción jmp) al punto de reanudación de la corutina objetivo, reutilizando el registro de activación actual y manteniendo una profundidad de pila constante O(1) independientemente de la longitud de la cadena.
struct SymmetricTransfer { std::coroutine_handle<> next; // Optimizado para cola: sin crecimiento de pila std::coroutine_handle<> await_suspend(std::coroutine_handle<>) { return next; } void await_resume() {} bool await_ready() { return false; } };
Desarrollamos un motor de procesamiento de audio en tiempo real para software profesional de producción musical. El sistema utilizó corutinas de C++20 para representar una canalización de más de 500 efectos de procesamiento de señal digital (DSP) (filtros, compresores, reverberaciones) encadenados. Durante las pruebas de estrés, la aplicación se bloqueó con un desbordamiento de pila al cargar racks de efectos complejos, a pesar de que cada corutina individual tenía un estado local mínimo.
Solución 1: await_suspend que devuelve void con reanudación directa La implementación inicial utilizó void await_suspend(std::coroutine_handle<>) y llamó a next.resume() internamente. Este enfoque ofreció un flujo de código intuitivo y secuencial y facilitó la depuración a través de trazas de pila estándar. Sin embargo, cada llamada a resume() estaba anidada dentro de la lógica de suspensión de la corutina anterior, consumiendo aproximadamente 16KB por etapa y agotando la pila de 8MB después de solo 500 etapas.
Solución 2: Cola de trabajo con programación asíncrona Consideramos reemplazar la cadena directa con una cola de tareas centralizada donde cada corutina enviaba la siguiente etapa como un elemento de trabajo y se suspendía inmediatamente. Esto garantizaba un uso constante de la pila al transformar la recursión en iteración. La desventaja fue una degradación significativa del rendimiento: asignaciones dinámicas para nodos de cola, agitación de caché debido a la contención de hilos y pérdida de localidad de caché entre etapas de la canalización, violando nuestros requisitos de latencia submilisegundo.
Solución 3: Transferencia simétrica a través de coroutine_handle Refactorizamos await_suspend para devolver directamente el std::coroutine_handle de la siguiente etapa. Esto indicó al compilador que realizara TCO, colapsando los marcos de la pila. La solución preservó la abstracción de costo cero de las corutinas mientras aseguraba un uso de memoria O(1). El principal riesgo involucraba la gestión del ciclo de vida: una vez que se devolvió el controlador, la corutina actual se suspendió y acceder a this o a variables locales después del punto de retorno resultó en comportamiento indefinido.
Solución elegida y resultado Adoptamos la Solución 3. Después de refactorizar, la canalización logró procesar 512 efectos consecutivos utilizando solo 4KB de espacio de pila, eliminando bloqueos y manteniendo un rendimiento en tiempo real determinístico. El cambio requirió revisiones cuidadosas del código para asegurar que no existiera lógica posterior al retorno en await_suspend, pero resultó en una arquitectura robusta y escalable.
¿Por qué la transferencia simétrica requiere devolver std::coroutine_handle en lugar de usar co_await en la siguiente corutina dentro de await_suspend?
Usar co_await dentro de await_suspend requeriría que la corutina en espera estuviera completamente suspendida primero y luego reanudada más tarde, lo que implica inherentemente regresar al tiempo de ejecución y hacer crecer la pila. Devolver el controlador directamente permite que el compilador trate la reanudación como una llamada de cola, mientras que co_await genera un punto de suspensión asimétrica que debe preservar el marco del llamador para reanudarlo más tarde.
¿Cómo afecta la transferencia simétrica a la seguridad de excepciones si la corutina reanudada lanza una excepción antes de alcanzar su punto de suspensión final?
Si la corutina a la que se transfiere simétricamente lanza una excepción, la excepción se desenvuelve a través del marco de await_suspend conceptualmente, pero dado que la corutina original ya está marcada como suspendida, su marco debe destruirse durante el desdoblamiento de la pila. Esto requiere que el compilador genere tablas complejas de manejo de excepciones que destruyen la promesa y los parámetros capturados de la corutina suspendida. Los candidatos a menudo pasan por alto que los asignadores de promise_type personalizados deben manejar la construcción parcial correctamente, o arriesgarse a errores de doble destrucción durante el desdoblamiento de excepciones.
¿Qué impide usar la transferencia simétrica al implementar un generador que devuelve valores de una estructura de datos recursiva?
Los generadores dependen de co_yield para devolver el control al llamador mientras mantienen su estado. La transferencia simétrica pasa incondicionalmente el control a otra corutina y nunca regresa al llamador original hasta que toda la cadena se complete. Por lo tanto, los generadores deben usar suspensión asimétrica (devolviendo void o true desde await_suspend) para permitir que el consumidor reciba el valor devuelto y potencialmente reanude el generador más tarde, en lugar de forzar una transferencia irreversible a otra corutina.