Ir al contenido

Coroutines de C++20

Comprende las coroutines como funciones suspendibles. Aprende co_yield, co_return, co_await, el patron generator y casos de uso practicos como I/O asincrono y maquinas de estado.

Que Son las Coroutines?

Una coroutine es una funcion que puede suspender su ejecucion y ser reanudada posteriormente. A diferencia de las funciones regulares que se ejecutan de inicio a fin, las coroutines pueden pausarse a mitad de ejecucion, producir resultados intermedios y continuar donde se quedaron. Esto es fundamentalmente diferente de los hilos: las coroutines son cooperativas (eligen cuando suspenderse), ligeras (no se necesita un hilo del SO) y deterministicas (sin carreras de datos por acceso concurrente).

C++20 introduce soporte para coroutines a nivel del lenguaje con tres nuevas palabras clave: co_yield (suspender y producir un valor), co_return (completar la coroutine con un valor final) y co_await (suspender hasta que una operacion asincrona se complete). Cualquier funcion que contenga una de estas palabras clave es automaticamente una coroutine.

El Patron Generator

Un generator es una coroutine que produce una secuencia de valores de forma perezosa. Cada llamada a co_yield suspende la coroutine y entrega un valor al llamador. Aqui hay una implementacion completa y compilable de generator:

generator.cpp
#include <iostream>
#include <coroutine>
#include <optional>

// A simple Generator type
template<typename T>
struct Generator {
    struct promise_type {
        T current_value;

        Generator get_return_object() {
            return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        std::suspend_always yield_value(T value) {
            current_value = std::move(value);
            return {};
        }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };

    std::coroutine_handle<promise_type> handle;

    explicit Generator(std::coroutine_handle<promise_type> h) : handle(h) {}
    ~Generator() { if (handle) handle.destroy(); }

    // Move only
    Generator(Generator&& other) noexcept : handle(other.handle) { other.handle = nullptr; }
    Generator& operator=(Generator&&) = delete;
    Generator(const Generator&) = delete;

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

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

// A coroutine that generates Fibonacci numbers
Generator<long long> fibonacci() {
    long long a = 0, b = 1;
    while (true) {
        co_yield a;
        auto next = a + b;
        a = b;
        b = next;
    }
}

// A coroutine that generates a range
Generator<int> range(int start, int end) {
    for (int i = start; i < end; ++i) {
        co_yield i;
    }
}

int main() {
    auto fib = fibonacci();
    std::cout << "First 10 Fibonacci numbers: ";
    for (int i = 0; i < 10 && fib.next(); ++i) {
        std::cout << fib.value() << " ";
    }
    std::cout << "\n";  // 0 1 1 2 3 5 8 13 21 34

    auto r = range(1, 6);
    while (r.next()) {
        std::cout << r.value() << " ";  // 1 2 3 4 5
    }
    std::cout << "\n";

    return 0;
}

Mecanismo de Coroutines: Promise y Handle

Toda coroutine tiene dos componentes clave:

promise_type — Controla el comportamiento de la coroutine. El compilador busca un promise_type anidado en el tipo de retorno de la coroutine. Metodos clave:
- get_return_object() — crea el objeto retornado al llamador
- initial_suspend() — retorna suspend_always (inicio perezoso) o suspend_never (inicio inmediato)
- final_suspend() — que hacer cuando la coroutine termina (debe ser noexcept)
- yield_value(T) — llamado en co_yield
- return_value(T) o return_void() — llamado en co_return
- unhandled_exception() — llamado si una excepcion escapa

std::coroutine_handle

— Un puntero con borrado de tipo al frame de la coroutine. Proporciona metodos resume(), done() y destroy(). El frame de la coroutine vive en el heap y contiene todas las variables locales y el punto de suspension.

El flujo es: el llamador crea la coroutine -> initial_suspend -> el llamador llama a resume() -> la coroutine ejecuta hasta co_yield/co_return/co_await -> el control retorna al llamador -> repetir.

co_await y Objetos Awaitable

co_await suspende una coroutine hasta que una operacion asincrona se complete. Cualquier tipo que implemente la interfaz Awaitable puede usarse con co_await. Esta es la base para frameworks de I/O asincrono:

co_await_example.cpp
#include <iostream>
#include <coroutine>
#include <chrono>
#include <thread>

// A simple awaitable that simulates async work
struct AsyncTimer {
    std::chrono::milliseconds duration;

    bool await_ready() const noexcept {
        return duration.count() <= 0;  // skip suspend if no wait needed
    }

    void await_suspend(std::coroutine_handle<> handle) const {
        // In production, this would register with an event loop
        // Here we simulate with a thread
        std::thread([handle, d = duration]() {
            std::this_thread::sleep_for(d);
            handle.resume();  // resume the coroutine
        }).detach();
    }

    void await_resume() const noexcept {
        // Called when the coroutine resumes — return value becomes
        // the result of the co_await expression
    }
};

// The three Awaitable methods:
// 1. await_ready()  — can we skip suspending? (optimization)
// 2. await_suspend() — what to do when suspending
// 3. await_resume()  — what value to produce when resuming

// A Task type for async coroutines
struct Task {
    struct promise_type {
        Task get_return_object() { return {}; }
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };
};

Task async_work() {
    std::cout << "Starting async work...\n";
    co_await AsyncTimer{std::chrono::milliseconds(100)};
    std::cout << "Phase 1 complete\n";
    co_await AsyncTimer{std::chrono::milliseconds(100)};
    std::cout << "Phase 2 complete\n";
}

int main() {
    async_work();
    std::this_thread::sleep_for(std::chrono::milliseconds(500));
    return 0;
}

Casos de Uso Practicos

Las coroutines destacan en varios escenarios del mundo real:

Generators / Secuencias Perezosas — Producen valores bajo demanda sin calcular toda la secuencia por adelantado. Ideales para secuencias infinitas, analisis de archivos y pipelines de datos.

I/O Asincrono — Servidores de red, consultas a bases de datos y operaciones de archivos pueden usar co_await para esperar la finalizacion sin bloquear hilos. Bibliotecas como cppcoro, libunifex y Boost.Asio proporcionan primitivas de I/O listas para coroutines.

Maquinas de Estado — Cada co_yield o co_await representa una transicion de estado. Las variables locales de la coroutine preservan naturalmente el estado entre transiciones, eliminando structs de estado manuales.

Programacion Orientada a Eventos — Los frameworks de UI y los bucles de juego pueden usar coroutines para expresar logica secuencial que abarca multiples frames o eventos.

Analisis Sintactico (Parsing) — Los tokenizadores y parsers pueden usar co_yield para producir tokens o nodos AST mientras procesan la entrada, manteniendo el estado del analisis de forma implicita.

Buena práctica

Las coroutines y los hilos resuelven problemas diferentes:

Usa coroutines cuando: necesites multitarea cooperativa, evaluacion perezosa o I/O asincrono sin la sobrecarga de hilos del SO. Las coroutines no tienen sobrecarga de cambio de contexto, no necesitan primitivas de sincronizacion y pueden escalar a millones de tareas concurrentes.

Usa hilos cuando: necesites verdadero paralelismo entre nucleos de CPU para trabajo limitado por CPU, o cuando necesites planificacion preemptiva (una coroutine que nunca se suspende bloqueara su hilo).

Mejor practica: Usa coroutines para concurrencia limitada por I/O e hilos para paralelismo limitado por CPU. Muchos servidores de alto rendimiento combinan ambos: un pool de hilos ejecuta coroutines, donde cada coroutine maneja una conexion usando co_await para I/O.

Error común

Referencias colgantes: Los parametros de coroutine se copian en el frame, pero las referencias a temporales pueden quedar colgantes. Siempre toma parametros por valor o asegura que el objeto referenciado sobreviva a la coroutine.

Asignacion en el heap: Cada coroutine asigna un frame en el heap. Para coroutines muy frecuentes y de corta duracion, esto puede ser una preocupacion de rendimiento (aunque los compiladores pueden optimizar via Heap Allocation eLision Optimization — HALO).

Dificultad de depuracion: Las pilas de coroutines son mas dificiles de inspeccionar en depuradores. El estado suspendido esta en el heap, no en la pila de llamadas. Usa logging y concurrencia estructurada para mantener la depurabilidad.

Sin tipos en la biblioteca estandar: C++20 proporciona las primitivas del lenguaje pero ningun tipo estandar de Generator o Task. Debes escribir los tuyos propios o usar una biblioteca. C++23 agrega std::generator.

Puntos clave
  • Las coroutines son funciones suspendibles — se pausan con co_yield/co_await y se reanudan despues
  • El patron generator produce secuencias perezosas: cada co_yield suspende y entrega un valor
  • co_await suspende hasta que una operacion asincrona se complete — los tres metodos del awaitable controlan el comportamiento
  • El promise_type y el coroutine_handle forman el mecanismo que conecta la coroutine con su llamador
  • Las coroutines son cooperativas (sin preempcion) y ligeras (sin hilo del SO) — ideales para concurrencia limitada por I/O
  • C++20 solo proporciona primitivas; usa std::generator (C++23) o bibliotecas como cppcoro para codigo en produccion

Quiz — Pon a prueba tus conocimientos

(20 XP)

1. Que palabra clave convierte una funcion en coroutine en C++20?

2. Cual es el proposito de `promise_type::yield_value(T)`?

3. Como difieren las coroutines de los hilos?