Concorrenza con thread e mutex in C++
I processori moderni dispongono di più core, e per sfruttarne appieno la potenza è necessario eseguire più operazioni contemporaneamente. La programmazione concorrente consente proprio questo: suddividere il lavoro in flussi di esecuzione paralleli chiamati thread. A partire da C++11, il linguaggio offre un supporto nativo e portabile alla concorrenza attraverso l'intestazione thread, insieme agli strumenti di sincronizzazione necessari a coordinare in sicurezza l'accesso ai dati condivisi.
Creare un thread
Un thread si crea costruendo un oggetto std::thread e passandogli una funzione o una lambda da eseguire. Il thread inizia immediatamente la propria esecuzione, in parallelo rispetto al thread che lo ha creato. Prima che l'oggetto thread venga distrutto, occorre decidere il suo destino: attenderne il completamento con il metodo join, oppure sganciarlo con detach per lasciarlo proseguire in modo indipendente.
#include <thread>
#include <iostream>
void worker() {
std::cout << "Lavoro nel thread secondario" << std::endl;
}
int main() {
std::thread t(worker); // Avvia un nuovo thread
std::cout << "Lavoro nel thread principale" << std::endl;
t.join(); // Attende che il thread secondario finisca
return 0;
}
Dimenticare di invocare join oppure detach prima che l'oggetto thread venga distrutto provoca la terminazione immediata del programma. L'ordine di esecuzione dei messaggi provenienti da thread diversi non è garantito: dipende dallo scheduler del sistema operativo e può variare a ogni esecuzione.
Le corse critiche
Il vero banco di prova della concorrenza è l'accesso ai dati condivisi. Quando due o più thread leggono e scrivono la stessa variabile senza coordinamento, si verifica una corsa critica: il risultato finale dipende dall'ordine imprevedibile con cui le operazioni si intrecciano, e può essere scorretto. Un'operazione apparentemente semplice come l'incremento di un contatore non è atomica, perché si compone di più passi che thread diversi possono interrompere a vicenda.
Il mutex
Per proteggere l'accesso ai dati condivisi si usa un mutex, ovvero un dispositivo di mutua esclusione. Un solo thread per volta può possedere il mutex; gli altri restano in attesa finché non viene rilasciato. La regione di codice protetta dal mutex, detta sezione critica, viene così eseguita da un thread alla volta. Anziché bloccare e sbloccare il mutex manualmente, si usa std::lock_guard, un oggetto RAII che acquisisce il mutex alla costruzione e lo rilascia automaticamente alla distruzione.
#include <thread>
#include <mutex>
#include <iostream>
int counter = 0;
std::mutex counterMutex;
void increment() {
for (int i = 0; i < 10000; i++) {
// Il lock protegge l'accesso concorrente al contatore
std::lock_guard<std::mutex> lock(counterMutex);
counter++;
}
}
int main() {
std::thread first(increment);
std::thread second(increment);
first.join();
second.join();
std::cout << counter << std::endl; // Risultato corretto: 20000
return 0;
}
Senza il mutex, il valore finale del contatore sarebbe imprevedibile e quasi sempre inferiore a quello atteso, proprio a causa delle corse critiche. Con la protezione, invece, il risultato è deterministico e corretto.
Le variabili atomiche
Per operazioni semplici su singole variabili, come l'incremento di un contatore, esiste un'alternativa più leggera al mutex: i tipi atomici forniti dall'intestazione atomic. Un tipo atomico garantisce che le operazioni su di esso avvengano in modo indivisibile, senza possibilità di interruzione da parte di altri thread, evitando la necessità di un blocco esplicito.
#include <atomic>
// L'incremento e garantito atomico senza mutex
std::atomic<int> counter{0};
void increment() {
for (int i = 0; i < 10000; i++) {
counter++;
}
}
Attendere un risultato con async e future
Quando si vuole eseguire un compito in parallelo e recuperarne il risultato, l'intestazione future offre un'astrazione di livello più alto. La funzione std::async avvia un'operazione, potenzialmente su un altro thread, e restituisce un oggetto std::future che rappresenta il risultato futuro. Invocando il metodo get su di esso si attende il completamento e si ottiene il valore prodotto.
#include <future>
#include <iostream>
int computeSum(int a, int b) {
return a + b;
}
int main() {
// Avvia il calcolo in modo asincrono
std::future<int> result = std::async(computeSum, 10, 20);
// Recupera il risultato quando e pronto
std::cout << result.get() << std::endl;
return 0;
}
La concorrenza è potente ma esige disciplina. Le corse critiche, i deadlock e i comportamenti non deterministici rendono la programmazione parallela una delle aree più insidiose del C++. La regola di fondo è minimizzare i dati condivisi tra thread e, quando la condivisione è inevitabile, proteggerli rigorosamente con mutex o tipi atomici. Preferire astrazioni di alto livello come std::async quando possibile aiuta a scrivere codice concorrente più chiaro e meno soggetto a errori.