Programación concurrente con C++17

Biblioteca Thread

C++ introduce a partir de la iteración de 2011 una nueva biblioteca para gestión de hebras concurrentes: <thread>. Permite la creación y ejecución de hebras como objetos de clase thread.

También da soporte al programador para sincronizar hebras mediante cerrojos de la clase mutex, y más complejos; disponible el uso de variables atómicas, y se ofrecen monitores con semántica de señalización SC y variables de condición.

Creación y ejecución de hebras

Creamos una instancia de la clase std::thread, utilizando como parámetro la función (en realidad, lo que se transfiere es un puntero a dicha función) que contiene el código que deseamos que la hebra ejecute.

El uso del contructor de clase tiene un doble efecto; no sólo crea el nuevo objeto, sino que también lo arranca de forma automática.

std::thread nombre(tarea);

nombre es la referencia al nuevo objeto de clase thread que estamos creando, y tarea es la función que contiene el código que la nueva hebra debe ejecutar.

Técnicas de creación

Además de la técnica anterior, podemos utilizar funciones lambda (función anónima), para el caso de que el código que la hebra a ejecutar sea muy pequeño, y no necesitemos escribir una función para contenerlo.

#include <vector>
#include <iostream>
#include <thread>

void hello() {
    std::cout << "Hello from thread" << std::this_thread::get_id() << std::endl;
}

int main() {
    std::vector<std::thread> threads;

    for(int i = 0; i < 5; ++i) {
        threads.push_back(std::thread(hello));
    }

    for(auto& thread : threads){
        thread.join();
    }
    return 0;

}
// Corrutina múltiple con array de hebras
#include <thread>
#include <iostream>
#include <vector>

auto hello() -> void {
    std::cout << "Hello from thread " << std::this_thread::get_id() << std::endl;
}

auto main() -> int {
    std::vector<std::thread> threads(100);
    for (auto & thread : threads) {
        thread = std::thread(hello);
    }

    for (auto& thread: threads) {
        thread.join();
    }

    std::cout << "Hello from main thread " << std::this_thread::get_id() << std::endl;
    return 0;
}

Gestionando las hebras

En general, el programador puede necesitar determinadas operaciones de gestión de sus hebras concurrentes como son:

  • Hacer que una hebra espera a que termina otra (método join)
  • Hacer que una hebra espere un tiempo determinado "durmiendo"
  • Asignar prioridades a las hebras para alterar el orden en que se ejecuten
  • Interrumpir la ejecución de una hebra

"Durmiendo" hebras, método sleep_for

Tiene como parámetro el tiempo de sueño habitualmente expresado en milisegundos (hace falta #include <chrono>). Admite otras unidades de media temporal, no es útil para planificar de manera adecuada.

std::chrono::milliseconds duracion(2000);
std::this_thread::sleep_for(duracion);

Replanificación voluntaria, método yield

Una hebra puede "insinuar" al planificador que puede ser replanificada, permitiendo que otras se ejecuten. El comportamiento exacto del método yield depende de la implementación y, en particular, del funcionamiento del planificador del SO.

No es una técnica de planificación adecuada, y es poco utilizada en la práctica

Técnicas de control de EM y sincronización en C++

C++ ofrece una amplia variedad de técnicas de control de la EM (y de sincronización) que incluyen:

  • Cerrojos y cerrojos reentrantes
  • Objetos atómicos
  • Monitores
  • Variables de condición (dentro de monitor)

Control de EM con cerrojos mutex:

  • C++ proporciona cerrojos mediante la clase mutex
  • La sección crítica se encierra entre los métodos lock() y unlock()
  • No provee cerrojos reentrantes, lo cuál los hace inútil en programas recursivos

Ejemplos:

#include <mutex>

struct Cuenta {
    int val =0;
    std::mutex cerrojo;
    void inc () {
        cerrojo.lock();
        val++;
        cerrojo.unlock();
    }

    // Otra forma de escribirlo
    void inc () {
        std::lock_guard<std::mutex> cerrojo(mutex);
        val++;
    }
}

Los objetos mutex carecen de reentrancia

Cerrojos reentrantes: recursive_mutex

La sección crítica se encierra entre los métodos lock() y unlock(). Ahora la posesión del bloqueo es por hebra, lo cuál permitra llamadas recursivas o llamadas entre funciones diferentes que tiene todo (o parte) de su código bajo exclusión mutua controlada por el mismo cerrojo recursivo.

El cerrojo puede ser adquirido varias veces por la misma hebra

std::recursive_mutex mutex;

void inc () {
    std::lock_guard<std::recursive_mutex> cerrojo(mutex);
    val++;
}

Limitando el acceso a una sola vez: call_once

En ocasiones, interesa limitar a una única vez el número de veces que una función puede ser llamada, con independencia del número de hebras que la utilicen. Para ella, se puede utilizar la función call_once, que tiene el siguiente prototipo:

std::call_once(flag, funcion);

Aquí flag es un objeto de clases std::once_flag, que nos proporciona la semántica de "llamada una sola vez" sobre el código que se encuentra en el parámetro funcion.

// Establecemos la bandera de tipo once_flag
std::once_flag bandera;

void hacer_algo () {
    //el mensaje siguiente solo se mostrar una vez.
    std::call_once(bandera, [](){ std::cout << "Lamado una vez" << std::endl; });
    //Se mostrara tantas veces como hilos tengamos
    std::cout<<"Llamado cada vez"<<std::endl;
}

Tipos atómicos

La biblioteca <atomic> proporciona componentes para efectuar operaciones seguras sobre secciones críticas de grano fino (muy pequeñas), cada operación atómica es indivisible frente a otras operaciones atómicas sobre el mismo dato.

Los datos procesados de esta forma están libre de condiciones de carrera, es posible aplicar atomicidad sobre información compartida tanto sobre datos de tipo primitivo, como sobre datos definidos por el programador.

#include <atomic>

// se define el objeto atomico
std::atomic<int> x;

Monitores

En C++, un monitor es un objeto donde todos los métodos se ejecutan en EM bajo el control de un mismo cerrojo.

Si es necesario, es posible disponer de varias variables de condición, para hacer esperar a hebras por condiciones concretas. En tal caso, el cerrojo a utilizar deberá ser de clase unique_lock, la cuál permite instanciar variables de condición.

En caso de utilizar variables de condición, la semántica de señalización del monitor es SC, lo que obliga al uso de condiciones de guarda.