tutoriales.com

Corrutinas en C++20: Programación Asíncrona Eficiente y Sin Bloqueos

Las corrutinas introducidas en C++20 revolucionan la forma en que manejamos operaciones asíncronas y de larga duración. Este tutorial explora desde los fundamentos hasta la creación de tipos personalizados de tareas, permitiéndote escribir código secuencial que se ejecuta de manera no bloqueante.

Avanzado12 min de lectura12 views
Reportar error

🚀 Introducción a las Corrutinas en C++20

La programación asíncrona en C++ tradicionalmente ha sido compleja, requiriendo el uso de callbacks anidados, máquinas de estados o la gestión manual de hilos de ejecución con librerías externas. Con la llegada de C++20, el lenguaje introdujo nativamente las corrutinas, una potente herramienta que permite pausar y reanudar la ejecución de una función sin bloquear el hilo actual.

Una corrutina es una función especial que puede suspender su ejecución utilizando la palabra clave co_await, ceder el control al invocador, y posteriormente reanudarse exactamente desde el punto donde se detuvo. Esto simplifica drásticamente el diseño de sistemas concurrentes, operaciones de red y procesamiento de flujos de datos.

💡 Consejo: Piensa en una corrutina como una función que puede tener múltiples puntos de salida y entrada temporales, manteniendo su estado interno intacto entre cada pausa.

🛠️ ¿Qué es una Corrutina y Cómo Funciona Bajo el Capó?

A diferencia de una función ordinaria, que se ejecuta desde el principio hasta el final y destruye su marco de pila (stack frame) al retornar, una corrutina asigna su estado (variables locales, parámetros y punto de suspensión) en el heap (montículo). Esto permite que el marco de la función sobreviva incluso después de que la función haya cedido el control.

Para que una función sea considerada una corrutina por el compilador de C++20, debe cumplir con al menos una de las siguientes condiciones en su cuerpo:

  • Contener la expresión co_await para suspender la ejecución y esperar el resultado de otra operación.
  • Contener la expresión co_yield para devolver un valor y suspender la ejecución (típicamente usado en generadores).
  • Contener la expresión co_return para finalizar la corrutina y opcionalmente devolver un valor final.
Función Normal Corrutina Inicio Ejecución Fin Flujo lineal y bloqueante Inicio Ejecución co_await Retorno al llamador Reanudación Fin Pausa Completo

📋 Anatomía de una Corrutina en C++20

Implementar corrutinas en C++20 requiere entender tres componentes principales que el compilador espera encontrar:

  1. El Tipo de Retorno (Promise Type Holder): Un objeto que gestiona el comportamiento de la corrutina.
  2. La Promesa (promise_type): Una clase interna dentro del tipo de retorno que controla la asignación, el manejo de excepciones y el valor retornado.
  3. El Objeto Corrutina (Coroutine Handle): Un identificador liviano (std::coroutine_handle) que permite manipular, reanudar o destruir la corrutina.

Vamos a construir un ejemplo práctico y completo de una tarea asíncrona simple que imprime mensajes con pausas simuladas.

Implementación de un Tipo de Tarea Personalizado

Para manejar nuestra corrutina, necesitamos definir una estructura de tipo tarea. Aquí tienes un ejemplo simplificado pero funcional:

#include <coroutine>
#include <iostream>
#include <thread>

struct Task {
    struct promise_type {
        Task get_return_object() {
            return Task{
                std::coroutine_handle<promise_type>::from_promise(*this)
            };
        }
        
        std::suspend_always initial_suspend() noexcept { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };

    std::coroutine_handle<promise_type> handle;

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

    // Deshabilitar copia para evitar doble destrucción del handle
    Task(const Task&) = delete;
    Task& operator=(const Task&) = delete;

    Task(Task&& other) noexcept : handle(other.handle) {
        other.handle = nullptr;
    }

    Task& operator=(Task&& other) noexcept {
        if (this != &other) {
            if (handle) handle.destroy();
            handle = other.handle;
            other.handle = nullptr;
        }
        return val;
    }

    void resume() {
        if (handle && !handle.done()) {
            handle.resume();
        }
    }
};
📌 Nota: Las estructuras `std::suspend_always` y `std::suspend_never` proporcionadas por la librería estándar indican al compilador si la corrutina debe suspenderse inmediatamente al iniciar o finalizar, o continuar sin pausa.

🔄 Creando y Consumiendo nuestra Primera Corrutina

Una vez que tenemos nuestro tipo Task definido con su promise_type, podemos escribir una función que utilice co_await y devuelva este tipo.

Task miPrimeraCorrutina() {
    std::cout << "[Corrutina] Inicio de la ejecución\n";
    
    // Simulamos una pausa o espera asíncrona
    co_await std::suspend_always{};
    
    std::cout << "[Corrutina] Reanudada después del primer punto de suspensión\n";
    
    co_await std::suspend_always{};
    
    std::cout << "[Corrutina] Finalizando la ejecución\n";
}

int main() {
    std::cout << "[Main] Llamando a la corrutina...\n";
    Task t = miPrimeraCorrutina();
    
    std::cout << "[Main] La corrutina fue creada, pero no ha terminado.\n";
    
    t.resume(); // Primera reanudación
    std::cout << "[Main] Control devuelto al main tras primera pausa.\n";
    
    t.resume(); // Segunda reanudación
    std::cout << "[Main] Fin del programa.\n";
    
    return 0;
}

Explicación del Flujo de Ejecución

Paso 1: Se invoca miPrimeraCorrutina(). Como initial_suspend devuelve std::suspend_always, la función se pausa antes de ejecutar su primera línea de código.
Paso 2: Se ejecuta t.resume() desde el main, lo que imprime "Inicio de la ejecución" y avanza hasta el primer co_await.
Paso 3: El control regresa al main. Se vuelve a llamar a t.resume(), imprimiendo "Reanudada..." hasta llegar al siguiente co_await.
Paso 4: Última llamada a t.resume() para finalizar la corrutina por completo.

⚡ Generadores Eficientes con co_yield

Otra aplicación clásica de las corrutinas es la creación de flujos de datos o generadores perezosos (lazy generators). A diferencia de devolver un contenedor completo con miles de elementos en memoria, un generador calcula y devuelve un elemento a la vez bajo demanda.

🔥 Importante: Los generadores son ideales para procesar secuencias infinitas o conjuntos de datos masivos donde la memoria RAM es un factor limitante.

Vamos a ver un ejemplo conceptual de cómo un generador utiliza co_yield para emitir valores secuenciales:

#include <coroutine>
#include <iostream>

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() noexcept { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        
        std::suspend_always yield_value(T value) {
            current_value = 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(); }

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

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

📊 Comparativa: Enfoque Tradicional vs. Corrutinas en C++20

A continuación, se presenta una tabla comparativa que resume las ventajas clave de adoptar corrutinas frente a los mecanismos tradicionales basados en callbacks o hilos dedicados:

CaracterísticaCallbacks / Máquinas de EstadoHilos Tradicionales (std::thread)Corrutinas C++20
------------
Uso de MemoriaBajoAlto (por cada pila de hilo ~1MB)Muy bajo (solo marco de pila en heap)
LegibilidadBaja (Callback Hell)AltaAlta (Código secuencial natural)
------------
EscalabilidadAltaBaja (Limitado por el S.O.)Extremadamente alta
ComplejidadMuy AltaMediaMedia-Alta (Curva de aprendizaje inicial)

⚠️ Buenas Prácticas y Errores Comunes

Cuando trabajes con corrutinas en entornos de producción, ten en cuenta las siguientes recomendaciones:

  • Cuidado con las referencias colgantes (dangling references): Si una corrutina acepta parámetros por referencia y se suspende, asegúrate de que los objetos referenciados sigan vivos cuando la corrutina se reanude.
  • Gestión de Excepciones: Implementa siempre el método unhandled_exception() dentro del promise_type para evitar llamadas inesperadas a std::terminate().
  • Uso de Librerías de Terceros: Escribir todo el soporte de promesas desde cero puede ser tedioso. Considera utilizar librerías maduras como CppCoro o las extensiones de networking de C++ cuando estén disponibles en tu entorno.
¿Preguntas Frecuentes (FAQ)

¿Las corrutinas de C++20 son multihilo por defecto?
No. Las corrutinas son independientes de los hilos. Una corrutina puede iniciarse en el hilo A, suspenderse, y reanudarse en el hilo B, pero esto requiere un despachador o ejecutor (*scheduler*) que gestione el cambio de contexto entre hilos.

¿Necesito compilar con alguna bandera especial?
Sí, asegúrate de habilitar el soporte para C++20 configurando tu compilador con -std=c++20 (GCC/Clang) o /std:c++20 (MSVC).


🎯 Conclusión

Las corrutinas en C++20 representan un salto cualitativo enorme en la programación moderna de sistemas y aplicaciones de alto rendimiento. Aunque su configuración inicial requiere comprender conceptos avanzados como el promise_type y el manejo de cursores, el resultado final es un código altamente legible, eficiente y preparado para lidiar con operaciones asíncronas complejas sin desperdiciar recursos del sistema.

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!