Coroutines en C++20: Anatomía del Promise Type

Una coroutine [C++20] es una función que puede suspender su ejecución, ceder el control al llamador y reanudarse posteriormente desde el mismo punto exacto, manteniendo intacto su estado local. A diferencia de una función convencional que tiene un ciclo de vida de “llamada-ejecución-retorno”, una coroutine introduce el concepto de suspensión.

El compilador implementa esto transformando la función en una máquina de estados. Para que esta transformación sea posible, la función debe contener al menos una de estas tres palabras clave: co_await, co_yield o co_return. Durante la compilación, el compilador analiza la función y, si detecta estas palabras, reserva un bloque de memoria llamado coroutine frame (normalmente en el heap) que almacena:
1. Los argumentos pasados a la función.
2. Las variables locales que sobreviven a la suspensión.
3. El estado actual de la máquina de estados (el punto de suspensión).
4. El objeto promise_type.

El corazón de la lógica de control reside en el promise_type. Este es un tipo interno que actúa como interfaz entre la coroutine y el objeto que el llamador recibe. Define cómo se crea el objeto de retorno, qué sucede cuando la coroutine se suspende al inicio o al final, y cómo se gestionan los valores devueltos o las excepciones. El llamador interactúa con la coroutine principalmente a través de un std::coroutine_handle<Promise>, que es un puntero opaco al coroutine frame que permite invocar resume() para reanudar la ejecución o destroy() para liberar la memoria del frame.

Deberías usar coroutines cuando necesites implementar flujos de datos perezosos (lazy sequences), lógica asíncrona compleja sin el “callback hell”, o generadores de valores. Si intentas gestionar manualmente estados y buffers para simular asincronía, terminarás con un código frágil y propenso a errores de memoria. El riesgo crítico aparece si pierdes el control sobre el ciclo de vida del handle: si la coroutine se suspende y el handle se destruye sin llamar a destroy() (en casos donde no hay RAII), o si intentas acceder al promise después de que el frame haya sido liberado, entrarás en el terreno del undefined behavior.

#include <iostream>
#include <coroutine>
#include <exception>
#include <optional>

// Un generador simple que produce una secuencia de enteros.
template<typename T>
struct Generator {
    // El promise_type es el motor que controla el comportamiento de la coroutine.
    struct promise_type {
        T current_value;

        // Se llama para construir el objeto que la función devuelve (Generator<T>).
        Generator get_return_object() {
            return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
        }

        // Determina si la coroutine se suspende inmediatamente al empezar.
        // Usamos std::suspend_always para un inicio "perezoso" (lazy).
        std::suspend_always initial_suspend() noexcept { return {}; }

        // Determina qué ocurre cuando la coroutine llega al final.
        // Al usar suspend_always, el frame permanece vivo hasta que llamemos a .destroy().
        std::suspend_always final_suspend() noexcept { return {}; }

        // Se llama cuando se usa co_yield en la coroutine.
        std::suspend_always yield_value(T value) {
            current_value = value;
            return {}; 
        }

        // Se llama cuando se usa co_return.
        void return_void() {}

        // Manejo de excepciones lanzadas dentro de la coroutine.
        void unhandled_exception() {
            std::terminate(); // En producción, aquí podrías propagar la excepción.
        }
    };

    std::coroutine_handle<promise_type> handle;

    // Constructor y RAII para asegurar la limpieza del frame.
    explicit Generator(std::coroutine_handle<promise_type> h) : handle(h) {}
    ~Generator() {
        if (handle) handle.destroy();
    }

    // Prohibimos la copia para evitar doble liberación del handle.
    Generator(const Generator&) = delete;
    Generator& operator=(const Generator&) = delete;
    
    // Permitimos el movimiento.
    Generator(Generator&& other) noexcept : handle(other.handle) {
        other.handle = nullptr;
    }

    bool next() {
        if (!handle.done()) {
            handle.resume();
            return !handle.done();
        }
        return false;
    }

    T value() const {
        return handle.promise().current_value;
    }
};

// Esta es la coroutine propiamente dicha.
Generator<int> secuencia_numerica(int limite) {
    for (int i = 1; i <= limite; ++i) {
        std::cout << "[Coroutine] Generando: " << i << "\n";
        co_yield i; // Suspende la ejecución y entrega el valor.
    }
}

int main() {
    auto gen = secuencia_numerica(3);

    std::cout << "[Main] Iniciando iteración...\n";
    while (gen.next()) {
        std::cout << "[Main] Recibido: " << gen.value() << "\n";
    }
    std::cout << "[Main] Fin de la secuencia.\n";

    return 0;
}

Desglose del concepto

En el ejemplo, Generator<int> actúa como el “wrapper” que el usuario final manipula. El método get_return_object() es crucial: es la razón por la cual la función secuencia_numerica devuelve un objeto de tipo Generator y no algo distinto. Este método utiliza std::coroutine_handle<promise_type>::from_promise(*this) para obtener el handle que apunta al frame recién creado.

Cuando llamamos a gen.next(), invocamos handle.resume(). Esto traslada el flujo de ejecución desde main hacia el cuerpo de secuencia_numerica. El compilador ha transformado el co_yield i en una llamada a promise.yield_value(i), la cual devuelve un std::suspend_always. Esto provoca que la coroutine se pause, guarde el valor de i en current_value dentro del coroutine frame y devuelva el control a main.

Fíjate en la importancia de final_suspend(). Al devolver std::suspend_always, la coroutine no se destruye automáticamente cuando llega al final de su cuerpo. Esto es vital en este diseño porque permite que el objeto Generator siga siendo válido y que el promise_type mantenga sus datos para que main pueda inspeccionarlos si fuera necesario. Si hubiéramos usado std::suspend_never, el frame de la coroutine se liberaría inmediatamente al terminar la función, y cualquier intento posterior de acceder a la memoria de la coroutine resultaría en un error de segmentación.

El ciclo de vida se gestiona mediante RAII en el destructor de Generator, donde llamamos a handle.destroy(). Esto es imperativo porque, al haber usado suspend_always en el punto final, la memoria del frame (que reside en el heap) no se liberará por sí sola.

El error frecuente

Un error clásico de nivel avanzado ocurre al confundir el comportamiento de final_suspend() con la propiedad de vida del promise_type.

// ERROR: Peligroso
Generator<int> error_prone(int n) {
    for(int i=0; i<n; ++i) co_yield i;
    // Si el promise_type usa std::suspend_never en final_suspend()...
}

Si final_suspend() devuelve std::suspend_never, el compilador destruye el coroutine frame automáticamente justo después de que la última instrucción de la coroutine se ejecute. Si el objeto Generator intenta acceder a handle.promise() después de que la coroutine haya terminado para obtener un valor final, estarás accediendo a memoria que ya ha sido liberada.

Este error es extremadamente difícil de depurar porque no siempre causa un crash inmediato; a veces simplemente corrompe otros datos en el heap. Para detectarlo, es imprescindible compilar con AddressSanitizer (-fsanitize=address en GCC/Clang), el cual detectará el use-after-free del frame de la coroutine.

102

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio