Bonnes pratiques de multithreading en pratique — édition C++ : éliminer les accidents structurellement avec RAII et jthread
· Go Komura · Windows, Multithreading, C++, Visual Studio, Application métier, Investigation de bugs, Conception
« Une conception qui fonctionnait en C# s’est mise à planter de temps en temps une fois portée en C++. » « En utilisant std::thread, dès qu’une exception survenait, toute l’application s’est arrêtée net avec terminate. » « On arrêtait le traitement avec un drapeau volatile bool, mais en build Release, seul, il ne s’arrêtait plus. » — le multithreading en C++ comporte une frayeur propre, absente des langages managés. Une course de données y devient directement un comportement indéfini (undefined behavior). Cela ne se limite pas à lire une valeur corrompue : les hypothèses d’optimisation du compilateur s’effondrent, et on entre dans un état où « n’importe quoi peut arriver sans que ce soit anormal ».
Cet article est l’édition C++ de notre série pratique sur le multithreading. Destiné aux développeurs qui écrivent des applications métier, du pilotage d’équipement ou des DLL en C++ moderne (C++17/20), il traduit les principes de conception du multithreading — ne pas augmenter directement le nombre de threads, réduire l’état mutable partagé, discipliner les verrous, concevoir la façon de s’arrêter dès le départ — dans les outils de C++ et de Windows, et les organise, avec les pièges propres à C++, à partir de sources primaires datant d’août 2026. Il est écrit pour se lire de façon autonome. Les mêmes principes déclinés dans d’autres langages sont disponibles dans l’« édition .NET », l’« édition C » et l’« édition Java ».
1. Conclusion, d’abord
- En C++, une course de données n’est pas « lire une valeur corrompue », c’est un « comportement indéfini ». Ne laisser subsister aucun accès mutable partagé non synchronisé est une condition absolue, plus encore que dans les autres langages.1
- N’utilisez pas
std::threadà nu. Si le destructeur destd::threads’exécute alors que le thread est encore joinable,std::terminatemet fin au processus sur-le-champ. Lestd::jthreadde C++20 effectue un join automatique dans son destructeur et intègre aussi une demande d’arrêt (stop_token).23 - Détenez toujours vos verrous via RAII. Abandonnez l’écriture manuelle de
mtx.lock()au profit delock_guard/scoped_lock. Même si une exception est levée, le destructeur libère le verrou de façon certaine. Pour l’acquisition simultanée de plusieurs verrous,scoped_locks’en charge grâce à un algorithme d’évitement des interblocages.4 volatilen’est pas un outil de synchronisation. Pour les drapeaux et compteurs partagés, utilisezstd::atomic; pour protéger plusieurs variables ensemble, utilisezstd::mutex.std::atomicfournit à la fois l’atomicité des opérations et un ordonnancement fondé surmemory_order.5- Pour les rendez-vous, utilisez le
waitdecondition_variableavec un prédicat. Les variables de condition connaissent des réveils intempestifs (spurious wakeups — se réveiller sans notification), si bien qu’unwaitsans prédicat est un terreau d’erreurs.6 - La forme de base pour s’arrêter est
jthread+stop_token(C++20). Dans les environnements antérieurs, on construit un arrêt coopératif avecstd::atomic<bool>+ une variable de condition. Considérez que la terminaison forcée d’un thread n’existe tout simplement pas dans le monde de C++.3 - N’utilisez
std::asyncqu’en sachant que le destructeur de future peut bloquer. Jeter la valeur de retour équivaut à une exécution séquentielle.7 - Les objets de synchronisation Win32 n’ont leur place que pour « l’interaction avec les API d’attente Win32 » et « l’inter-processus ». En dehors de cela, écrire avec la bibliothèque standard est préférable du point de vue de la portabilité et de la maintenabilité.8
2. Pourquoi le multithreading est-il difficile — conditions de concurrence, interblocages, comportement indéfini
Les problèmes qu’apporte le multithreading se ramènent, quel que soit le langage, à deux catégories fondamentales.
Une condition de concurrence (race condition) est un bug où le résultat change selon l’ordre dans lequel plusieurs threads atteignent une portion de code donnée. L’exemple classique est le compteur partagé : l’expression ++count se décompose, au niveau du langage machine, en trois étapes — « lecture → addition → réécriture ». Si deux threads entrent simultanément dans ces trois étapes, l’addition de l’un est écrasée et perdue par la réécriture de l’autre. Le résultat change à chaque exécution, et on ne peut pas prédire lequel sortira.
sequenceDiagram
participant A as Thread A
participant M as Variable partagée count
participant B as Thread B
Note over M: count = 10
A->>M: Lecture (10)
B->>M: Lecture (10)
A->>A: Addition locale (11)
B->>B: Addition locale (11)
A->>M: Réécriture (11)
B->>M: Réécriture (11)
Note over M: Deux additions effectuées mais count = 11<br/>L'addition du thread A a été perdue
Figure 1 : une condition de concurrence typique où une addition sur un compteur partagé est perdue. Si un autre thread s’intercale entre les trois étapes de ++count, celui qui réécrit en dernier écrase l’autre.
Un interblocage (deadlock) est un état où deux threads attendent chacun le verrou détenu par l’autre, si bien qu’aucun des deux ne peut avancer. Le thread A détient le verrou 1 et attend le verrou 2, le thread B détient le verrou 2 et attend le verrou 1 — cela suffit à figer les deux threads pour toujours.
flowchart LR
A["Thread A<br/>détient le verrou 1"] -->|"attend la libération du verrou 2"| B["Thread B<br/>détient le verrou 2"]
B -->|"attend la libération du verrou 1"| A
Figure 2 : l’attente circulaire d’un interblocage. Dès que les flèches d’attente forment une boucle, tous les threads pris dans cette boucle s’arrêtent pour toujours.
Ce qui rend les deux problèmes délicats, c’est qu’ils dépendent du timing. Une combinaison d’ordres d’exécution qui ne se produit qu’une fois sur plusieurs dizaines de milliers sur une machine de développement peut survenir tous les jours chez un client, dont le nombre de cœurs et le timing diffèrent. « Ça ne se reproduit plus avec un débogueur attaché », « ça a disparu en ajoutant des logs » — cela vient du fait que l’observation elle-même change le timing, un comportement typique des bugs de concurrence. C’est précisément pour cela que tous les principes de cet article pointent dans une seule direction : « réduire les endroits qui ont besoin de synchronisation » avant même « bien synchroniser ».
2.1. En C++, une course de données est directement un comportement indéfini
Au-delà de cela, C++ a une particularité plus profonde que les autres langages. Selon la norme C++, si plusieurs threads accèdent sans synchronisation à un même emplacement mémoire et qu’au moins l’un d’eux écrit, c’est une course de données, et donc un comportement indéfini. Le chapitre sur la concurrence des C++ Core Guidelines (CP.2, « Avoid data races ») pose cela comme règle absolue dès le départ.1 Le comportement indéfini n’est pas l’histoire bénigne de « on lit soit l’ancienne, soit la nouvelle valeur ». Le compilateur optimise en partant du principe qu’« aucune course de données n’existe », si bien que des comportements inimaginables à la lecture du code source peuvent légitimement survenir : un test de condition qui disparaît d’une boucle, des écritures réordonnées ou fusionnées, etc. Le classique « le drapeau d’arrêt volatile bool ne fonctionne que dans un build Debug, pas en Release » en est l’exemple type.
2.2. RAII est le socle
Une autre prémisse propre à C++ est la gestion des exceptions et des ressources. C++ n’a pas de finally, mais dispose en contrepartie de RAII (libération automatique via le destructeur), et l’outillage du multithreading est lui aussi conçu en partant de RAII. « Gérer les verrous par la durée de vie d’un objet », « garantir la jonction (join) d’un thread aussi par la durée de vie d’un objet » — adopter cette façon de faire est le socle qui permet d’écrire du multithreading sûr en C++.
3. Comment démarrer un thread — le piège de thread et jthread
3.1. Le destructeur de std::thread : une spécification qui « provoque des accidents »
std::thread comporte un piège bien connu. Si son destructeur s’exécute alors que le thread est encore joinable (ni join ni detach n’a été effectué), std::terminate est appelé et le processus s’arrête net.9
void process()
{
std::thread worker([]{ HeavyWork(); });
DoSomething(); // ← si une exception est levée ici…
worker.join(); // ← join n'est jamais atteint, terminate est appelé dans le destructeur de worker
}
Pour rendre ce code sûr en cas d’exception, il fallait garantir le join via try/catch — une situation tordue où, dans un langage RAII, seuls les threads devaient être gérés manuellement. Le std::jthread de C++20 résout ce problème : son destructeur émet automatiquement une demande d’arrêt puis effectue le join, si bien que le code ci-dessus devient sûr en cas d’exception simplement en remplaçant std::thread par std::jthread.2 Sur MSVC, <stop_token> et jthread sont disponibles à partir de Visual Studio 2019 16.9.3
flowchart TB
T["Un thread a été lancé"] --> Q{"Que se passe-t-il<br/>en sortant de la portée ?"}
Q -->|"std::thread<br/>ni join ni detach"| X["std::terminate<br/>arrêt immédiat du processus"]
Q -->|"std::thread<br/>déjà join"| OK1["Rejoint en toute sécurité"]
Q -->|"std::jthread (C++20)"| OK2["request_stop + join automatiques<br/>sûr même si une exception est levée"]
Figure 3 : la durée de vie d’un objet thread et la façon dont il se termine. Comme std::thread a pour spécification de tuer le processus en cas de join oublié, à partir de C++20, jthread devrait être le choix par défaut.
En principe, n’utilisez pas detach(). Un thread ayant perdu tout moyen d’être rejoint entre en concurrence, à la fin du processus, avec la destruction des variables statiques et du tas, ce qui est une cause classique de plantage à la fermeture.
3.2. Des outils « au-dessus du thread » — async, future et les algorithmes parallèles
Le principe de l’édition .NET, « ne créez pas vos propres threads », correspond en C++ aux outils suivants.
std::async+std::future: pour une tâche asynchrone ponctuelle et la récupération de son résultat. Une spécification importante à connaître, cependant : lefuture(ou le derniershared_future) associé à une tâche lancée parstd::asyncbloque jusqu’à l’achèvement de la tâche si son destructeur s’exécute alors que la tâche n’est pas terminée.7 Pour un travail réellement lancé avecstd::launch::async, jeter le future de retour équivaut, au moment même où on le jette, à une exécution synchrone. Pire encore, si aucune politique de lancement n’est spécifiée, l’implémentation peut par défaut choisirdeferred(exécution différée) — dans ce cas, si personne n’appelleget()/wait(), le travail n’est tout simplement jamais exécuté et disparaît silencieusement. Pour garantir une exécution réellement concurrente, spécifiez explicitementstd::launch::asyncet faites gérer la durée de vie du future par son propriétaire.concurrency::parallel_for/parallel_for_eachde la PPL (Parallel Patterns Library) : pour appliquer un traitement en parallèle à tous les éléments d’une collection. Cependant, si le travail d’une seule itération est trop petit, le surcoût du fork/join engloutit le gain, donc le principe est de paralléliser au niveau de la boucle la plus externe.10- Les algorithmes parallèles de C++17 (
std::execution::par) : sur MSVC, les algorithmes principaux sont parallélisés (mais pas tous).11 Point de vigilance : si une exception s’échappe du traitement d’un élément sous une politique d’exécution,std::terminateest appelé. Placer sa propre frontière d’exception (try/catch) dans le callback obéit à la même logique que la frontière de thread du chapitre 6.
La distinction « l’attente d’E/S n’est pas une raison d’ajouter des threads » reste également valable telle quelle. Pour du code natif Windows, les E/S OVERLAPPED et l’IOCP en sont le réceptacle (pour le mécanisme, voir « Windows I/O en profondeur, 2ᵉ partie »).
4. Réduire l’état mutable partagé — division, passage par valeur, const, files d’attente
Une concurrence problématique ne survient que lorsque « plusieurs threads » et « des données mutables partagées » sont réunis. Le nombre de threads est fixé par les exigences, donc ce que la conception peut réduire, c’est le partage. Les moyens se répartissent en trois familles — « diviser », « rendre immuable », « transmettre » — et voici comment les écrire en C++.
Diviser. Pour un traitement comme une agrégation parallèle, plutôt que chaque thread écrive dans une variable de total partagée, donnez à chaque thread un sous-total local, et ne les fusionnez qu’une seule fois à la fin. Les écritures dans le partagé passent ainsi de « à chaque itération » à « une fois par thread », ce qui réduit d’un ou deux ordres de grandeur le coût de synchronisation et la fenêtre de concurrence. Cette fusion unique peut se faire aussi bien avec un std::mutex qu’avec un fetch_add sur un std::atomic.
Passer par valeur. Si les données nécessaires sont transmises par copie (ou par move) au démarrage du thread, ces données deviennent la propriété exclusive du thread et aucune synchronisation n’est nécessaire. Les accidents où une capture de lambda par référence ([&]) touche une variable dont la durée de vie est expirée sont fréquents, donc les lambdas transmises à un thread doivent utiliser des captures explicites, en principe par copie ou par move. Cependant, l’idée que « copié donc exclusif » ne tient que si la valeur est un graphe de valeurs profond, sans alias tel qu’un pointeur ou un shared_ptr. Copier une structure contenant un pointeur brut laisse ce vers quoi elle pointe partagé comme avant.
Partager en const. Des données qui ne sont que lues sont sûres à lire simultanément depuis n’importe quel nombre de threads. Des valeurs de configuration, des données maîtresses ou des entrées de calcul peuvent être partagées sans synchronisation si elles sont partagées en const sans être réécrites après leur construction (par exemple via std::shared_ptr<const Config>). Point de vigilance : ce qu’interdit shared_ptr<const T>, c’est uniquement la modification via cette poignée-là. Si un alias non-const subsiste quelque part, ou si un membre mutable est réécrit, la concurrence problématique demeure — concevez donc en incluant l’exigence de « libérer toute référence non-const une fois la construction terminée, et que plus personne n’écrive ensuite ». Décider que « lorsqu’une modification devient nécessaire, on crée un nouvel objet et on le substitue, plutôt que de réécrire » suffit à éliminer un état mutable de plus à protéger (pour la gestion de la durée de vie de la substitution elle-même, voir la remarque du 5.2).
Transmettre via une file d’attente. Faites transiter le flux de données entre threads par une file producteur/consommateur, plutôt que par une variable partagée. La norme C++ n’a pas de canal (channel), donc écrire une petite file d’attente avec std::mutex + std::condition_variable est la pratique standard.
template <typename T>
class BlockingQueue {
public:
explicit BlockingQueue(std::size_t capacity) : capacity_(capacity)
{
if (capacity == 0) // Une capacité de 0 ferait attendre tous les Push indéfiniment
throw std::invalid_argument("capacity must be positive");
}
// Si plein, attend qu'une place se libère (ou une demande d'arrêt). false = 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 une place et un arrêt surviennent en même temps, priorité à l'arrêt,
return false; // aucun ajout n'est accepté après le début de l'arrêt
queue_.push(std::move(item));
}
not_empty_.notify_one(); // notifier en dehors du verrou
return true;
}
// Attend une demande d'arrêt (stop_token) ou l'arrivée d'un élément. nullopt en cas d'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 un arrêt surviennent en même temps, priorité à l'arrêt,
return std::nullopt; // aucun nouveau travail n'est entamé 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 utiliser wait compatible avec stop_token
std::condition_variable_any not_full_;
std::queue<T> queue_;
};
Deux points sont essentiels dans cette conception. Premièrement, fixer une limite de capacité et faire attendre le producteur si elle est atteinte. Une file sans limite, dans une configuration où la production est plus rapide que la consommation, devient une bombe à retardement qui « fonctionne mais dont la mémoire ne cesse de croître ». Le fait que Push bloque lorsque la file est pleine agit comme une contre-pression (backpressure) naturelle, qui propage mécaniquement la surcharge vers l’amont. Deuxièmement, les variables de condition connaissent des réveils intempestifs (se réveiller sans notification), donc wait doit toujours être appelé avec un prédicat. Le wait avec prédicat exécute en interne la boucle « tant que la condition n’est pas vraie » à votre place.6
5. La discipline des verrous — RAII et scoped_lock
Même en réduisant l’état mutable partagé, on ne peut souvent pas le ramener à zéro. Pour ce qui reste partagé, on utilise un contrôle d’exclusion, mais un verrouillage sans discipline ne fait que masquer la concurrence problématique.
Tout d’abord, pensez l’unité de verrouillage non pas en termes de « portion de code », mais de « données ». Faites correspondre un mutex à chaque ensemble de données mutables à protéger (comme un membre private non exposé à l’extérieur), et prenez ce même mutex à chaque endroit qui touche ces données — la réalité des bugs de concurrence, c’est que cette table de correspondance s’effrite. De plus, tant que vous détenez un verrou, la seule chose autorisée est de lire ou d’écrire les données protégées. Une E/S fichier, un appel réseau ou un callback (appel de code externe) pendant qu’un verrou est détenu ne fait pas qu’allonger la durée de détention : cela crée aussi un chemin où le code appelé tente de prendre un autre verrou et provoque un interblocage. Préparer en dehors du verrou, et ne faire que la substitution à l’intérieur est la forme de base.
5.1. Interdiction d’écrire lock()/unlock() à la main
Un code qui appelle directement lock() / unlock() sur un std::mutex risque une fuite de libération en cas d’exception ou de retour anticipé. Confiez toujours l’acquisition et la libération du verrou à un wrapper RAII.
| Wrapper | Usage |
|---|---|
std::lock_guard |
Détient un mutex unique le temps de la portée, la forme la plus basique |
std::scoped_lock (C++17) |
Acquiert plusieurs mutex simultanément. Résout le problème d’ordre grâce à un algorithme d’évitement des interblocages4 |
std::unique_lock |
Pour libérer/réacquérir en cours de route, ou pour le passer à condition_variable::wait |
Lorsqu’il y a deux verrous ou plus, le schéma classique d’interblocage survient quand l’ordre d’acquisition s’inverse selon le thread (c’est ainsi que naît l’attente circulaire de la figure 2). La parade consiste à établir la règle que « tous les threads acquièrent dans le même ordre », mais si l’on veut les acquérir simultanément, C++ offre une meilleure réponse : en passant plusieurs mutex ensemble à std::scoped_lock, la bibliothèque garantit l’évitement des interblocages liés à l’ordre d’acquisition.4 Dans un cas comme un transfert entre deux objets où l’on veut « verrouiller les deux », ne les prenez jamais séparément — prenez-les toujours ensemble.
void Transfer(Account& from, Account& to, int amount)
{
if (&from == &to) return; // Ne rien faire si c'est le même compte (voir la remarque ci-dessous)
std::scoped_lock lock(from.mtx, to.mtx); // Les deux ensemble, l'ordre est résolu par la bibliothèque
from.balance -= amount;
to.balance += amount;
}
La vérification d’identité au début n’est pas décorative. Si le même Account est passé à la fois en from et en to, on finit par transmettre deux fois le même mutex non récursif à scoped_lock, ce qui cause un blocage ou un comportement indéfini. Toute fonction qui « verrouille les deux » doit systématiquement exclure le cas du même objet.
Pour des données « souvent lues mais rarement écrites », vous pouvez utiliser un verrou de lecture/écriture avec std::shared_mutex (C++17).12 Par ailleurs, recursive_mutex est un type qui « ne casse pas si le même thread le réacquiert », mais une conception qui nécessite une acquisition récursive est souvent le signe que la frontière de responsabilité du verrou est devenue floue — envisagez d’abord de revoir la structure.
5.2. La juste place d’atomic
std::atomic fournit des opérations atomiques sur une variable unique et un ordonnancement fondé sur memory_order.5 Son usage correspond à celui d’Interlocked dans l’édition .NET : la mise à jour d’une variable unique, comme un compteur ou un drapeau. Il ne peut pas maintenir la cohérence de plusieurs variables ensemble, auquel cas il faut revenir à std::mutex.
La substitution d’un pointeur brut (std::atomic<T*>) comporte un piège propre. Même si la substitution elle-même est atomique, personne ne protège la durée de vie de l’ancien objet une fois remplacé. Si un lecteur vient de charger l’ancien pointeur et que l’auteur le remplace puis le delete juste après, c’est un accès à de la mémoire libérée. Si vous voulez faire, en C++, une conception de type « substituer et partager un objet immuable », choisissez un moyen associé à une gestion de la durée de vie, comme la substitution d’un std::shared_ptr<const T> protégé par un verrou (ou std::atomic<std::shared_ptr<T>> en C++20).
Et, répétons-le, volatile n’est pas un outil de synchronisation entre threads. La programmation sans verrou (lock-free) où l’on spécifie soi-même memory_order est le domaine d’experts disposant d’une raison légitime et de moyens de vérification pour assouplir la valeur par défaut (seq_cst). Dans une application métier, utilisez la valeur par défaut telle quelle, ou écrivez tout simplement avec mutex.
6. Concevoir la façon de s’arrêter — stop_token et l’arrêt coopératif
La première question à poser lors d’une revue de conception multithread est : « comment est-ce que ça s’arrête ? » Et il n’existe en C++ aucun moyen sûr d’arrêter un thread de l’extérieur (à quel point le TerminateThread de Win32 est dangereux est détaillé dans l’« édition C »). La façon de s’arrêter doit donc être construite avec les outils de C++ sous la forme de l’arrêt coopératif — le côté qui arrête se contente d’émettre une demande, c’est le thread lui-même qui décide quand et comment se terminer, à un endroit propice à un nettoyage propre, et c’est l’achèvement du join qui fait foi de l’« arrêt ».
En C++20, std::jthread intègre le mécanisme d’arrêt. Appeler request_stop() fait apparaître la demande d’arrêt dans le std::stop_token reçu par la fonction du thread, et la boucle interroge cet état par polling. Le wait de condition_variable_any peut recevoir directement un stop_token, ce qui permet de réveiller immédiatement, même sur une demande d’arrêt, un « thread en attente de travail » (c’est la forme prise par BlockingQueue::Pop du chapitre 4).
class Worker {
public:
void Start()
{
if (thread_.joinable()) // Refuse un double Start pendant que le thread tourne.
throw std::logic_error("already running"); // Si on affectait sans refuser, le nouveau thread
// démarrerait pendant qu'on attend l'arrêt de l'ancien,
// et deux workers tourneraient en parallèle
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); // transmet aussi st au traitement qui peut bloquer en interne
} catch (...) {
ReportError(std::current_exception()); // un seul échec est journalisé, puis on continue
}
}
}
} catch (...) {
// Dernière ligne de défense à la frontière du thread (reçoit aussi les échecs de Pop ou d'un move).
// Si une exception s'échappe d'ici, std::terminate fait tomber tout le processus,
// donc ReportError doit être implémenté pour ne jamais lever d'exception
ReportError(std::current_exception());
}
});
}
// Aucun Stop explicite n'est nécessaire :
// destructeur de Worker → destructeur de jthread → request_stop() + join()
private:
BlockingQueue<WorkItem> queue_{100}; // avec une capacité maximale (chapitre 4)
std::jthread thread_;
};
flowchart TB
OWNER["Côté qui arrête<br/>(destructeur de jthread ou request_stop)"] -->|"demande d'arrêt"| ST["stop_token"]
ST --> P["Boucle de calcul :<br/>interroge stop_requested()"]
ST --> W["Thread en attente :<br/>condition_variable_any::wait(lock, st, pred)<br/>se réveille immédiatement"]
P --> E["Nettoie et retourne (return) de lui-même"]
W --> E
E --> J["Jonction (join) terminée<br/>c'est seulement là qu'on peut dire « arrêté »"]
Figure 4 : l’arrêt coopératif de C++20. Le côté qui arrête se contente d’émettre une demande, c’est le thread lui-même qui décide comment se terminer, et l’achèvement du join fait foi de l’arrêt.
Autre point : le try/catch à l’intérieur du worker ne peut pas être omis. Ce que jthread rend sûr en cas d’exception, c’est uniquement le join — si une exception s’échappe de la fonction du thread, le processus tombe avec std::terminate, exactement comme pour std::thread. La façon de traiter l’échec d’une seule tâche (journaliser et continuer, ou le communiquer au propriétaire via un canal d’erreur) doit être décidée explicitement à la frontière du thread.
Pour la même raison, remarquez que stop_token est également transmis à Process. Si le traitement d’une seule tâche bloque en interne (attente réseau, long calcul, etc.) et que ce point ne peut pas observer la demande d’arrêt, le join implicite du destructeur attendra indéfiniment l’achèvement de cette tâche. L’arrêt coopératif ne fonctionne réellement que si le token atteint « tous les endroits où l’on attend ». Si un appel externe non interruptible est impliqué, ajoutez un délai d’expiration pour plafonner le temps d’exécution d’une tâche.
Dans les environnements antérieurs à C++17, on construit à la main la même structure avec un drapeau d’arrêt std::atomic<bool> + notify_all de condition_variable. Le point essentiel, ici, est d’inclure la vérification du drapeau d’arrêt dans le prédicat de la variable de condition (si l’on se contente de lever le drapeau sans notifier, un thread en attente ne se réveillera jamais).
7. Précautions spécifiques à Windows — la frontière avec les API Win32
7.1. Choisir entre la bibliothèque standard et les objets de synchronisation Win32
La documentation Microsoft recommande std::mutex / std::shared_mutex pour du code C++ soucieux de portabilité, et cantonne les objets de synchronisation Win32 aux cas où « une API d’attente Win32 est nécessaire » et à la « synchronisation entre processus ».8
| Situation | Choix |
|---|---|
| Exclusion intra-processus habituelle | std::mutex + RAII (par défaut) |
| Lectures nombreuses, écritures rares | std::shared_mutex |
Attendre plusieurs objets simultanément avec WaitForMultipleObjects |
Objets noyau Win32 tels qu’événements, Mutex |
| Exclusion/notification entre processus | Mutex, événements, sémaphores nommés |
| Verrou intra-processus utilisant directement les API Win32 | Verrou SRW (CRITICAL_SECTION seulement si la récursion est nécessaire)8 |
Pour une conception concrète de l’exclusion de la mémoire partagée entre processus, voir « Les pièges de la mémoire partagée et les bonnes pratiques ».
7.2. Ne pas toucher aux threads dans DllMain
Une contrainte majeure lors de l’écriture d’une DLL est le verrou du chargeur (loader lock). DllMain est appelée alors que ce verrou est détenu, si bien qu’à l’intérieur, se synchroniser avec un autre thread, attendre la fin d’un thread, ou appeler LoadLibrary sont des opérations qui causent des interblocages ou un comportement instable. Sortez toute initialisation qui démarre ou rejoint un thread hors de DllMain (dans une fonction d’initialisation explicite).13
7.3. Le thread d’interface utilisateur et les appartements COM
Les applications de bureau Windows sont soumises à une contrainte forte, indépendante du langage : seul le thread qui a créé une fenêtre ou un contrôle (le thread d’interface utilisateur) peut le toucher. Windows délivre les messages de fenêtre à la file de messages du thread qui a créé cette fenêtre, donc la création et la manipulation de l’interface doivent être concentrées sur ce thread. Lorsqu’un thread de travail (worker) veut mettre à jour l’écran, il ne doit pas le toucher directement : il en fait la demande au thread d’interface avec PostMessage (asynchrone), qui est traitée dans la procédure de fenêtre du thread d’interface. La forme synchrone SendMessage, appelée dans une situation où le thread d’interface attend justement l’achèvement de ce worker, provoque un interblocage où chacun attend l’autre — les notifications émises par un worker doivent donc être asynchrones par défaut. Le cas STA/MTA lorsque COM est impliqué est expliqué dans « Les bases de STA/MTA en COM ». Il faut aussi noter que, dans du code C++/CLI compilé avec /clr, les en-têtes standard de threading comme <thread> et <mutex> sont bloqués.14
8. Vérification et débogage — se préparer en partant du principe que « ça ne se reproduira pas »
On ne peut pas espérer que les tests détectent les bugs de concurrence, car un test ordinaire compte comme un succès une exécution qui, par chance, n’a pas rencontré de concurrence problématique. Pensez la préparation en trois couches.
La première ligne de défense est constituée des principes de conception exposés jusqu’ici. En revue, on vérifie sous forme de tableau : « quelles sont les données mutables partagées », « quel mutex protège chacune », « l’ordre d’acquisition de plusieurs verrous est-il unique (ou pris ensemble via scoped_lock) », « où se situe le chemin d’arrêt ». Une conception pour laquelle ce tableau ne peut pas être rempli n’est pas encore terminée, même si elle fonctionne.
Deuxièmement, rendez les anomalies observables au lieu de les masquer. Pour un verrou qui ne devrait jamais rester pris, ajoutez un délai d’expiration avec try_lock_for de timed_mutex ou wait_for de condition_variable, et journalisez le dépassement comme une anomalie : cela transforme un blocage éternel en un échec détectable. Journalisez systématiquement les exceptions reçues par le try/catch de la frontière de thread (chapitre 6). Sur le terrain, en cas de blocage ou de plantage, prélevez un dump, examinez la pile de tous les threads, et vérifiez si les attentes de verrous forment un cycle entre elles. La mise en place des dumps et des journaux est traitée dans « Concevoir la conservation des journaux et des dumps lors du plantage d’une application Windows ».
Troisièmement, secouez le système sous charge. Faire tourner longtemps avec un degré de parallélisme supérieur au nombre de cœurs, randomiser l’ordre de traitement, insérer des délais artificiels — ces tests de stress sont des moyens concrets d’augmenter, sur une machine de développement, la probabilité de « tomber » sur une concurrence problématique. Un bug qui disparaît en build Debug se reproduit souvent en build Release optimisé sous forte charge.
9. Conclusion — liste de contrôle version C++
Aux principes communs à tous les langages (ne pas créer de threads directement, minimiser l’état mutable partagé, correspondance biunivoque entre verrou et donnée, arrêt coopératif) s’ajoutent les vérifications propres à C++.
std::threadest-il utilisé à nu (peut-on le remplacer parjthread? le join est-il garanti même sur le chemin d’exception ?)detach()est-il utilisé ?- Les captures de lambda sont-elles explicites, et la durée de vie des variables capturées par référence dépasse-t-elle celle du thread ?
- Peut-on affirmer qu’il n’existe aucun accès mutable partagé sans synchronisation (= comportement indéfini) ?
- Y a-t-il un
lock()/unlock()écrit à la main ? Les verrous multiples sont-ils pris ensemble viascoped_lock? - Tous les
condition_variable::waitsont-ils accompagnés d’un prédicat ? volatileest-il utilisé pour un drapeau partagé (au lieu destd::atomic) ?- Le chemin d’arrêt est-il conçu avec
stop_token(ou un drapeau atomique + notification), et l’achèvement de la jonction est-il vérifié par join ? - Le future de
std::asyncest-il jeté ? - Un thread est-il démarré, synchronisé ou rejoint dans
DllMain?
Le multithreading en C++ consiste à marcher juste au bord de la falaise du « comportement indéfini », mais, à l’inverse, se contenter d’adopter sincèrement la philosophie de RAII et de la bibliothèque standard suffit à s’en éloigner nettement. jthread, scoped_lock, wait avec prédicat, atomic — bien choisir les valeurs par défaut de ces outils constitue, en C++, la mise en pratique même des principes de conception.
Articles connexes
- Bonnes pratiques de multithreading en pratique — édition .NET
- Bonnes pratiques de multithreading en pratique — édition C
- Bonnes pratiques de multithreading en pratique — édition Java
- Envelopper une DLL native avec C++/CLI en pratique
- Les pièges de la mémoire partagée et les bonnes pratiques
- Les bases de STA/MTA en COM - le modèle de thread et comment éviter les blocages
- Windows I/O en profondeur (2ᵉ partie) ── E/S synchrones et asynchrones : la vraie signification d’OVERLAPPED
Domaines de conseil associés
合同会社小村ソフト (Komura Software LLC) prend en charge la revue de conception multithread d’applications et de DLL en C++, l’investigation de bugs liés à la concurrence (analyse de dumps) pour des symptômes du type « ça plante de temps en temps » ou « ça se comporte bizarrement seulement en build Release », ainsi que le conseil pour migrer du code de threading hérité vers du C++ moderne.
- Conseil technique et revue de conception
- Investigation de bugs et analyse de causes
- Développement d’applications Windows
- Contact
Références
-
ISO C++, C++ Core Guidelines - CP: Concurrency and parallelism. Sur le fait que le chapitre sur la concurrence et le parallélisme pose comme règles d’ouverture CP.1 (supposez que votre code s’exécute en multithread) et CP.2 (évitez les courses de données), sur le fait qu’aucune garantie ne tient en présence d’une course de données, et sur le fait que les règles de conception du code concurrent — étendue de détention des verrous, usage de RAII, etc. — y sont systématisées. ↩ ↩2
-
cppreference.com, std::jthread. Sur le fait que, à la différence de std::thread, le jthread de C++20 appelle automatiquement request_stop() puis effectue le join dans son destructeur, sur le fait que la fonction du thread peut recevoir un std::stop_token comme premier paramètre, et sur le fait que cela garantit la jonction du thread et la demande d’arrêt même en cas d’exception. ↩ ↩2
-
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 de C++20) sont pris en charge à partir de Visual Studio 2019 16.9, et sur l’état de prise en charge des fonctionnalités de la bibliothèque standard C++ par version. ↩ ↩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 si plusieurs mutex sont passés ils sont acquis avec un algorithme d’évitement des interblocages équivalent à std::lock, qu’ils sont libérés de façon certaine même si une exception est levée, et que pour un mutex unique, lock_guard/unique_lock restent aussi des options. ↩ ↩2 ↩3
-
Microsoft Learn, <atomic>. Sur le fait que les opérations atomiques étant indivisibles, les autres threads ne peuvent observer que l’état d’avant ou d’après l’opération, sur le fait que l’argument memory_order établit des exigences d’ordonnancement de la visibilité vis-à-vis des autres opérations atomiques et empêche les optimisations du compilateur qui les violeraient, sur le fait qu’atomic_flag est toujours sans verrou (lock-free), et sur le fait que cet en-tête est bloqué sous /clr:pure. ↩ ↩2
-
Microsoft Learn, <condition_variable>. Sur le fait que l’attente d’une variable de condition nécessite un mutex et que le verrou est relâché pendant l’attente, sur l’existence de réveils intempestifs (se réveiller sans notification), ce qui oblige le côté en attente à vérifier explicitement la condition à son retour, sur le fait que le wait(lock, pred) avec prédicat effectue cette boucle à sa place, et sur le fait que condition_variable_any peut se combiner avec n’importe quel type de mutex. ↩ ↩2
-
Microsoft Learn, <future>. Sur le fait que les destructeurs de future et de shared_future ne bloquent en principe pas, mais qu’en seule exception, le future (ou le dernier shared_future) associé à une tâche lancée par std::async bloque jusqu’à ce que l’état partagé devienne prêt si son destructeur s’exécute alors que la tâche est inachevée, et sur le fait que ce comportement est explicitement mentionné dans une note de la norme. ↩ ↩2
-
Microsoft Learn, About Synchronization. Sur les indications 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 quand une API d’attente Win32 ou une synchronisation inter-processus est nécessaire ; pour du nouveau code intra-processus utilisant directement les API Win32, le choix par défaut est le verrou SRW, CRITICAL_SECTION n’étant réservé qu’au cas où l’acquisition récursive est nécessaire ; et utiliser un Mutex pour une synchronisation intra-processus est une « erreur courante » car cela implique toujours une transition noyau. ↩ ↩2 ↩3
-
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) au moment de l’appel, c’est-à-dire que la décision de join ou de detach doit impérativement être prise avant la destruction de l’objet thread. ↩
-
Microsoft Learn, Best Practices in the Parallel Patterns Library. Sur le fait que la parallélisation devrait être exprimée au niveau le plus élevé possible (la boucle la plus externe), sur le fait que, dans une boucle parallèle dont le travail de chaque itération est petit ou déséquilibré, le surcoût d’ordonnancement du fork/join peut dépasser le gain de l’exécution parallèle, et sur le fait que cette tendance s’accentue à 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 des algorithmes parallèles de C++17 est complète, mais que cette « complétude » ne signifie pas que tous les algorithmes sont parallélisés dans tous les cas, et sur la politique d’implémentation consistant à paralléliser les algorithmes les plus importants tout en fournissant la signature de politique d’exécution même pour ceux qui ne le sont pas. ↩
-
Microsoft Learn, C++ standard library header files. Sur le fait que les en-têtes standard liés au multithreading y sont recensés : <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, Dynamic-Link Library Best Practices. Sur le fait que DllMain est appelée pendant que le verrou du chargeur est détenu, ce qui impose des contraintes sévères sur les API appelables, sur le fait que synchroniser avec un autre thread dans DllMain peut provoquer un interblocage, sur le fait que l’appel de LoadLibrary ou l’attente de la fin d’un thread sont des interdictions typiques, sur le fait que l’initialisation devrait autant que possible être différée et sortie de DllMain, et sur le fait qu’il faudrait définir une hiérarchie de verrous plaçant le verrou du chargeur au sommet. ↩
-
Microsoft Learn, <thread>. Sur le fait que l’en-tête <thread> définit la classe thread et des fonctions auxiliaires comme sleep_for, sur le fait que cet en-tête est bloqué dans du code compilé avec /clr, et sur le fait que la macro STDCPP_THREADS permet de déterminer la présence ou l’absence de la prise en charge des threads. ↩
Articles associés
Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.
Bonnes pratiques de multithreading en pratique — édition .NET : ce qu'il faut décider avant d'ajouter des threads
Un ensemble de bonnes pratiques de conception pour .NET/C# afin d'éviter que « démarrer un thread » ne fasse planter ou geler l'applicati...
Bonnes pratiques du multithreading en pratique — édition Java : les usages à l'ère des threads virtuels
En Java, la bonne pratique consiste à ne jamais créer de threads directement, mais à s'appuyer sur ExecutorService et les threads virtuel...
Bonnes pratiques du multithreading en pratique — édition langage C — écrire en toute sécurité à la manière de l'API Win32
Le multithreading en C avec Win32 repose sur des règles éprouvées : création de threads avec _beginthreadex, verrous SRW et variables de ...
Externalisation et développement sur mesure d'une application Windows : ce qu'il faut clarifier avant de se lancer
Avant de confier l'externalisation ou le développement sur mesure d'une application Windows, voici les points à clarifier : révision d'un...
Cliché instantané de volume (VSS) : fonctionnement et pratique — Pourquoi peut-on sauvegarder des fichiers en cours d'utilisation ?
Un fichier en cours d'utilisation ne peut pas être copié à cause d'une violation de partage, alors comment les logiciels de sauvegarde y ...
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.