Introduction à la concurrence et au multithreading en C++
Les applications modernes exigent des performances maximales. La concurrence et le multithreading en C++ permettent d'utiliser efficacement les processeurs multicœurs, accélérant l'exécution des tâches. Sans comprendre ces concepts, il est impossible de créer des systèmes à forte charge, des gestionnaires de données ou des moteurs de jeu.
Dans cet article, nous allons analyser les mécanismes clés du multithreading en C++, en commençant par les bases std::thread et en terminant par les motifs avancés. Vous apprendrez à éviter les courses de données, à synchroniser correctement les threads et à écrire du code parallèle sécurisé.
Fondamentaux du multithreading : std::thread et std::jthread
La classe std::thread (C++11) est le fondement de la programmation multithread. Elle permet de lancer une fonction ou un objet callable dans un thread séparé. Voici un exemple simple :
#include <iostream>#include <thread>
void worker(int id) { std::cout << "Thread " << id << " a commencé le travail"; std::this_thread::sleep_for(std::chrono::seconds(1)); std::cout << "Thread " << id << " terminé";}
int main() { std::thread t1(worker, 1); std::thread t2(worker, 2); t1.join(); t2.join(); return 0;}Points clés :
- join() — le thread principal attend la fin du thread enfant
- detach() — le thread continue son travail indépendamment (risque de fuite de ressources)
- std::jthread (C++20) — appelle automatiquement join() dans le destructeur
Vérifiez toujours joinable() avant d'appeler join(), pour éviter une double jonction.
Synchronisation des threads : mutex et verrous
Le principal problème du multithreading est la course de données, lorsque plusieurs threads modifient simultanément une variable partagée. Les mutex sont utilisés pour la protection.
std::mutex et std::lock_guard
#include <iostream>#include <thread>#include <mutex>
std::mutex mtx;int counter = 0;
void increment() { for (int i = 0; i < 100000; ++i) { std::lock_guard<std::mutex> lock(mtx); ++counter; }}
int main() { std::thread t1(increment); std::thread t2(increment); t1.join(); t2.join(); std::cout << "Résultat : " << counter << ""; // 200000 return 0;}std::lock_guard libère automatiquement le mutex en sortant de la portée. Pour une gestion plus flexible, utilisez std::unique_lock.
Deadlock et std::scoped_lock
Un interblocage (deadlock) se produit lorsque deux threads s'attendent mutuellement. La solution est d'utiliser std::scoped_lock (C++17), qui verrouille plusieurs mutex de manière atomique :
std::mutex m1, m2;
void safe_function() { std::scoped_lock lock(m1, m2); // travail sécurisé avec deux ressources}Algorithmes parallèles et std::async
Pour exécuter des tâches asynchrones sans gestion manuelle des threads, utilisez std::async. Il retourne un std::future à partir duquel vous pouvez obtenir le résultat.
#include <iostream>#include <future>#include <chrono>
int compute(int n) { std::this_thread::sleep_for(std::chrono::seconds(n)); return n * n;}
int main() { auto future1 = std::async(std::launch::async, compute, 2); auto future2 = std::async(std::launch::async, compute, 3); std::cout << "Résultats : " << future1.get() << ", " << future2.get() << ""; // 4, 9 return 0;}Politiques de lancement :
std::launch::async— lancement dans un nouveau threadstd::launch::deferred— exécution différée lors de l'appel à get()
Files d'attente thread-safe et variables de condition
Pour les modèles producteur-consommateur, utilisez std::queue avec un mutex et une variable de condition std::condition_variable.
#include <iostream>#include <thread>#include <mutex>#include <condition_variable>#include <queue>
std::queue<int> data_queue;std::mutex mtx;std::condition_variable cv;
void producer() { for (int i = 0; i < 5; ++i) { std::this_thread::sleep_for(std::chrono::milliseconds(100)); std::lock_guard<std::mutex> lock(mtx); data_queue.push(i); cv.notify_one(); }}
void consumer() { while (true) { std::unique_lock<std::mutex> lock(mtx); cv.wait(lock, []{ return !data_queue.empty(); }); int value = data_queue.front(); data_queue.pop(); lock.unlock(); std::cout << "Valeur reçue : " << value << ""; if (value == 4) break; }}
int main() { std::thread prod(producer); std::thread cons(consumer); prod.join(); cons.join(); return 0;}La variable de condition permet au consommateur d'attendre efficacement sans boucle active.
Atomiques et mémoire modèle
Pour les opérations simples sur des variables partagées, utilisez std::atomic. Il garantit des opérations sans verrou et un ordre mémoire correct.
#include <iostream>#include <thread>#include <atomic>
std::atomic<int> counter(0);
void increment() { for (int i = 0; i < 100000; ++i) { counter.fetch_add(1, std::memory_order_relaxed); }}
int main() { std::thread t1(increment); std::thread t2(increment); t1.join(); t2.join(); std::cout << "Résultat : " << counter.load() << ""; // 200000 return 0;}Les ordres mémoire (memory_order_relaxed, memory_order_acquire, memory_order_release) contrôlent la visibilité des changements entre threads.
Bonnes pratiques et pièges courants
- Évitez les verrous globaux — utilisez des verrous fins pour minimiser la contention
- Préférez les algorithmes sans verrou lorsque c'est possible
- Utilisez des outils de détection — ThreadSanitizer (TSan) pour trouver les courses de données
- Évitez les deadlocks — verrouillez toujours les mutex dans le même ordre
- std::jthread est préférable à std::thread pour la sécurité
La concurrence en C++ est un outil puissant, mais elle nécessite de la discipline. Commencez par des mécanismes simples comme std::async et std::jthread, puis passez progressivement à des constructions plus complexes.