Concurrence et multithreading en C++ : des bases aux techniques avancées
Les applications modernes exigent de plus en plus de performances maximales, en particulier lorsqu'elles travaillent avec des processeurs multicœurs. C++ offre des outils puissants pour la concurrence et le multithreading, depuis C++11 jusqu'aux derniers standards. Dans cet article, nous aborderons les aspects avancés du multithreading : de l'utilisation correcte des mutex et des opérations atomiques aux modèles de synchronisation complexes. Le contenu est destiné aux développeurs déjà familiarisés avec les bases des threads en C++.
1. Gestion des threads : std::thread et std::jthread
La classe std::thread est la base de la programmation multithread en C++. Cependant, à un niveau avancé, il est important non seulement de créer un thread, mais aussi de gérer correctement son cycle de vie. Avec C++20, std::jthread est apparu, qui se joint automatiquement lors de la destruction, évitant ainsi les fuites de ressources.
Exemple d'utilisation de std::jthread :
#include <iostream>#include <thread>#include <chrono>
void worker(int id) { std::this_thread::sleep_for(std::chrono::milliseconds(100 * id)); std::cout << "Thread " << id << " terminé";}
int main() { // std::jthread se joint automatiquement à la sortie de la portée std::jthread t1(worker, 1); std::jthread t2(worker, 2); std::jthread t3(worker, 3); // Pas besoin d'appeler explicitement join() ou detach() // Lors de la destruction de t1, t2, t3, main attend leur fin std::cout << "Le thread principal continue son travail"; return 0;}Il est important de se rappeler : std::jthread prend également en charge le mécanisme d'arrêt via std::stop_token, ce qui permet de terminer en toute sécurité les tâches d'arrière-plan longues.
2. Synchronisation d'accès : mutex, verrous et variables conditionnelles
Le principal problème du multithreading est les courses de données. Pour les éviter, on utilise des mutex. À un niveau avancé, il est recommandé d'utiliser std::scoped_lock (C++17) pour verrouiller plusieurs mutex sans risque d'interblocage.
Exemple avec std::scoped_lock :
#include <iostream>#include <mutex>#include <thread>#include <vector>
std::mutex mtx1, mtx2;int shared_data = 0;
void safe_increment(int id) { // scoped_lock verrouille les deux mutex dans l'ordre, évitant le deadlock std::scoped_lock lock(mtx1, mtx2); ++shared_data; std::cout << "Thread " << id << " a augmenté la valeur à " << shared_data << "";}
int main() { std::vector<std::jthread> threads; for (int i = 0; i < 10; ++i) { threads.emplace_back(safe_increment, i); } return 0;}Pour des scénarios de synchronisation plus complexes (par exemple, producteur-consommateur), on utilise des variables conditionnelles (std::condition_variable). Elles permettent aux threads d'attendre qu'un certain événement se produise, sans gaspiller de ressources en attente active.
3. Opérations atomiques et modèle mémoire C++
Les opérations atomiques (std::atomic) sont la base de la programmation sans verrou (lock-free). Elles garantissent que la lecture et l'écriture d'une variable sont effectuées comme une seule action indivisible. Cependant, à un niveau avancé, il est important de comprendre le modèle mémoire C++ et les niveaux d'ordonnancement (memory ordering).
Principaux niveaux de memory ordering :
memory_order_relaxed— garanties minimales, atomicité uniquementmemory_order_acquire— garantit que toutes les lectures et écritures ultérieures verront les résultats des opérations précédentesmemory_order_release— garantit que toutes les opérations précédentes seront visibles après cette écriturememory_order_acq_rel— combinaison d'acquire et de release