Bonnes pratiques du multithreading : édition C++ — éliminer les accidents avec RAII et jthread
· Mis à jour le: · Go Komura · Windows, Multithreading, C++, Visual Studio, Application métier, Investigation de bugs, Conception
Historique des révisions (première version, publiée le 2 Aug 2026)
- Première publication
Citer cet article(DOI (archive enregistrée): 10.5281/zenodo.22175856)
Les DOI ci-dessous renvoient à des versions déjà archivées et peuvent différer du texte actuel. Pour citer le texte actuel, utilisez l’URL de cette page.
Go Komura (2026). Bonnes pratiques du multithreading : édition C++ — éliminer les accidents avec RAII et jthread. KomuraSoft LLC. https://comcomponent.com/fr/blog/multithreading-best-practices-cpp/
- DOI (archive enregistrée)
- 10.5281/zenodo.22175856
- DOI (dernière version enregistrée)
- 10.5281/zenodo.22175857
« Une exception a été levée, et le nettoyage d’un std::thread a emporté toute l’application. » « J’ai levé le drapeau d’arrêt, mais le travailleur ne revient jamais. » « Une conception qui marchait en C# a été portée en C++, et maintenant ça plante de temps en temps. » En multithreading C++, avant d’aligner le travail, il faut décider comment les données partagées sont protégées et comment les threads se terminent.
En C++ en particulier, une course de données n’est pas un simple calcul faux ; c’est un comportement indéfini (undefined behavior). Le fait qu’un résultat correct soit sorti par hasard ne peut pas servir de preuve de sûreté.1
Cet article est l’édition C++ de la série pratique sur le multithreading, destinée aux développeurs qui écrivent des applications métier, de la commande d’équipement et des DLL en C++ moderne (C++17/20). Il procède dans cet ordre : choisir le moyen d’exécution, réduire le partage, implémenter la synchronisation et l’arrêt, puis vérifier les contraintes propres à Windows et la validation. Les exemples qui exigent C++20 sont marqués comme tels là où ils apparaissent.
Les mêmes principes sont déclinés dans d’autres langages dans « l’édition .NET », « l’édition langage C » et « l’édition Java », mais cet article se lit de manière autonome.
1. D’abord la conclusion — décider le partage et la durée de vie avant d’« ajouter de la synchronisation »
D’abord, réduire l’état mutable partagé, et protéger ce qui reste partagé avec la bibliothèque standard et le RAII. Puis faire de tout, de la demande d’arrêt jusqu’à l’achèvement du join, une seule conception.1
| Ordre des décisions | Ce qu’il faut décider | Où dans cet article |
|---|---|---|
| 1. Moyen d’exécution | Une tâche ponctuelle, un traitement parallèle sur une collection, ou un travailleur de longue durée ? Cherche-t-on à résoudre l’attente d’E/S en ajoutant des threads ? | Chapitre 3 |
| 2. Propriétaire des données | Le découpage, le passage par valeur, l’immuabilité ou une file peuvent-ils réduire les écritures vers l’état partagé ? | Chapitre 4 |
| 3. Protection de ce qui reste partagé | Quelles données sont gardées par quel mutex ? A-t-on distingué les mises à jour uniques gérées par atomic de la cohérence portant sur plusieurs variables ? | Chapitre 5 |
| 4. Procédure d’arrêt | La demande d’arrêt atteint-elle à la fois les threads en attente et ceux qui travaillent ? Peut-on enregistrer les exceptions et joindre à la fin ? | Chapitre 6 |
| 5. Placement et vérification | Les contraintes DLL, UI et COM sont-elles respectées, et peut-on investiguer avec journaux, dumps et essais de charge ? | Chapitres 7 à 8 |
Les outils par défaut sont std::jthread pour la durée de vie du thread, le RAII pour les verrous, le wait à prédicat pour l’attente, et std::atomic pour un drapeau ou un compteur partagé unique. volatile ne sert pas à la synchronisation. Avant C++20, la conception de l’arrêt se construit à partir d’un drapeau atomic et d’une variable de condition.2345
Choisir les outils ne termine toutefois pas le travail. Même si jthread joint automatiquement, il continue d’attendre si le travail ne peut pas se terminer. Même si atomic permute un pointeur, il ne protège pas la durée de vie de l’ancien objet. Vérifiez les deux à l’aide des exemples de code plus loin.
Dans le diagramme, un trait continu marque une relation qui vaut toujours et un trait pointillé une relation conditionnelle (les conditions figurent dans l’explication de chaque relation sur la page de détail). La liste complète des relations (27 au total, avec preuve et niveau de certitude) et les définitions des concepts principaux sont rassemblées sur la page de détail de la carte des connaissances (en japonais). Données : JSON-LD / Turtle
2. Trois préalables à comprendre d’abord — courses, attentes mutuelles et RAII
2.1. Une condition de concurrence dépend de l’ordre d’exécution, et une course de données C++ est un comportement indéfini
Une condition de concurrence (race condition) est un bug dont le résultat change selon l’ordre dans lequel les threads atteignent un morceau de travail. Décomposez le ++count du compteur partagé en « lire, additionner, réécrire », et vous voyez comment deux threads lisent la même valeur et une mise à jour disparaît.
flowchart TB
accTitle: Exemple de mise à jour perdue sur un compteur partagé
accDescr: Montre schématiquement comment une mise à jour est perdue lorsque deux threads lisent la même valeur, chacun additionne et réécrit.
A["count vaut 10"] --> B["A et B lisent tous deux 10"]
B --> C["Chacun additionne localement vers 11"]
C --> D["A réécrit 11"]
D --> E["B réécrit aussi 11"]
E -.-> F["En C++ le résultat ne se limite pas à cela"]
Figure 1 : Un schéma d’une addition perdue ; il ne borne pas le résultat d’un comportement indéfini en C++.
C’est un schéma pour comprendre les courses. En C++, lorsque plusieurs threads touchent le même emplacement mémoire non atomic, qu’au moins l’un d’eux écrit, et que la synchronisation requise est absente, la norme le classe comme comportement indéfini causé par une course de données. Le résultat réel ne se limite pas à « une addition est perdue ».1
Le compilateur optimise en supposant qu’il n’y a pas de courses de données. De ce fait, le comportement que l’on attend du code source ne peut plus être garanti, y compris le réordonnancement et la fusion des tests de condition, des lectures et des écritures. Ne supposez pas que « on lit soit l’ancienne valeur, soit la nouvelle ». Faire du drapeau d’arrêt un volatile bool ne résout pas non plus cela, parce que ce n’est pas de la synchronisation entre threads en C++.15
2.2. L’interblocage survient lorsque les « parties attendues » forment un cycle
Un interblocage est un état dans lequel chaque côté attend que l’autre libère un verrou qu’il détient, et aucun ne peut avancer. Il suffit que le thread A détienne le verrou 1 en attendant le verrou 2, et que le thread B détienne le verrou 2 en attendant le verrou 1.
flowchart TB
accTitle: Attente circulaire sur deux verrous
accDescr: Lorsque chaque côté attend le verrou que l'autre détient, aucun ne peut avancer.
A["A détient le verrou 1"] -->|"attend le verrou 2"| B["B détient le verrou 2"]
B -->|"attend le verrou 1"| A
Figure 2 : Lorsque les flèches d’attente forment un cycle, chaque côté s’arrête, attendant que l’autre avance.
Pour les courses comme pour les interblocages, l’ordre d’exécution problématique peut ne jamais apparaître sur la machine de développement, puis apparaître chez un client avec un autre nombre de cœurs ou une autre charge. Attacher un débogueur ou ajouter des journaux peut changer le timing et faire cesser la reproduction. C’est pourquoi, avant de synchroniser correctement, il importe de réduire les endroits qui ont besoin de synchronisation.
2.3. Utiliser le RAII pour faire du nettoyage, y compris les chemins d’exception, une partie de la structure
C++ n’a pas de finally ; à la place il a le RAII (Resource Acquisition Is Initialization), qui lie la gestion des ressources à la durée de vie des objets. L’objet qui a acquis un verrou le libère en quittant la portée, et l’objet qui possède un thread le joint lorsqu’il est détruit.1
Non seulement l’achèvement normal, mais aussi les chemins qui quittent la portée par un return anticipé ou une exception, empruntent le même nettoyage. Si vous lisez les enveloppeurs jthread et de verrou qui suivent comme des outils qui implémentent cette idée, le choix entre eux devient plus facile à voir.
3. Choisir le moyen d’exécution — d’abord les tâches, les threads au besoin
3.1. Séparer le travail ponctuel, le traitement sur une collection et l’attente d’E/S
Le principe « n’ajoutez pas de threads vous-même » vaut aussi en C++. Regardez d’abord la forme du travail, puis choisissez l’outil qui l’exprime.
| Forme du travail | Options principales | Ce qu’il faut vérifier d’abord |
|---|---|---|
| Une opération asynchrone ponctuelle et la réception de son résultat | std::async et std::future |
La politique de lancement et la durée de vie du future |
| Traitement parallèle sur une collection | PPL, algorithmes parallèles C++17 | La quantité de travail par itération et le traitement des exceptions |
| Un travailleur avec une durée de vie | std::jthread de C++20 |
Où la demande d’arrêt est observée, et le join |
| Attente d’E/S | Sous Windows, E/S OVERLAPPED ou IOCP | Utiliser l’E/S asynchrone plutôt qu’ajouter des threads |
flowchart TB
accTitle: Choisir le moyen d'exécution, puis décider de la durée de vie
accDescr: Choisir un moyen d'exécution qui convient à la forme du travail, et décider comment les résultats et l'arrêt sont gérés pour ce moyen.
A["Vérifier la forme du travail"] --> B{"Ce qui est exécuté"}
B -->|"Ponctuel ou traitement de collection"| C["Tâches ou algorithmes parallèles"]
B -->|"Traitement de longue durée"| D["Gérer la durée de vie du travailleur"]
B -->|"Attente d'E/S"| E["Envisager l'E/S asynchrone"]
C --> F["Concevoir résultats, exceptions et terminaison"]
D --> F
E --> F
Figure 3 : Avant d’ajouter des threads, décider de la forme du travail et de qui possède sa terminaison.
Évitez d’ajouter des threads seulement pour attendre des E/S. L’E/S OVERLAPPED et l’IOCP, les mécanismes qui tiennent ce rôle dans le code natif Windows, sont traités dans « Les profondeurs de l’E/S Windows (partie 2) ».
3.2. Avec async, décider ensemble la « condition de démarrage » et « combien de temps le résultat est tenu »
std::async est commode, mais il ne faut pas jeter le future renvoyé. Il y a deux points à surveiller.6
Le premier est le blocage à la destruction. Si un travail lancé avec std::launch::async est encore inachevé lorsque le future ou shared_future qui détient en dernier son état partagé est détruit, une attente d’achèvement se produit. Si vous jetez la valeur de retour sur place, vous attendez à la fin de cette expression alors que vous visiez l’asynchrone, ce qui équivaut à une exécution séquentielle.
Le second est l’exécution différée. Si vous ne spécifiez pas de politique de lancement, l’implémentation peut choisir deferred. Dans ce cas le travail ne s’exécute jamais à moins que quelqu’un n’appelle get() ou wait(). Là où vous avez besoin d’une exécution sur un autre thread de façon certaine, spécifiez std::launch::async explicitement, et décidez qui tient le future et pendant combien de temps.
flowchart TB
accTitle: Politique de lancement d'async et durée de vie du future
accDescr: Distingue le blocage à la destruction lorsqu'on lance avec async du cas où rien ne s'exécute sous deferred à moins que quelqu'un n'attende.
A["Appeler std::async"] --> B{"Politique de lancement choisie"}
B -->|"async"| C["S'exécute sur un autre thread"]
C --> D["Le dernier future est détruit"]
D -.-> E["Attend l'achèvement s'il est inachevé"]
B -->|"deferred"| F["Exécution différée jusqu'à get ou wait"]
F -.-> G["Ne s'exécute jamais si ni l'un ni l'autre n'est appelé"]
Figure 4 : Le comportement dépend non seulement de la politique de lancement, mais de la durée pendant laquelle le future est tenu.
3.3. Vérifier la granularité et les frontières d’exception dans les boucles parallèles
concurrency::parallel_for et parallel_for_each de la PPL (Parallel Patterns Library) appliquent une opération aux éléments d’une collection en parallèle. Si toutefois le travail par itération est trop petit, le surcoût de fork-join et d’ordonnancement l’emporte sur le gain. Le principe est d’exprimer le parallélisme à la boucle la plus externe possible.7
Avec les algorithmes parallèles C++17 vous pouvez utiliser std::execution::par. MSVC parallélise les algorithmes principaux, mais cela ne signifie pas que chaque algorithme s’exécute toujours en parallèle.8
De plus, si une exception s’échappe du traitement d’élément d’un algorithme qui utilise une politique d’exécution standard, cela mène à std::terminate. Placez le try/catch nécessaire non seulement chez l’appelant mais à l’intérieur du callback par élément. C’est la même idée que la frontière d’exception d’une fonction de thread traitée au chapitre 6.
3.4. Si vous possédez un thread, garantir le join aussi sur les chemins d’exception
Un std::thread qui est détruit alors qu’il est encore joinable, ni joint ni détaché, appelle std::terminate. Que le travail de la fonction de thread soit fini ou non, le côté objet doit achever le join.9
L’exemple suivant montre ce piège.
void process()
{
std::thread worker([]{ HeavyWork(); });
DoSomething(); // ← si une exception est levée ici...
worker.join(); // ← join n'est jamais atteint, et le destructeur de worker appelle terminate
}
Écrire join() à la fin ne protège pas à lui seul le chemin d’exception au milieu. Si vous utilisez std::thread, garantissez le join avec try/catch ou RAII. Si C++20 est disponible, faites de std::jthread le défaut ; lorsque le thread est joinable, son destructeur émet une demande d’arrêt puis joint. Dans MSVC, <stop_token> et jthread sont disponibles à partir de Visual Studio 2019 16.9.210
flowchart TB
accTitle: Destruction d'un objet thread et jonction
accDescr: Détruire un thread joinable mène à terminate, tandis que détruire un jthread émet une demande d'arrêt et joint.
A["Détruire l'objet thread"] --> B{"Est-il joinable"}
B -->|"Non"| C["Rien à joindre"]
B -->|"Oui"| D{"De quel type s'agit-il"}
D -->|"thread"| E["std::terminate"]
D -->|"jthread"| F["Émettre une demande d'arrêt et joindre"]
F -.-> G["Le travail lui-même doit se terminer"]
Figure 5 : jthread automatise la jonction mais ne termine pas le travail de force.
Ce qui est automatisé ici, c’est la demande d’arrêt et le join du propriétaire. Ce n’est pas une fonctionnalité qui attrape les exceptions s’échappant de la fonction de thread ni qui termine de force un travail qui ne s’arrête pas. Le chapitre 6 conçoit ces deux points séparément.
En principe, n’utilisez pas detach(). Parce qu’il renonce à tout moyen de joindre, la destruction des variables statiques ou du tas entre en course avec l’exécution du thread et mène à un plantage à la sortie. Faites de « pouvoir attendre la fin » une exigence de base de la conception.
4. Réduire le partage — découpage, passage par valeur, immuabilité et files
4.1. Tenir des sous-totaux par thread plutôt qu’un total partagé
L’état mutable partagé, cause des courses, peut se réduire avant d’ajouter des verrous. Pour une agrégation parallèle, au lieu que chaque thread mette à jour un total partagé, chaque thread construit un sous-total local et le fusionne exactement une fois à la fin.
Les écritures vers l’état partagé passent de « chaque itération » à « une fois par thread », donc le coût de synchronisation et les endroits où la contention peut se produire diminuent. Pour la mise à jour à la fusion, std::mutex ou le fetch_add de std::atomic conviennent.
flowchart TB
accTitle: Fusionner les sous-totaux par thread à la fin
accDescr: Au lieu de mettre à jour le total partagé à chaque fois, le sous-total de chaque thread est appliqué au total exactement une fois à la fin.
A["Découper l'entrée"] --> B["Sous-total du thread A"]
A --> C["Sous-total du thread B"]
B --> D["Synchroniser et fusionner à la fin"]
C --> D
D --> E["Total partagé"]
Figure 6 : Mettre à jour des données privées pendant l’itération, et rassembler les écritures vers l’état partagé au moment de la fusion.
4.2. Passer par valeur, mais vérifier ce que la copie pointe
Si les données nécessaires au démarrage sont passées par copie ou par move de sorte qu’elles appartiennent à ce seul morceau de travail, la synchronisation ultérieure peut se réduire. Plutôt que de s’appuyer sur [&], les lambdas utilisent des captures explicites, par copie ou par move en principe. Si vous utilisez une capture par référence, l’objet référencé doit vivre plus longtemps que le thread.
Toutefois, copier un pointeur ne rend pas privé l’objet qu’il désigne. Une structure qui contient des pointeurs bruts ou des shared_ptr partage encore par alias. On ne peut conclure « c’est sûr parce que j’ai copié la valeur » que lorsque tout le graphe de valeurs, y compris ce qui est référencé, est une valeur profonde sans état mutable partagé.
flowchart TB
accTitle: Copier un pointeur partage encore l'objet référencé
accDescr: Lorsqu'une valeur contenant un pointeur est copiée, les deux variables sont distinctes mais l'objet référencé reste le même.
A["Pointeur dans la valeur d'origine"] --> C["Le même objet référencé"]
A -.->|"Copier la valeur du pointeur"| B["Pointeur dans la copie"]
B --> C
C -.-> D["Synchronisation nécessaire pour le modifier"]
Figure 7 : Vérifier non seulement la valeur copiée, mais les objets qu’elle référence.
4.3. Si vous partagez via const, créer un état où « personne ne modifie »
Ce qui n’est pas modifié après construction, comme la configuration, les données de référence et les entrées de calcul, peut se partager en lecture seule. Par exemple, std::shared_ptr<const Config> interdit la modification via ce handle.
Mais si une référence non const reste ailleurs, ou si un membre mutable est modifié, la course demeure. Incluez dans la conception le point où une fois la construction terminée, les références non const sont relâchées et plus personne n’y écrit ensuite.
Lorsqu’un changement est nécessaire, la politique est de construire un nouvel objet et de le permuter plutôt que de modifier l’existant. Synchroniser le pointeur permuté et gérer la durée de vie de l’ancien objet restent toutefois requis séparément. Le § 5.4 les vérifie.
4.4. Donner aux files une limite de capacité et une attente qui peut s’arrêter
Faites transiter les remises entre threads par une file producteur-consommateur plutôt que de faire toucher des variables partagées directement. La bibliothèque standard C++17/20 traitée ici n’a pas de canal, donc une petite file qui combine un mutex et une variable de condition est la forme de base.
L’exemple suivant suppose C++20. Parce qu’il utilise un wait qui prend un std::stop_token, la variable de condition est std::condition_variable_any. Les valeurs de retour de Push et Pop signalent qu’une demande d’arrêt a été observée et que l’opération a été abandonnée.
template <typename T>
class BlockingQueue {
public:
explicit BlockingQueue(std::size_t capacity) : capacity_(capacity)
{
if (capacity == 0) // une capacité 0 est un piège où chaque Push attend pour toujours
throw std::invalid_argument("capacity must be positive");
}
// Si plein, attendre qu'il y ait de la place (ou une demande d'arrêt). false signifie une demande d'arrêt.
bool Push(T item, std::stop_token st)
{
{
std::unique_lock lock(mtx_);
if (!not_full_.wait(lock, st, [this]{ return queue_.size() < capacity_; }))
return false; // réveillé par une demande d'arrêt
if (st.stop_requested()) // si place et arrêt coïncident, l'arrêt l'emporte,
return false; // et aucun élément n'est accepté après le début de l'arrêt
queue_.push(std::move(item));
}
not_empty_.notify_one(); // notifier hors du verrou
return true;
}
// Attendre une demande d'arrêt (stop_token) ou l'arrivée d'un élément. nullopt à l'arrêt.
std::optional<T> Pop(std::stop_token st)
{
std::optional<T> item;
{
std::unique_lock lock(mtx_);
if (!not_empty_.wait(lock, st, [this]{ return !queue_.empty(); }))
return std::nullopt; // réveillé par une demande d'arrêt
if (st.stop_requested()) // si un élément et l'arrêt coïncident, l'arrêt l'emporte,
return std::nullopt; // et aucun nouveau travail n'est commencé après le début de l'arrêt
item = std::move(queue_.front());
queue_.pop();
}
not_full_.notify_one();
return item;
}
private:
const std::size_t capacity_;
std::mutex mtx_;
std::condition_variable_any not_empty_; // _any est choisi pour le wait conscient du stop_token
std::condition_variable_any not_full_;
std::queue<T> queue_;
};
Il y a trois points à regarder dans ce code.
| Ce qu’il faut vérifier | Où le code s’en occupe | Problème empêché |
|---|---|---|
| Que se passe-t-il lorsque la file est pleine | Fixer une limite de capacité et attendre de la place dans Push. Refuser la capacité 0 |
La production qui dépasse la consommation et une mémoire qui croît sans borne |
| Que vérifier après le retour d’une attente | Passer à wait un prédicat qui inspecte l’état de la file |
Revenir sans notification, ou la condition ayant changé au moment du réveil |
| Si une attente peut être quittée à l’arrêt | Utiliser le wait conscient du stop_token, et vérifier l’arrêt avant l’opération aussi |
Ne pas pouvoir sortir en attendant sur une file vide ou pleine |
Faire attendre le producteur lorsque la file est pleine fournit une backpressure, qui propage la surcharge vers l’amont. De plus, parce que les variables de condition ont des réveils parasites, où un thread se réveille sans notification, les attentes s’écrivent avec un prédicat. La forme à prédicat de wait prend en charge la boucle qui revérifie la condition.4
flowchart TB
accTitle: File avec une limite de capacité et levée d'attente
accDescr: Le producteur attend de la place lorsqu'elle est pleine, le consommateur attend un élément lorsqu'elle est vide, et la demande d'arrêt atteint les deux attentes.
A["Producteur"] --> B["Si plein, attendre de la place"]
B --> C["File avec une limite de capacité"]
C --> D["Si vide, attendre un élément"]
D --> E["Consommateur"]
S["Demande d'arrêt"] -.-> B
S -.-> D
Figure 8 : La limite de capacité fournit une backpressure, et la demande d’arrêt atteint aussi les attentes du producteur et du consommateur.
La politique de cet exemple est qu’une fois un arrêt observé, il ne traite pas tout ce qui reste ; il arrête le prochain push ou pop. Toutefois, la demande d’arrêt et une opération déjà en cours ne sont pas combinées en une seule opération indivisible. Il n’y a aucune garantie qu’une opération qui a passé le contrôle d’arrêt juste avant l’arrivée de la demande sera annulée, et le travail en cours est traité par l’arrêt coopératif du chapitre 6.
L’exemple est le squelette de la synchronisation et de l’arrêt. Fournissez les en-têtes requis et les types propres au métier là où vous l’intégrez, et traitez aussi les échecs de move d’éléments ou d’opérations de file à la frontière d’exception du chapitre 6.
5. Protéger ce qui reste partagé — discipline des verrous et portée d’atomic
5.1. Faire correspondre les verrous aux données qu’ils gardent, pas au code
Lorsque l’état mutable partagé ne peut pas être réduit à zéro, faites correspondre un mutex à chaque ensemble de données qu’il garde, et prenez le même mutex à chaque accès. Le mutex n’est pas exposé à l’extérieur ; la forme de base est de le tenir comme membre privé avec les données.1
Pendant que vous tenez le verrou, ne faites que les lectures et écritures courtes des données gardées. Appeler du code externe tel que des E/S fichiers, des appels réseau ou des callbacks tout en tenant le verrou allonge non seulement le temps de détention mais crée des chemins qui attendent des verrous à l’intérieur de l’appelé. Visez la forme préparer hors du verrou, et seulement permuter à l’intérieur.
flowchart TB
accTitle: Préparer hors du verrou et permuter à l'intérieur
accDescr: La préparation longue sort du verrou, et seul le changement des données partagées se produit dans une courte région verrouillée.
A["Préparer hors du verrou"] --> B["Acquérir le verrou avec RAII"]
B --> C["Modifier les données partagées"]
C --> D["Quitter la portée et libérer"]
D --> E["Effectuer les notifications externes et analogues"]
Figure 9 : Faire correspondre les données gardées à leur mutex, et tenir le traitement externe hors de la période de détention.
5.2. Laisser l’acquisition et la libération au RAII, et prendre plusieurs verrous ensemble
Si vous appariez lock() et unlock() à la main, vous oubliez la libération sur un return anticipé ou une exception. Laissez l’acquisition et la libération aux enveloppeurs suivants.
| Enveloppeur | Usage |
|---|---|
std::lock_guard |
La forme la plus basique : tenir un mutex pendant la durée d’une portée |
std::scoped_lock (C++17) |
Acquérir plusieurs mutex à la fois. Un algorithme d’évitement d’interblocage résout le problème d’ordre3 |
std::unique_lock |
Lorsque vous voulez libérer et réacquérir en cours de route, ou le passer à condition_variable::wait |
Si vous prenez plusieurs verrous séparément, unifiez l’ordre d’acquisition pour tous les threads. Si les verrous sont nécessaires en même temps, passez-les ensemble à std::scoped_lock et laissez l’évitement d’interblocage à l’acquisition à la bibliothèque. Ce mécanisme couvre l’acquisition des mutex que vous passez ; il n’empêche pas d’autres attentes circulaires, telles que des appels externes faits pendant que le verrou est tenu.3
Voici un exemple qui garde deux comptes à la fois. Concentrez-vous sur la façon dont les verrous sont acquis, pas sur des préoccupations métier telles que les contrôles de solde.
void Transfer(Account& from, Account& to, int amount)
{
if (&from == &to) return; // ne rien faire pour le même compte (voir note ci-dessous)
std::scoped_lock lock(from.mtx, to.mtx); // les deux à la fois, et la bibliothèque résout l'ordre
from.balance -= amount;
to.balance += amount;
}
Le contrôle d’identité en tête est obligatoire. Si le même compte est passé pour les deux arguments, le même mutex non récursif est passé deux fois, ce qui provoque un hang ou un comportement indéfini. Une fonction qui « verrouille les deux » doit exclure le même objet.
5.3. Choisir shared_mutex et recursive_mutex après confirmation de l’usage
Pour des données souvent lues et rarement écrites, le std::shared_mutex de C++17 donne un verrou lecteurs-rédacteur.11
recursive_mutex est un type qui permet la réacquisition par le même thread. Mais avoir besoin d’une acquisition récursive est souvent le signe que la responsabilité du verrou s’est brouillée, donc revoir la structure avant de régler l’affaire en changeant de type.
5.4. Les mises à jour atomiques et la durée de vie des objets sont des problèmes distincts
std::atomic fournit des opérations indivisibles sur une variable unique et un ordonnancement fondé sur memory_order. Son usage principal est la mise à jour de compteurs et de drapeaux. Lorsque plusieurs variables doivent rester cohérentes comme un ensemble, les rendre chacune atomic ne suffit pas ; gardez-les ensemble avec un mutex.5
std::atomic<T*> en particulier ne rend indivisible que la permutation du pointeur ; il n’étend pas la durée de vie de ce qu’il désigne. Si un lecteur obtient l’ancien pointeur et que, juste après, l’écrivain le permute et supprime l’ancien objet, le lecteur touche de la mémoire libérée.
flowchart TB
accTitle: Une permutation de pointeur atomic ne protège pas à elle seule la durée de vie
accDescr: L'ancien pointeur qu'un lecteur a obtenu perd son objet référencé lorsque l'écrivain le supprime après la permutation.
A["Le lecteur obtient l'ancien pointeur"] --> B["L'écrivain permute le pointeur"]
B --> C["L'écrivain supprime l'ancien objet"]
C --> D["Le lecteur accède à l'ancien objet"]
D --> E["Accès à de la mémoire libérée"]
B -.-> F["L'indivisibilité de la permutation seule ne suffit pas"]
Figure 10 : La permutation du pointeur et la durée de vie de l’objet référencé doivent être protégées séparément.
Si vous partagez des objets immuables en les permutant, choisissez un moyen qui inclut la gestion de la durée de vie, comme le remplacement d’un std::shared_ptr<const T> sous un verrou, ou le std::atomic<std::shared_ptr<T>> de C++20. Même avec shared_ptr, vous n’êtes pas libre de modifier les données référencées, comme l’expliquait le § 4.3.
volatile n’est pas non plus un substitut ici. Dans les applications métier, utilisez le seq_cst par défaut d’atomic ou écrivez-le avec un mutex. Les conceptions lock-free qui relâchent memory_order sont une option de spécialiste, limitée aux cas où vous pouvez expliquer à la fois la nécessité et les moyens de vérification.
6. Achever l’arrêt — demande, levée d’attente, traitement des exceptions et join
6.1. request_stop est une demande ; l’achèvement du join confirme l’arrêt
Dans une revue, il faut pouvoir expliquer « comment ça s’arrête » avant comment ça démarre. C++ n’a aucun moyen de forcer en sécurité la terminaison d’un thread depuis l’extérieur. Les dangers de TerminateThread de Win32 sont traités dans l’édition langage C.
La base est l’arrêt coopératif. Le côté qui arrête émet une demande, le thread lui-même sort à un point où il peut faire le ménage, et le propriétaire confirme que le join est achevé. En C++20, le request_stop() de jthread transmet la demande au stop_token passé à la fonction de thread.2
Dans une boucle de calcul, vérifiez stop_requested() ; pendant l’attente, recevez la demande d’arrêt via condition_variable_any::wait(lock, st, pred). La file du chapitre 4 est formée pour que même les attentes vide et pleine reviennent par ce chemin. Pouvoir lever une attente par une demande d’arrêt n’est pas la même chose que l’arrêt s’achevant dans un délai borné, ordonnanceur et réacquisition de verrou compris.
flowchart TB
accTitle: L'arrêt coopératif fait de tout jusqu'au join un seul chemin
accDescr: Le propriétaire demande un arrêt, le calcul et les attentes l'observent et sortent, et l'achèvement du join confirme l'arrêt.
A["Le propriétaire demande un arrêt"] --> B["Transmis au stop_token"]
B --> C["Vérifier la demande pendant le calcul"]
B --> D["Lever l'attente correspondante"]
C --> E["Faire le ménage et revenir"]
D --> E
E --> F["Le join du propriétaire s'achève"]
Figure 11 : Confirmer l’arrêt par l’achèvement du join, pas par le moment où la demande est émise.
6.2. Lire l’exemple Worker du point de vue de la durée de vie et du double démarrage
Voici un travailleur C++20 qui utilise la BlockingQueue du chapitre 4. WorkItem, Process et ReportError sont des types et des fonctions fournis par le côté métier. En particulier, implémentez ReportError de sorte qu’il ne lève pas.
class Worker {
public:
void Start()
{
if (thread_.joinable()) // Refuser un second Start pendant l'exécution.
throw std::logic_error("already running"); // Si vous assignez au lieu de refuser, deux
// travailleurs tournent côte à côte pendant que l'arrêt
// de l'ancien thread est attendu après le démarrage du nouveau
thread_ = std::jthread([this](std::stop_token st) {
try {
while (!st.stop_requested()) {
if (auto item = queue_.Pop(st)) { // se réveille aussi sur une demande d'arrêt
try {
Process(*item, st); // passer st aussi au travail qui peut bloquer en interne
} catch (...) {
ReportError(std::current_exception()); // un échec est enregistré, puis on continue
}
}
}
} catch (...) {
// Dernière ligne de défense à la frontière du thread (les échecs de Pop ou de move atterrissent ici aussi).
// Une exception qui s'échappe d'ici emporte tout le processus via std::terminate,
// donc implémenter ReportError de sorte qu'il ne lève pas
ReportError(std::current_exception());
}
});
}
// Pas de Stop explicite nécessaire :
// destructeur de Worker → destructeur de jthread → request_stop() + join()
private:
BlockingQueue<WorkItem> queue_{100}; // avec une limite de capacité (chapitre 4)
std::jthread thread_;
};
En tête de Start(), un second démarrage sur un thread joinable est refusé. Si vous assignez un nouveau jthread sans vérifier, deux threads peuvent tourner côte à côte pendant que l’arrêt de l’ancien est attendu après le démarrage du nouveau. Ce contrôle n’est pas un verrou qui synchronise des appels Start() concurrents de plusieurs appelants. Le préalable est que démarrage et destruction sont gérés en série du côté du propriétaire.
L’ordre de déclaration des membres fait aussi partie de la durée de vie. Dans l’exemple, thread_ est déclaré après queue_, donc thread_ est détruit en premier. Ce n’est qu’après l’achèvement de sa demande d’arrêt et de son join que la file utilisée par le travailleur est détruite.
flowchart TB
accTitle: Ordre de destruction des membres de Worker et durée de vie de la file
accDescr: Le jthread déclaré plus tard est détruit en premier, et la file utilisée par le travailleur est détruite après l'achèvement du join.
A["Détruire Worker"] --> B["Détruire thread_, déclaré plus tard"]
B --> C["Demande d'arrêt et join"]
C --> D["Confirmer que le travailleur est sorti"]
D --> E["Détruire queue_"]
Figure 12 : Ne pas détruire la file que le travailleur référence avant le join.
6.3. jthread n’attrape pas les exceptions qui s’échappent du travailleur
Le join automatique et le traitement des exceptions dans la fonction de thread sont distincts. Si une exception s’échappe de la fonction, cela mène à std::terminate avec jthread comme avec std::thread.
Le try/catch de l’exemple a deux rôles.
| Frontière d’exception | Cible | Politique dans l’exemple |
|---|---|---|
| Intérieure | Échec d’un seul élément de travail dans Process |
L’enregistrer et passer à l’élément suivant |
| Extérieure | Échec de la boucle dans son ensemble, y compris Pop et les move d’éléments |
L’enregistrer comme dernière ligne de défense et ne rien laisser s’échapper de la fonction |
flowchart TB
accTitle: Frontières d'exception pour un élément et pour tout le thread
accDescr: L'échec d'un élément et les échecs tels que les opérations de file sont attrapés à des frontières distinctes, et aucune exception ne s'échappe de la fonction de thread.
A["Boucle du travailleur"] --> B["Prendre dans la file"]
B --> C["Traiter un élément"]
C -.->|"Exception d'un élément"| D["Enregistrer à la frontière intérieure et continuer"]
D --> A
B -.->|"Exception de la prise, etc."| E["Enregistrer à la frontière extérieure et sortir"]
E -.-> F["La routine d'enregistrement ne doit pas lever non plus"]
Figure 13 : Plutôt que de s’appuyer sur le join automatique, rendre explicites les frontières d’exception de la fonction de thread.
Dans le travail métier réel, décidez s’il faut continuer après un échec ou le signaler au propriétaire via un canal d’erreur et s’arrêter. N’attrapez pas pour jeter en silence ; rendez-le observable.
6.4. Faire courir le chemin d’arrêt jusqu’à l’intérieur de Process
Le jeton est aussi passé à Process(*item, st) parce que le thread doit répondre à un arrêt pendant le traitement d’un seul élément, pas seulement pendant l’attente sur la file. Si un long calcul ou une attente réseau n’observe pas la demande, le join implicite du destructeur continue d’attendre jusqu’à la fin de ce traitement.
L’arrêt coopératif ne fonctionne que lorsque le chemin d’arrêt atteint chaque endroit qui attend. Donnez un délai d’expiration aux appels externes non interruptibles, et posez une borne supérieure sur le temps d’exécution d’un seul élément. Ajouter un jeton d’arrêt à la liste des arguments ne rend pas à lui seul cette API externe interruptible.
6.5. Avant C++20, apparier un drapeau d’arrêt à une notification
Dans les environnements où le mécanisme d’arrêt C++20 n’est pas disponible, construisez la même structure à partir d’un drapeau d’arrêt std::atomic<bool> et du notify_all de la variable de condition. Incluez aussi le drapeau d’arrêt dans le prédicat de l’attente. Si vous ne faites que lever le drapeau sans notifier, le thread en attente ne se réveille pas.4
De plus, même si le drapeau est atomic, une discipline reste nécessaire pour qu’une notification ne soit pas manquée entre le contrôle de condition et le début de l’attente. Arbitrez les changements d’état d’arrêt avec le même mutex que l’attente, et notifiez après avoir changé l’état. Vérifiez séparément que vous empêchez une course de données sur la valeur et que vous ne manquez pas la notification de réveil.
7. S’intégrer à Windows — les frontières des API de synchronisation, des DLL et de l’UI
7.1. Le C++ ordinaire utilise la bibliothèque standard ; choisir l’intégration Win32 d’après les exigences
Dans du code C++ où la portabilité compte, faites de std::mutex ou std::shared_mutex avec RAII le défaut. Les raisons de choisir des objets de synchronisation Win32 sont l’intégration avec les API d’attente Win32 et la synchronisation inter-processus.12
| Situation | Choix |
|---|---|
| Exclusion mutuelle intra-processus ordinaire | std::mutex + RAII (le défaut) |
| Beaucoup de lecteurs, peu d’écrivains | std::shared_mutex |
Attendre plusieurs objets à la fois avec WaitForMultipleObjects |
Objets noyau Win32 tels qu’événements et mutex |
| Exclusion ou notification inter-processus | Mutex, événements et sémaphores nommés |
| Un verrou intra-processus utilisant l’API Win32 directement | Verrou SRW (CRITICAL_SECTION seulement lorsque la récursion est nécessaire)12 |
flowchart TB
accTitle: Choisir entre synchronisation standard et intégration Win32
accDescr: Le code C++ ordinaire prend par défaut la synchronisation standard, et les objets Win32 sont choisis lorsque des attentes Win32 ou une synchronisation inter-processus sont nécessaires.
A["Vérifier les exigences de synchronisation"] --> B{"Attente Win32 ou inter-processus"}
B -->|"Non"| C["Mutex standard et RAII par défaut"]
B -->|"Oui"| D["Choisir des objets Win32"]
C -.-> E["Verrou SRW et analogues si Win32 est utilisé directement"]
Figure 14 : Choisir d’après les exigences d’intégration OS, et ne pas les confondre avec l’exclusion intra-processus ordinaire.
Évitez de remplacer l’exclusion intra-processus ordinaire par un Mutex Win32. Cela implique une transition noyau, coût inutile pour cet usage. La ligne est tracée ainsi : pour du code nouveau qui utilise Win32 directement, un verrou SRW, et CRITICAL_SECTION seulement lorsque le même thread a besoin d’une acquisition récursive.12
Pour une conception concrète qui garde de la mémoire partagée entre processus, voir « Pièges de la mémoire partagée et bonnes pratiques ».
7.2. Dans DllMain, ne pas démarrer, synchroniser ni attendre des threads
DllMain est appelée pendant que le verrou du chargeur est tenu. Y synchroniser avec d’autres threads, attendre qu’ils se terminent, ou appeler LoadLibrary provoque des interblocages et d’autres problèmes.13
Déplacez l’initialisation et le démontage qui démarrent ou joignent des threads dans des fonctions explicites hors de DllMain. jthread n’est pas une exception ; vérifiez où le join automatique a lieu.
flowchart TB
accTitle: Séparer le traitement des threads de DLL dans des fonctions explicites
accDescr: Ne pas synchroniser dans DllMain pendant que le verrou du chargeur est tenu, et séparer le démarrage des threads et l'attente de sortie dans des fonctions externes.
A["DllMain"] --> B["Verrou du chargeur tenu"]
B --> C["Pas de démarrage, de synchronisation ni de join ici"]
D["Fonction explicite hors de DllMain"] --> E["Gérer démarrage, arrêt et join"]
Figure 15 : Vérifier aussi où s’exécute le join implicite issu de la destruction d’un objet thread.
7.3. Déléguer les mises à jour d’UI au thread créateur, et ne pas s’attendre mutuellement via des notifications synchrones
Concentrez les opérations sur les fenêtres et les contrôles dans le thread d’UI qui les a créés. Plutôt que de mettre à jour l’écran directement depuis un travailleur, la forme de base est de déléguer avec le PostMessage asynchrone et de traiter dans la procédure de fenêtre du côté UI.
Le SendMessage synchrone, appelé pendant que le thread d’UI attend la fin du travailleur, crée une attente circulaire. Les notifications du travailleur sont rendues asynchrones pour éviter ce chemin.
flowchart TB
accTitle: Attente circulaire par notification synchrone entre UI et travailleur
accDescr: Lorsque l'UI attend la fin du travailleur tandis que le travailleur attend le traitement de SendMessage, une attente circulaire en résulte.
A["Thread d'UI"] -->|"Attend la fin du travailleur"| B["Thread travailleur"]
B -->|"Attend l'achèvement de SendMessage"| A
C["Notification du travailleur"] --> D["Déléguer avec PostMessage"]
D --> E["Traité du côté UI"]
Figure 16 : Rendre la délégation vers l’UI asynchrone par défaut, et ne pas créer d’attentes mutuelles d’achèvement.
Pour les contraintes STA/MTA lorsque COM est impliqué, voir « Fondamentaux COM STA/MTA ». C++/CLI a aussi ses propres contraintes : dans du code compilé avec /clr, les en-têtes de threading standard tels que <thread> et <mutex> sont bloqués.14
8. Vérifier — se préparer en trois couches : tableau de conception, observation et essais de stress
8.1. Avant de regarder les résultats, confirmer que l’on peut expliquer le partage et l’arrêt
Même si les tests ordinaires passent, cela peut seulement signifier que cette exécution n’a pas eu de course. La première ligne de défense est la conception jusqu’ici. Dans une revue, confirmez ce qui suit sous forme de tableau.
| Cible | Ce que vous devez pouvoir expliquer |
|---|---|
| Données mutables partagées | Qui les lit et les écrit, et quel mutex les garde |
| Plusieurs verrous | Si l’ordre d’acquisition est unifié, ou s’ils sont pris ensemble avec scoped_lock |
| Données passées | S’il reste des alias après copie, et si la durée de vie suffit |
| Arrêt | Où la demande est observée, où les attentes sont levées, et qui joint |
Une conception qui ne peut pas expliquer cette correspondance n’est pas achevée, même si elle tourne.
8.2. Rendre les anomalies visibles avec délais, journaux et dumps
Pour les verrous qui ne devraient pas se prendre et les attentes qui ne devraient pas finir, envisagez des délais tels que timed_mutex::try_lock_for ou condition_variable::wait_for. Si vous journalisez l’expiration, un hang silencieux devient un échec que l’on peut détecter. Enregistrez aussi toujours les exceptions capturées à la frontière du thread.
Sur le terrain, si un hang ou un plantage survient, confirmez depuis le dump la pile de tous les threads et suivez si les attentes de verrous forment un cycle. La façon de s’y préparer est traitée dans « Concevoir les applications Windows pour laisser des journaux et des dumps en cas de plantage ».
8.3. Même en compilation release, secouer l’ordre d’exécution et la charge
Tourner longtemps avec plus de parallélisme qu’il n’y a de cœurs, randomiser l’ordre de traitement, insérer des délais artificiels : de tels essais de stress rendent plus facile de tirer un ordre d’exécution problématique. Appliquez la charge non seulement en compilation debug, mais aussi en compilation release optimisée.
flowchart TB
accTitle: Préparer conception et observation puis essayer sous stress
accDescr: Confirmer le partage et l'arrêt dans la conception, rendre observable par journaux et dumps, puis essayer en variant charge et ordre d'exécution.
A["Confirmer partage, verrous, durée de vie et arrêt"] --> B["Préparer journaux et dumps"]
B --> C["Essayer en variant charge et ordre d'exécution"]
C --> D["Rapporter les problèmes trouvés à la conception"]
D --> A
Figure 17 : Ne pas prendre le seul succès des essais pour une preuve de sûreté ; combiner conception, observation et essais.
Les essais ne remplacent pas la conception. L’ordre est de protéger le partage et la durée de vie par la structure, d’observer les anomalies, et de chercher les points faibles par les essais.
9. Synthèse — liste de contrôle de l’édition C++
Enfin, confirmez dans l’implémentation C++ que l’on n’ajoute pas de threads directement, que l’on réduit l’état mutable partagé, que l’on fait correspondre verrous et données, et que l’on arrête de façon coopérative.
std::threadn’est-il pas utilisé nu (jthreadest-il possible, le join est-il garanti aussi sur les chemins d’exception) ?detach()n’est-il pas utilisé ?- Les captures de lambda sont-elles explicites, et la durée de vie des variables capturées par référence est-elle plus longue que le thread ?
- Peut-on affirmer qu’il n’y a nulle part d’accès mutable partagé sans synchronisation (= comportement indéfini) ?
- N’y a-t-il pas de
lock()/unlock()écrits à la main, et les verrous multiples sont-ils pris ensemble avecscoped_lock? - Tous les
condition_variable::waitsont-ils à prédicat ? - Les drapeaux partagés n’utilisent-ils pas
volatile(sont-ils enstd::atomic) ? - Le chemin d’arrêt est-il conçu avec
stop_token(ou drapeau atomic + notification), et l’achèvement du join confirme-t-il la jonction ? - Le future de
std::asyncn’est-il pas jeté ? DllMainne démarre-t-elle pas, ne synchronise-t-elle pas et ne joint-elle pas de threads ?
En C++, une course de données est un comportement indéfini, donc on ne peut pas s’appuyer sur « d’habitude ça marche ». Pour autant, en suivant le RAII et les usages de la bibliothèque standard, on peut réduire structurellement les échecs de nettoyage et de synchronisation.
Choisir le moyen d’exécution, réduire le partage, protéger ce qui reste partagé, et achever de la demande d’arrêt jusqu’au join. Utiliser jthread, scoped_lock, le wait à prédicat et atomic dans cet ordre est le fondement de la pratique.
Articles associés
- Bonnes pratiques du multithreading : édition .NET
- Bonnes pratiques du multithreading : édition langage C
- Bonnes pratiques du multithreading : édition Java
- Envelopper des DLL natives avec C++/CLI en pratique
- Pièges de la mémoire partagée et bonnes pratiques
- Fondamentaux COM STA/MTA - modèle de threads et façon d’éviter les hangs
- Les profondeurs de l’E/S Windows (partie 2) — E/S synchrone et asynchrone : le vrai sens d’OVERLAPPED
Domaines de conseil associés
KomuraSoft LLC assure des revues de conception multithread pour applications et DLL C++, l’investigation de défaillances liées aux courses telles que « ça plante de temps en temps, ou seulement en compilation release » (analyse de dumps), et le conseil sur la migration de code de threads héritage vers le C++ moderne.
- Conseil technique et revue de conception
- Investigation de bugs et analyse de cause
- Développement d’applications Windows
- Nous contacter
Références
-
ISO C++, C++ Core Guidelines - CP: Concurrency and parallelism. Sur le fait que les règles d’ouverture du chapitre concurrence et parallélisme sont CP.1 (supposez que votre code tourne en multithread) et CP.2 (évitez les courses de données), qu’en présence d’une course de données aucune garantie ne tient, et que les règles de conception du code concurrent, telles que la portée de détention des verrous et l’usage du RAII, y sont systématisées. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
cppreference.com, std::jthread. Sur le fait que le jthread de C++20, contrairement à std::thread, appelle automatiquement request_stop() puis join dans son destructeur ; qu’il peut recevoir un std::stop_token comme premier argument de la fonction de thread ; et que cela garantit la jonction du thread et la demande d’arrêt même en cas d’exception. ↩ ↩2 ↩3
-
Microsoft Learn, scoped_lock Class. Sur le fait que le scoped_lock de C++17 acquiert un ou plusieurs mutex à la construction et les libère dans le destructeur ; que lorsqu’on passe plusieurs mutex ils sont acquis avec un algorithme d’évitement d’interblocage équivalent à std::lock ; qu’ils sont libérés même si une exception est levée ; et que pour un seul mutex lock_guard/unique_lock restent des options. ↩ ↩2 ↩3
-
Microsoft Learn, <condition_variable>. Sur le fait que l’attente d’une variable de condition exige un mutex, et que le verrou est relâché pendant l’attente ; qu’il existe des réveils parasites sans notification, si bien que le côté qui attend doit vérifier explicitement la condition au retour, le wait(lock, pred) à prédicat se chargeant de cette boucle ; et que condition_variable_any peut se combiner à n’importe quel type de mutex. ↩ ↩2 ↩3
-
Microsoft Learn, <atomic>. Sur le fait que les opérations atomiques sont indivisibles, si bien que les autres threads n’observent que l’état avant ou après l’opération ; que l’argument memory_order établit des exigences d’ordonnancement sur la visibilité des autres opérations atomiques et inhibe les optimisations du compilateur qui s’y opposeraient ; que atomic_flag est toujours lock-free ; et que ce header est bloqué avec /clr:pure. ↩ ↩2 ↩3
-
Microsoft Learn, <future>. Sur le fait que les destructeurs de future et shared_future ne bloquent en principe pas, la seule exception étant qu’un future (ou le dernier shared_future) lié à une tâche lancée par std::async bloque jusqu’à ce que l’état partagé soit ready si le destructeur s’exécute alors que la tâche n’est pas achevée, ce comportement étant explicitement noté dans la norme. ↩
-
Microsoft Learn, Best Practices in the Parallel Patterns Library. Sur le fait que le parallélisme doit s’exprimer au plus haut niveau possible (boucle externe) ; que dans une boucle parallèle dont le travail par itération est petit ou déséquilibré, le surcoût d’ordonnancement fork/join peut l’emporter sur le gain de l’exécution parallèle ; et que cette tendance se renforce à mesure que le nombre de processeurs augmente. ↩
-
Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. Sur le fait que la bibliothèque d’algorithmes parallèles C++17 est complète, tout en précisant que « complète » ne signifie pas que tous les algorithmes sont parallélisés dans tous les cas : les algorithmes les plus importants sont parallélisés, et ceux qui ne le sont pas reçoivent néanmoins la signature de politique d’exécution. ↩
-
cppreference.com, std::thread::~thread. Sur le fait que le destructeur de std::thread appelle std::terminate si le thread est encore joinable (ni join ni detach) lorsqu’il est appelé, c’est-à-dire qu’il faut avoir tranché join ou detach avant de détruire l’objet thread. ↩
-
Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. Sur le fait que P0660R10 (<stop_token> et jthread) et P1135R6 (bibliothèque de synchronisation C++20) sont pris en charge dans Visual Studio 2019 16.9, et sur l’état de conformité des fonctionnalités de la bibliothèque standard C++ par version. ↩
-
Microsoft Learn, C++ standard library header files. Sur le recensement des headers standard liés au multithread : <atomic> (C++11), <mutex> (C++11), <shared_mutex> (C++14), <condition_variable> (C++11), <future> (C++11), <stop_token> · <semaphore> · <latch> · <barrier> (C++20), <thread> (C++11). ↩
-
Microsoft Learn, About Synchronization. Sur la consigne de choix des primitives de synchronisation Win32 : pour du code C++ soucieux de portabilité, std::mutex / std::shared_mutex et RAII sont recommandés ; les objets de synchronisation Win32 s’utilisent lorsque des API d’attente Win32 ou une synchronisation inter-processus sont nécessaires ; le défaut pour du code nouveau intra-processus est le verrou SRW, CRITICAL_SECTION seulement en cas d’acquisition récursive ; et utiliser un Mutex pour la synchronisation intra-processus est une « erreur courante » qui implique toujours une transition noyau. ↩ ↩2 ↩3
-
Microsoft Learn, Dynamic-Link Library Best Practices. Sur le fait que DllMain est appelée pendant que le verrou du chargeur est tenu, ce qui place de sérieuses restrictions sur les API appelables ; que synchroniser avec d’autres threads à l’intérieur de DllMain peut mener à l’interblocage ; que l’appel de LoadLibrary et l’attente de fin de threads sont des interdits typiques ; que l’initialisation doit être différée autant que possible hors de DllMain ; et qu’il faut définir une hiérarchie de verrous avec le verrou du chargeur au sommet. ↩
-
Microsoft Learn, <thread>. Sur le fait que le header <thread> définit la classe thread et des fonctions auxiliaires telles que sleep_for ; que ce header est bloqué dans le code compilé avec /clr ; et que la macro STDCPP_THREADS permet de déterminer si le support des threads est présent. ↩
Articles associés
Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.
Bonnes pratiques du multithreading : édition .NET — ce qu'il faut décider avant d'ajouter des threads
Empêcher les threads .NET/C# de planter ou de figer. S'appuyer sur Task, réduire l'état mutable partagé, verrouiller avec discipline, arr...
DllMain et le verrou du chargeur — la vraie raison pour laquelle on vous dit de « ne rien faire dans l'initialisation d'une DLL »
Pourquoi il ne faut pas appeler LoadLibrary ni synchroniser avec d'autres threads depuis DllMain. À partir des sources primaires, cet art...
Réveils parasites — pourquoi une variable de condition se réveille « sans notification » et comment attendre correctement sous Windows
Le wait d'une variable de condition peut revenir sans notification (réveil parasite). L'article explique, d'après l'implémentation Window...
Bonnes pratiques du multithreading : édition Java — les usages à l'ère des threads virtuels
En Java, la pratique établie consiste à ne pas créer de threads directement, mais à s'appuyer sur ExecutorService et les threads virtuels...
Bonnes pratiques du multithreading : édition C — écrire en sécurité à la manière Win32
En C sur Win32, la pratique établie est _beginthreadex, les verrous SRW et les variables de condition, Interlocked, et un événement d'arr...
Sujets associés
Ces pages replacent le sujet dans un contexte plus large de services et de décisions.
Thèmes techniques Windows
Portail des sujets sur le développement Windows, l'analyse des incidents et la valorisation des actifs existants.
Services liés à ce sujet
Cet article est directement lié aux services suivants.
Développement d'applications Windows
Applications métier, intégration d'équipements et outils de communication, des besoins au développement.
Conseil technique et revue de conception
Clarification de la stratégie de modification, de la conception et du traitement des actifs existants.
Questions fréquentes
Questions souvent posées lors d’une consultation sur le sujet de cet article.
- Comment choisir entre std::mutex et les verrous Win32 CRITICAL_SECTION / SRW ?
- Dans du code C++ ordinaire soucieux de portabilité, std::mutex / std::shared_mutex accompagnés de wrappers RAII (lock_guard / scoped_lock) sont le premier choix. Choisir les objets de synchronisation Win32 se justifie lorsqu'on veut les combiner avec une API d'attente Win32 comme WaitForMultipleObjects, ou lorsqu'une synchronisation entre processus via un objet nommé est nécessaire. Si vous utilisez directement les API Win32 à l'intérieur d'un processus, le choix par défaut pour du nouveau code est le verrou SRW, et vous n'employez CRITICAL_SECTION que lorsque l'acquisition récursive par le même thread est nécessaire. Utiliser un Mutex Win32 pour l'exclusion intra-processus est une erreur classique, car cela implique toujours une transition noyau et reste lent.
- Peut-on utiliser detach() sur un std::thread ?
- En principe, évitez-le. Un thread détaché perd tout moyen d'être rejoint (join), et vous ne pouvez plus contrôler s'il tourne encore ou non au moment où le processus se termine. Le cas classique est un thread détaché qui continue de s'exécuter après la destruction des variables statiques ou du tas, provoquant un plantage à la fermeture. Pouvoir « attendre la fin » est une exigence fondamentale de la conception d'un thread, donc utilisez jthread (join automatique), ou, avec thread, structurez toujours le code pour effectuer le join avant la fin de la portée. detach() n'est acceptable que dans des cas très limités, où le thread peut partager le sort du processus et où vous pouvez garantir qu'il ne touche à aucun état partagé.
- volatile peut-il servir à la synchronisation entre threads en C++ ?
- Non. Le volatile de C++ est un qualificatif destiné à des cas comme les E/S mappées en mémoire — des lectures/écritures que l'on ne veut pas voir optimisées par le compilateur — et ne garantit ni la visibilité ni l'ordonnancement entre threads. Si plusieurs threads accèdent à la même variable sans synchronisation, c'est une course de données, donc un comportement indéfini. Utilisez std::atomic pour les drapeaux et compteurs partagés entre threads, et std::mutex lorsqu'il faut protéger plusieurs variables ensemble. std::atomic fournit à la fois l'indivisibilité des opérations et un ordonnancement fondé sur memory_order.
- std::async semble pratique, mais comporte-t-il des pièges ?
- Le plus grand piège est le destructeur du future. Le future (ou le dernier shared_future) associé à une tâche lancée par std::async bloque jusqu'à l'achèvement si son destructeur s'exécute alors que la tâche n'est pas terminée. Si vous récupérez la valeur de retour sans la stocker et la laissez être détruite immédiatement, cela équivaut sur-le-champ à une exécution synchrone — un accident classique où l'on croyait avoir rendu le traitement asynchrone, mais qui reste séquentiel. De plus, si vous ne spécifiez pas de politique de lancement, savoir si le travail s'exécute réellement sur un autre thread est laissé à la discrétion de l'implémentation. Si vous l'utilisez, gérez explicitement la durée de vie du future, et spécifiez std::launch::async partout où vous voulez garantir une exécution réellement concurrente.
Profil de l’auteur
Page de présentation de l’auteur de l’article.
Go Komura
Représentant de KomuraSoft LLC
Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.