Skip to main content

Command Palette

Search for a command to run...

C++ Multithreading desde cero — Parte 2

Fundamentos y gestión segura de hilos

Updated
9 min read
C++ Multithreading desde cero — Parte 2

1. Introducción

Hasta ahora he abordado los fundamentos conceptuales de la concurrencia: qué es, cuándo conviene usarla y por qué es un elemento esencial en los sistemas modernos. En esta sección daremos el siguiente paso, centrando nuestra atención en la práctica: la gestión de hilos en C++ y su aplicación a través de ejemplos concretos.

Todo programa en C++ comienza con un único hilo: aquel que ejecuta la función main(). Sin embargo, la verdadera potencia del lenguaje aparece cuando aprendemos a lanzar nuevos hilos que ejecutan tareas de manera independiente y concurrente. El punto de partida es la clase std::thread, introducida en C++11. Esta clase encapsula la creación y control de un hilo de ejecución. En su forma más simple, lanzar un hilo consiste en construir un objeto std::thread y especificar qué función se ejecutará en ese nuevo hilo.

Hello_Concurrent_World CD_01:

#include <iostream>
#include <thread>

void hello() {
    std::cout << "Hello Concurrent World\n";
}

int main() {
    std::thread t(hello);  // Lanza un nuevo hilo
    t.join();              // Espera a que el hilo termine
}

Este pequeño programa crea dos hilos:

  • el hilo principal, que inicia en main(),

  • y un hilo secundario, que comienza ejecutando la función hello().

El flujo principal continúa inmediatamente después de lanzar el hilo. Si no esperáramos a que el hilo termine, el programa podría finalizar antes de que el mensaje se muestre. Por eso llamamos a join(), que bloquea la ejecución hasta que el hilo finaliza. Aunque el ejemplo parece trivial, marca un cambio profundo: desde este punto, el control de flujo deja de ser lineal. Cada hilo representa un camino de ejecución independiente, y depende de nosotros decidir cómo y cuándo sincronizarlos.


2. Lanzar un hilo

En C++, crear un hilo siempre se reduce a construir un objeto std::thread, pasando como argumento una función o cualquier objeto callable.

Por ejemplo, podríamos usar una función normal:

void do_some_work();
std::thread worker(do_some_work);

o un objeto que sobrecarga el operador ():

class background_task {
public:
    void operator()() const {
        do_something();
        do_something_else();
    }
};

background_task task;
std::thread worker(task);

Cuando se crea el objeto std::thread, su constructor copia la función u objeto callable dentro del contexto del nuevo hilo y comienza su ejecución inmediatamente. Una vez que el hilo inicia, su función de entrada se ejecutará hasta completarse, momento en el cual el hilo finalizará de forma automática.

Riesgos iniciales

Lanzar un hilo puede parecer una operación simple, pero implica un cambio fundamental en la gestión de recursos y en el tiempo de vida de los datos. Si el programa termina o un objeto local se destruye antes de que el hilo haya terminado de usarlo, se entra en terreno de comportamiento indefinido.

En particular, si no esperas a que el hilo finalice (por ejemplo, al no llamar a join(), debes asegurarte de que los datos a los que accede el hilo sigan siendo válidos hasta que este complete su ejecución.

Este problema no es exclusivo del código concurrente: incluso en un solo hilo es incorrecto acceder a un objeto destruido. Sin embargo, con múltiples hilos, la posibilidad de error se amplifica, porque el momento exacto en que el hilo accede a la memoria depende del planificador del sistema operativo.

Considera el siguiente ejemplo:

struct func {
    int& i;
    func(int& i_) : i(i_) {}

    void operator()() {
        for (unsigned j = 0; j < 1000000; ++j) {
            do_something(i);
        }
    }
};

void oops() {
    int some_local_state = 0;
    func my_func(some_local_state);
    std::thread my_thread(my_func);
    my_thread.detach();  // No esperamos a que termine
}

Aquí el hilo creado con my_thread continúa ejecutándose incluso después de que la función oops() haya retornado, el objeto some_local_state deja de existir al salir de la función, pero el hilo aún podría estar ejecutando do_something(i), accediendo a una referencia colgante, esto constituye un acceso a memoria destruida, lo que provoca un comportamiento indefinido: desde fallos silenciosos hasta bloqueos o corrupciones de datos.

La situación es similar a mantener un puntero o referencia a una variable local fuera de su ámbito, pero en programación concurrente el error es más fácil de pasar por alto, porque el hilo podría seguir corriendo en segundo plano sin que sea evidente.

Una manera segura de evitar este tipo de errores es:

  • Hacer que la función del hilo sea autosuficiente, copiando los datos que necesita en lugar de referenciarlos.

  • Asegurarse de que el hilo haya completado su ejecución antes de que los recursos que usa sean destruidos, normalmente mediante una llamada a join().


3. Estado joinable y ciclo de vida del hilo

Cada objeto std::thread mantiene una asociación con un hilo real del sistema operativo. Mientras esa asociación exista, el hilo se considera joinable, es decir, puede ser esperado o desacoplado.

Cuando se llama a join(), suceden dos cosas:

  1. El hilo que hace la llamada se bloquea hasta que el hilo asociado termina.

  2. Los recursos del sistema utilizados por el hilo son liberados, y el objeto std::thread deja de estar asociado a ningún hilo.

Después de esa llamada, el hilo ya no es joinable, y cualquier intento posterior de llamar a join() sobre él producirá un error en tiempo de ejecución.

Puedes verificar este estado con el método joinable():

std::thread worker(do_some_work);
if (worker.joinable()) {
    worker.join();
}

Por lo tanto, cada hilo debe terminar de una de dos formas:

  • sincronizándose mediante join()

  • liberándose mediante detach(), que lo convierte en un hilo en segundo plano (background thread).

Hilos en segundo plano

El método detach() permite que un hilo se ejecute de manera independiente, sin necesidad de que el hilo principal espere su finalización. Al llamarlo, el hilo queda completamente desacoplado del objeto std::thread, pasando a ser gestionado por el sistema operativo.

background_task_CD_02

void background_task() {
    std::cout << "Tarea en segundo plano iniciada\n";
    std::this_thread::sleep_for(std::chrono::seconds(3));
    std::cout << "Tarea en segundo plano finalizada\n";
}

int main() {
    std::thread t(background_task);
    t.detach();  // El hilo continúa ejecutándose de forma independiente
    std::cout << "Hilo principal continúa sin esperar\n";
}

Este enfoque resulta útil cuando la tarea es autónoma —por ejemplo, registrar información, limpiar recursos o realizar un trabajo no crítico— y su finalización no afecta al flujo principal del programa.

Sin embargo, el uso de detach() implica una pérdida total de control sobre el hilo: ya no puede ser sincronizado, y si accede a recursos locales que ya fueron destruidos, el comportamiento será indefinido. Por ello, debe usarse con precaución y únicamente cuando el hilo no dependa de datos cuyo ciclo de vida sea más corto que el suyo.

Esperas más precisas

El método join() ofrece una sincronización binaria: esperas completamente o no esperas en absoluto. Si necesitas comprobar periódicamente si el hilo ha terminado, o limitar el tiempo de espera, deberás recurrir a mecanismos más avanzados como futures, promises o condition variables, que permiten un control más granular sobre la sincronización.

Estos temas se abordarán más adelante, pero por ahora basta con entender que join() es la forma más directa y segura de garantizar que un hilo haya finalizado antes de continuar, mientras que detach() ofrece una ejecución completamente independiente, con la responsabilidad adicional de asegurar que los recursos del hilo sigan siendo válidos durante su ejecución.

4. Esperar en circunstancias de excepciones

Gestionar correctamente los hilos no solo implica saber cuándo sincronizarlos, sino también asegurar que siempre sean liberados, incluso si ocurre una excepción. Recordemos que un objeto std::thread debe terminar su ciclo de vida habiendo sido unido (join()) o desacoplado (detach()).
De lo contrario, al destruirse un objeto std::thread todavía joinable, el programa llamará a std::terminate().

Esto representa un riesgo evidente en contextos donde el flujo puede interrumpirse abruptamente, por ejemplo, debido a una excepción. Si el control abandona una función antes de ejecutar la llamada a join(), el hilo quedará sin gestionar y el programa fallará.

Ejemplo del problema

Supón el siguiente escenario simplificado:

void f() {
    int some_local_state = 0;
    func my_func(some_local_state);
    std::thread t(my_func);

    do_something_in_current_thread();
    t.join();
}

En condiciones normales, este código funciona correctamente: el hilo se une antes de salir de la función.

Pero si do_something_in_current_thread() lanza una excepción, la llamada a join() nunca se ejecutará, y cuando t se destruya, el programa terminará con std::terminate().

Manejo explícito con try/catch

Una solución inmediata consiste en usar un bloque try/catch para garantizar que el hilo se una tanto en la ejecución normal como en la excepcional:

void f() {
    int some_local_state = 0;
    func my_func(some_local_state);
    std::thread t(my_func);

    try {
        do_something_in_current_thread();
    } catch (...) {
        t.join();  // Asegura que el hilo se libere
        throw;     // Repropaga la excepción
    }

    t.join();  // Camino normal
}

Esta estrategia es funcional, pero tiene dos inconvenientes:

  • Duplica la llamada a join(), lo que ensucia el código.

  • Es propensa a errores si el bloque try no cubre todas las rutas de salida posibles.

Solución RAII clásica: el patrón thread guard

En C++ Concurrency in Action, Anthony Williams propone una solución basada en RAII (Resource Acquisition Is Initialization) mediante una clase llamada thread_guard. Su destructor garantiza que el hilo se una automáticamente al salir del alcance, incluso si se lanza una excepción.

class thread_guard {
    std::thread& t;
public:
    explicit thread_guard(std::thread& t_) : t(t_) {}
    ~thread_guard() {
        if (t.joinable())
            t.join();
    }
    thread_guard(thread_guard const&) = delete;
    thread_guard& operator=(thread_guard const&) = delete;
};

El principio es simple: cuando el objeto thread_guard sale de ámbito —ya sea por una salida normal o por una excepción— su destructor invoca join() si el hilo sigue activo. Este patrón es una aplicación directa del RAII y resulta muy eficaz para C++11 y C++17.


5. std::jthread

En C++20, el estándar introdujo std::jthread, que ya implementa automáticamente la lógica de unión en su destructor, reemplazando la necesidad de un thread_guard manual. Al destruirse un std::jthread, si el hilo sigue ejecutándose, el destructor llama a join() de manera segura, eliminando el riesgo de std::terminate().

Además, std::jthread admite cancelación cooperativa mediante std::stop_token, lo que facilita el diseño de tareas que pueden detenerse desde fuera del hilo.

Ejemplo con std::jthread

#include <iostream>
#include <thread>
#include <chrono>

void do_work() {
    std::cout << "Hilo iniciado\n";
    std::this_thread::sleep_for(std::chrono::seconds(2));
    std::cout << "Hilo finalizado\n";
}

void f() {
    std::jthread t(do_work);  // Se unirá automáticamente al salir del scope
    do_something_in_current_thread();  // Si lanza, no hay problema
}

Si do_something_in_current_thread() lanza una excepción, el hilo t se unirá automáticamente al salir del ámbito de la función, gracias al destructor de std::jthread.
Esto elimina por completo la necesidad de un bloque try o de una clase auxiliar.

Ejemplo con cancelación cooperativa

stop_token_CD_03

#include <iostream>
#include <thread>
#include <chrono>

void task(std::stop_token st) {
    while (!st.stop_requested()) {
        std::cout << "Trabajando...\n";
        std::this_thread::sleep_for(std::chrono::milliseconds(500));
    }
    std::cout << "Cancelado\n";
}

int main() {
    std::jthread t(task);  // hilo con soporte de cancelación
    std::this_thread::sleep_for(std::chrono::seconds(2));
    t.request_stop();      // solicita la detención
}

El hilo se ejecuta mientras no se solicite su detención, y al salir del main(), el destructor de std::jthread realiza el join() automáticamente, garantizando una finalización limpia.

Hasta este punto se he mostrado las bases para lanzar hilos y aplicar una forma sencilla de sincronización en C++. El trabajo con hilos empieza a adquirir estructura: ya no se trata solo de ejecutar tareas, sino de comprender ciclo de vida del hilo y mantener el control sobre cada una de ellos. En la siguiente parte se profundizaré en la gestión de los hilos, el paso de argumentos y el manejo de su propiedad.

Cplusplus

Part 2 of 3

Serie de artículos dedicada a la programación en C++, donde se explican desde los fundamentos del lenguaje hasta técnicas avanzadas, con ejemplos claros y orientados a la práctica

Up next

C++ Multithreading desde cero — Parte 1

Qué es la concurrencia y cuándo usarla