C++ Multihilo

Un hilo es una unidad de ejecución ligera dentro de un programa, que permite al programa ejecutar varias tareas al mismo tiempo.

El multihilo es una forma especial de multitarea; la multitarea permite que la computadora ejecute dos o más programas al mismo tiempo.

Por lo general, existen dos tipos de multitarea:Basada en procesos y basada en hilos.。

  • La multitarea basada en procesos es la ejecución concurrente de programas.
  • La multitarea basada en hilos es la ejecución concurrente de segmentos del mismo programa.

La programación multihilo en C++ implica crear y gestionar múltiples hilos que se ejecutan concurrentemente en un programa.

C++ proporciona un sólido soporte para multihilo, especialmente a partir del estándar C++11, mediante<thread>la biblioteca estándar, lo que hace que la programación multihilo sea más sencilla y segura.


Explicación de conceptos

Hilo (Thread)

  • Un hilo es un flujo de control secuencial único en la ejecución de un programa; varios hilos pueden ejecutarse de forma independiente dentro del mismo proceso.
  • Los hilos comparten recursos del proceso como el espacio de direcciones, los descriptores de archivo, el montón y las variables globales, pero cada hilo tiene su propia pila, registros y contador de programa.

Concurrencia (Concurrency) y paralelismo (Parallelism)

  • Concurrencia: múltiples tareas se ejecutan alternadamente en intervalos de tiempo, dando la apariencia de que ocurren simultáneamente.
  • Paralelismo: múltiples tareas se ejecutan simultáneamente en múltiples procesadores o núcleos de procesador.

Los estándares de C++11 y posteriores ofrecen soporte para multihilo; los componentes principales incluyen:

  • std::thread: se utiliza para crear y gestionar hilos.
  • std::mutex: se utiliza para la exclusión mutua entre hilos, evitando que varios hilos accedan simultáneamente a recursos compartidos.
  • std::lock_guardystd::unique_lock: se utiliza para gestionar la adquisición y liberación de bloqueos.
  • std::condition_variable: se utiliza para variables de condición entre hilos, coordinando la espera y la notificación entre hilos.
  • std::futureystd::promise: se utiliza para implementar el paso de valores y la sincronización de tareas entre hilos.

Crear hilos

Después de C++11, se añadió una nueva biblioteca estándar de hilos.std::thread,std::thread en <thread>se declara en el archivo de cabecera; por lo tanto, para usarstd::threades necesario incluiren <thread>archivo de cabecera.

Nota:Antes, algunos compiladores usaban el parámetro de compilación de C++11 era-std=c++11:

g++ -std=c++11 test.cpp 

std::thread

#include<thread>
std::thread thread_object(callable, args...);
  • callable: objeto invocable, puede ser un puntero a función, un objeto de función, una expresión lambda, etc.
  • args...: pasado acallablela lista de parámetros.

Usar punteros a función

Crear un hilo mediante un puntero a función es la forma más básica:

Ejemplo

#include <iostream>
#include <thread>

void printMessage(int count) {
    for (int i = 0; i < count; ++i) {
        std::cout << "Hello from thread (function pointer)!\n";
    }
}

int main() {
    std::thread t1(printMessage, 5); // Crea el hilo, pasa el puntero a función y los argumentos
    t1.join(); // Espera a que el hilo termine
    return 0;
}

Usandog++ -std=c++11después de compilar, el resultado de ejecución es:

Hello from thread (function pointer)!
Hello from thread (function pointer)!
Hello from thread (function pointer)!
Hello from thread (function pointer)!
Hello from thread (function pointer)!

Usar objetos de función

A través deloperator()método de la clase se define el objeto de función para crear el hilo:

Ejemplo

#include <iostream>
#include <thread>

class PrintTask {
public:
    void operator()(int count) const {
        for (int i = 0; i < count; ++i) {
            std::cout << "Hello from thread (function object)!\n";
        }
    }
};

int main() {
    std::thread t2(PrintTask(), 5); // Crea el hilo, pasa el objeto de función y los argumentos
    t2.join(); // Espera a que el hilo termine
    return 0;
}

Usandog++ -std=c++11después de compilar, el resultado de ejecución es:

Hello from thread (function object)!
Hello from thread (function object)!
Hello from thread (function object)!
Hello from thread (function object)!
Hello from thread (function object)!

Usar expresiones lambda

Las expresiones lambda pueden definir directamente el código de ejecución del hilo de forma inline:

Ejemplo

#include <iostream>
#include <thread>

int main() {
    std::thread t3([](int count) {
        for (int i = 0; i < count; ++i) {
            std::cout << "Hello from thread (lambda)!\n";
        }
    }, 5); // Crea el hilo, pasa la expresión lambda y los argumentos
    t3.join(); // Espera a que el hilo termine
    return 0;
}

Usandog++ -std=c++11después de compilar, el resultado de ejecución es:

Hello from thread (lambda)!
Hello from thread (lambda)!
Hello from thread (lambda)!
Hello from thread (lambda)!
Hello from thread (lambda)!

Gestión de hilos

join()

join() se usa para esperar a que el hilo termine su ejecución. Si se destruye el objeto del hilo sin llamar a join() o detach(), el programa puede fallar.

t.join();

detach()

detach() separa el hilo del hilo principal; el hilo se ejecuta independientemente en segundo plano y el hilo principal ya no espera por él.

t.detach();

Paso de parámetros a hilos

Paso por valor

Los parámetros pueden pasarse al hilo por valor:

std::thread t(func, arg1, arg2);

Paso por referencia

Si se necesita pasar parámetros por referencia, se debe usar std::ref:

Ejemplo

#include <iostream>
#include <thread>

void increment(int& x) {
    ++x;
}

int main() {
    int num = 0;
    std::thread t(increment, std::ref(num)); // Usa std::ref para pasar la referencia
    t.join();
    std::cout << "Value after increment: " << num << std::endl;
    return 0;
}

Ejemplo completo

El siguiente es un ejemplo completo que muestra cómo crear hilos usando los tres métodos anteriores y realizar la gestión de hilos.

Ejemplo

#include <iostream>
#include <thread>
using namespace std;

// Una función simple que sirve como función de entrada del hilo
void foo(int Z) {
    for (int i = 0; i < Z; i++) {
        cout << "El hilo usa puntero a función como parámetro invocable\n";
    }
}

// Definición de la clase del objeto invocable
class ThreadObj {
public:
    void operator()(int x) const {
        for (int i = 0; i < x; i++) {
            cout << "El hilo usa objeto de función como parámetro invocable\n";
        }
    }
};

int main() {
    cout << "Los hilos 1, 2 y 3 se ejecutan de forma independiente" << endl;

    // Crea el hilo usando puntero a función
    thread th1(foo, 3);

    // Crea el hilo usando objeto de función
    thread th2(ThreadObj(), 3);

    // Crea el hilo usando una expresión lambda
    thread th3([](int x) {
        for (int i = 0; i < x; i++) {
            cout << "El hilo usa expresión lambda como parámetro invocable\n";
        }
    }, 3);

    // Espera a que todos los hilos terminen
    th1.join(); // Espera a que el hilo th1 termine
    th2.join(); // Espera a que el hilo th2 termine
    th3.join(); // Espera a que el hilo th3 termine

    return 0;
}

Usando el parámetro de compilación de C++11-std=c++11:

g++ -std=c++11 test.cpp 

La salida del código anterior puede variar según la plataforma o cambiar en cada ejecución, porque el orden de ejecución de los hilos lo decide el algoritmo de planificación del sistema operativo; varios hilos se ejecutan de forma concurrente y la salida puede entremezclarse, por ejemplo:

线程 1 、2 、3 独立运行
线程使用函数指针作为可调用参数
线程使用函数对象作为可调用参数
线程使用 lambda 表达式作为可调用参数
线程使用函数指针作为可调用参数
...

Sincronización y exclusión mutua de hilos

En la programación multihilo, la sincronización y la exclusión mutua son dos conceptos muy importantes. Se utilizan para controlar el acceso de múltiples hilos a los recursos compartidos, con el fin de evitar problemas como la condición de carrera y el punto muerto.

1. Mutex (exclusión mutua)

El mutex es una primitiva de sincronización que se utiliza para evitar que varios hilos accedan simultáneamente a recursos compartidos. Cuando un hilo necesita acceder a un recurso compartido, primero debe bloquear (lock) el mutex. Si el mutex ya está bloqueado por otro hilo, el hilo que solicita el bloqueo se bloqueará hasta que el mutex se desbloquee (unlock).

std::mutex: se utiliza para proteger los recursos compartidos y evitar la condición de carrera.

std::mutex mtx;
mtx.lock();   // 锁定互斥锁
// 访问共享资源
mtx.unlock(); // 释放互斥锁

std::lock_guardystd::unique_lock: gestiona automáticamente la adquisición y liberación del bloqueo.

std::lock_guard<std::mutex> lock(mtx); // 自动锁定和解锁
// 访问共享资源

Ejemplo de uso del mutex:

Ejemplo

#include <mutex>

std::mutex mtx; // Mutex global

void safeFunction() {
    mtx.lock(); // Solicita bloquear el mutex
    // Accede o modifica el recurso compartido
    mtx.unlock(); // Libera el mutex
}

int main() {
    std::thread t1(safeFunction);
    std::thread t2(safeFunction);
    t1.join();
    t2.join();
    return 0;
}

2. Bloqueos (Locks)

C++ proporciona varios tipos de bloqueos para simplificar el uso y la gestión de los mutex.

Los tipos de bloqueo más comunes incluyen:

  • std::lock_guard: bloqueo de ámbito; bloquea automáticamente el mutex al construirse y lo desbloquea automáticamente al destruirse.
  • std::unique_lock: similar a std::lock_guard, pero ofrece más flexibilidad, por ejemplo, permite transferir la propiedad y desbloquear manualmente.

Ejemplo de uso de los bloqueos:

Ejemplo

#include <mutex>

std::mutex mtx;

void safeFunctionWithLockGuard() {
    std::lock_guard<std::mutex> lk(mtx);
    // Accede o modifica el recurso compartido
}

void safeFunctionWithUniqueLock() {
    std::unique_lock<std::mutex> ul(mtx);
    // Accede o modifica el recurso compartido
    // ul.unlock(); // Opcional: desbloqueo manual
    // ...
}

3. Variable de condición (Condition Variable)

La variable de condición se utiliza para la coordinación entre hilos; permite que uno o más hilos esperen a que ocurra una determinada condición. Normalmente se usa junto con un mutex para lograr la sincronización entre hilos.

std::condition_variableSe utiliza para implementar el mecanismo de espera y notificación entre hilos.

std::condition_variable cv;
std::mutex mtx;
bool ready = false;

std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []{ return ready; }); // 等待条件满足
// 条件满足后执行

Ejemplo de uso de la variable de condición:

Ejemplo

#include <mutex>
#include <condition_variable>

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void workerThread() {
    std::unique_lock<std::mutex> lk(mtx);
    cv.wait(lk, []{ return ready; }); // Esperar condición
    // Ejecutar el trabajo cuando se cumpla la condición
}

void mainThread() {
    {
        std::lock_guard<std::mutex> lk(mtx);
        // Preparar datos
        ready = true;
    } // Desbloquear al salir del ámbito
    cv.notify_one(); // Notificar a un hilo en espera
}

4. Operaciones atómicas (Atomic Operations)

Las operaciones atómicas aseguran que el acceso a los datos compartidos sea indivisible, es decir, en un entorno de subprocesos múltiples, una operación atómica se ejecuta por completo o no se ejecuta en absoluto, sin estados intermedios.

Ejemplo de uso de operaciones atómicas:

Ejemplo

#include <atomic>
#include <thread>

std::atomic<int> count(0);

void increment() {
    count.fetch_add(1, std::memory_order_relaxed);
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    return count; // Debería devolver 2
}

5. Almacenamiento local de hilo (Thread Local Storage, TLS)

El almacenamiento local de subprocesos permite que cada subproceso tenga su propia copia de datos. Esto se puede lograr mediante la palabra clave thread_local, evitando la contención por recursos compartidos.

Ejemplo de uso del almacenamiento local de subprocesos:

Ejemplo

#include <iostream>
#include <thread>

thread_local int threadData = 0;

void threadFunction() {
    threadData = 42; // Cada subproceso tiene su propia copia de threadData
    std::cout << "Thread data: " << threadData << std::endl;
}

int main() {
    std::thread t1(threadFunction);
    std::thread t2(threadFunction);
    t1.join();
    t2.join();
    return 0;
}

6. Punto muerto (Deadlock) y estrategias para evitarlo

El punto muerto ocurre cuando varios subprocesos esperan mutuamente a que los otros liberen recursos, pero ninguno puede continuar su ejecución. Las estrategias para evitar el punto muerto incluyen:

  • Solicitar siempre los recursos en el mismo orden.
  • Usar tiempo de espera (timeout) para intentar adquirir recursos.
  • Usar algoritmos de detección de punto muerto.

Comunicación entre hilos

std::future y std::promise: implementan la transferencia de valores entre subprocesos.

Ejemplo


std::promise<int> p;
std::future<int> f = p.get_future();

std::thread t([&p] {
    p.set_value(10); // Establecer el valor, activar el future
});

int result = f.get(); // Obtener el valor

Cola de mensajes (basada en std::queue y std::mutex) para implementar una comunicación simple entre subprocesos.


C++17 introdujo la biblioteca de algoritmos paralelos (<algorithm>), donde algunos algoritmos admiten la ejecución en paralelo, lo que permite aprovechar CPU multinúcleo para mejorar el rendimiento.

Ejemplo

#include <algorithm>
#include <vector>
#include <execution>

std::vector<int> vec = {1, 2, 3, 4, 5};
std::for_each(std::execution::par, vec.begin(), vec.end(), [](int &n) {
    n *= 2;
});

Algoritmos paralelos en la biblioteca estándar de C++

Más ejemplos de referencia:

C++ Multithreading:http://www.example.com/w3cnote/cpp-multithread-demo.html

C++ std::thread: https://www.example.com/w3cnote/cpp-std-thread.html

Otras extensiones