Corrutinas en C++20: co_await, co_yield y generators en la práctica

Una corrutina es una función que puede suspenderse y reanudarse. A diferencia de un hilo, no bloquea: cuando se suspende, devuelve el control al llamador y puede retomarse más tarde desde el mismo punto. En C++20 hay tres palabras clave nuevas que convierten cualquier función en una corrutina: co_await, co_yield y co_return.

El caso de uso más inmediato son los generadores: funciones que producen una secuencia de valores uno a uno sin tener que calcularlos todos por adelantado. El otro caso de uso importante es el código asíncrono: en lugar de callbacks o futuros encadenados, escribes código lineal que se lee de arriba a abajo aunque por dentro se suspenda y se reanude.

El problema que resuelven

Antes de las corrutinas, un generador de Fibonacci en C++ requería una clase con estado interno, un operator++ y bastante boilerplate. Con código asíncrono la situación era peor: callbacks anidados o cadenas de std::future::then que no existen en el estándar base.

Las corrutinas colapsan esa complejidad en una función que parece síncrona pero no lo es.

co_yield: generadores

El ejemplo más sencillo es un generador de valores. En C++23 ya tienes std::generator<T> disponible directamente:

#include <generator>  // C++23
#include <ranges>
#include <print>

std::generator<int> fibonacci() {
    int a = 0, b = 1;
    while (true) {
        co_yield a;
        auto tmp = a + b;
        a = b;
        b = tmp;
    }
}

int main() {
    // Los primeros 10 números de Fibonacci
    for (int n : fibonacci() | std::views::take(10)) {
        std::print("{} ", n);
    }
    // Salida: 0 1 1 2 3 5 8 13 21 34
}

Cada vez que el bucle pide el siguiente elemento, la corrutina avanza hasta el siguiente co_yield y se suspende ahí. El estado local (a, b) se preserva entre suspensiones. No hay copia de un vector completo, no hay clase auxiliar.

Generadores con argumentos y recursivos

std::generator<int> rango(int inicio, int fin, int paso = 1) {
    for (int i = inicio; i < fin; i += paso) {
        co_yield i;
    }
}

// Generador recursivo (std::generator soporta co_yield con rangos)
std::generator<int> aplanar(std::vector<std::vector<int>> vv) {
    for (auto& v : vv) {
        co_yield std::ranges::elements_of(v);  // C++23
    }
}

co_await: código asíncrono lineal

co_await suspende la corrutina hasta que una operación asíncrona completa. El truco es que mientras espera, el hilo puede hacer otras cosas. Desde fuera se lee como código síncrono:

// Pseudocódigo con un framework hipotético:
Task<std::string> obtener_datos(std::string url) {
    auto respuesta = co_await http_get(url);   // suspende aquí
    auto json      = co_await parsear(respuesta); // suspende aquí
    co_return json["datos"];
}

Sin corrutinas, esto sería una cadena de callbacks o lambdas anidadas. Con ellas, el flujo es lineal y el manejo de errores puede usar try/catch normal.

Cómo funciona por dentro

Cuando el compilador encuentra una función con co_await, co_yield o co_return, la transforma en una máquina de estados. El estado de la función (sus variables locales) se guarda en el heap en un objeto llamado coroutine frame. Cuando la corrutina se reanuda, ese frame se restaura.

La interfaz que conecta la corrutina con el llamador se controla a través de un tipo que el usuario define (o que aporta la librería), que debe implementar ciertos métodos con nombres fijos: promise_type, get_return_object, initial_suspend, final_suspend. Es bastante boilerplate si lo haces a mano.

Escribir tu propio tipo de retorno (C++20, sin C++23)

Si no tienes C++23 disponible o necesitas un generador personalizado, aquí está el mínimo necesario:

#include <coroutine>

template <typename T>
struct Generator {
    struct promise_type {
        T valor_actual;

        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 v) {
            valor_actual = v;
            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(); }

    bool siguiente() {
        handle.resume();
        return !handle.done();
    }
    T valor() { return handle.promise().valor_actual; }
};

Generator<int> contar(int desde, int hasta) {
    for (int i = desde; i < hasta; ++i)
        co_yield i;
}

Esto da una idea del mecanismo subyacente. Para proyectos reales con C++20 sin C++23, librerías como cppcoro o libcoro rellenan ese hueco.

co_return

co_return termina la corrutina y devuelve un valor, igual que un return normal pero para funciones corrutina:

Task<int> calcular() {
    int resultado = co_await operacion_lenta();
    co_return resultado * 2;
}

Si la corrutina no devuelve valor, usa co_return; sin expresión, o simplemente deja que llegue al final de la función.

Combinación con std::ranges

std::generator de C++23 implementa el concepto std::ranges::input_range, así que se puede encadenar directamente con las vistas del artículo sobre std::ranges en C++20/23:

auto pares_fibonacci = fibonacci()
    | std::views::filter([](int n){ return n % 2 == 0; })
    | std::views::take(5);

for (int n : pares_fibonacci) {
    std::println("{}", n);  // 0, 2, 8, 34, 144
}

Soporte en compiladores

Las corrutinas básicas (co_await, co_yield, co_return y el header <coroutine>) están en GCC 10+, Clang 12+ y MSVC VS2019. std::generator de C++23 requiere GCC 14 o Clang 17+ con libstdc++ o libc++ actualizados. Compila con -std=c++23 o -std=c++20 según lo que uses.

Para sistemas embebidos o entornos sin heap dinámico, las corrutinas tienen restricciones: el frame de corrutina normalmente se aloja en el heap. Hay propuestas para corrutinas en stack estático pero aún no están en el estándar. Si esto te afecta, el artículo de esta serie sobre C++ en sistemas embebidos aborda las limitaciones del lenguaje sin stdlib.

Imagen: Pexels / Ivan S

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP