Bonnes pratiques du multithreading : édition C++ — éliminer les accidents avec RAII et jthread

· Mis à jour le: · · 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.

Exemple de mise à jour perdue sur un compteur partagéMontre schématiquement comment une mise à jour est perdue lorsque deux threads lisent la même valeur, chacun additionne et réécrit.count vaut 10A et B lisent tous deux 10Chacun additionne localement vers 11A réécrit 11B réécrit aussi 11En 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.

Attente circulaire sur deux verrousLorsque chaque côté attend le verrou que l'autre détient, aucun ne peut avancer.attend le verrou 2attend le verrou 1A détient le verrou 1B détient le verrou 2

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
Choisir le moyen d'exécution, puis décider de la durée de vieChoisir 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.Ponctuel ou traitement de collectionTraitement de longue duréeAttente d'E/SVérifier la forme du travailCe qui est exécutéTâches ou algorithmes parallèlesGérer la durée de vie du travailleurEnvisager l'E/S asynchroneConcevoir résultats, exceptions et terminaison

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.

Politique de lancement d'async et durée de vie du futureDistingue 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.asyncdeferredAppeler std::asyncPolitique de lancement choisieS'exécute sur un autre threadLe dernier future est détruitAttend l'achèvement s'il est inachevéExécution différée jusqu'à get ou waitNe 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

Destruction d'un objet thread et jonctionDétruire un thread joinable mène à terminate, tandis que détruire un jthread émet une demande d'arrêt et joint.NonOuithreadjthreadDétruire l'objet threadEst-il joinableRien à joindreDe quel type s'agit-ilstd::terminateÉmettre une demande d'arrêt et joindreLe 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.

Fusionner les sous-totaux par thread à la finAu lieu de mettre à jour le total partagé à chaque fois, le sous-total de chaque thread est appliqué au total exactement une fois à la fin.Découper l'entréeSous-total du thread ASous-total du thread BSynchroniser et fusionner à la finTotal 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é.

Copier un pointeur partage encore l'objet référencéLorsqu'une valeur contenant un pointeur est copiée, les deux variables sont distinctes mais l'objet référencé reste le même.Copier la valeur du pointeurPointeur dans la valeur d'origineLe même objet référencéPointeur dans la copieSynchronisation 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

File avec une limite de capacité et levée d'attenteLe 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.ProducteurSi plein, attendre de la placeFile avec une limite de capacitéSi vide, attendre un élémentConsommateurDemande d'arrêt

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.

Préparer hors du verrou et permuter à l'intérieurLa préparation longue sort du verrou, et seul le changement des données partagées se produit dans une courte région verrouillée.Préparer hors du verrouAcquérir le verrou avec RAIIModifier les données partagéesQuitter la portée et libérerEffectuer 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.

Une permutation de pointeur atomic ne protège pas à elle seule la durée de vieL'ancien pointeur qu'un lecteur a obtenu perd son objet référencé lorsque l'écrivain le supprime après la permutation.Le lecteur obtient l'ancien pointeurL'écrivain permute le pointeurL'écrivain supprime l'ancien objetLe lecteur accède à l'ancien objetAccès à de la mémoire libéréeL'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.

L'arrêt coopératif fait de tout jusqu'au join un seul cheminLe 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.Le propriétaire demande un arrêtTransmis au stop_tokenVérifier la demande pendant le calculLever l'attente correspondanteFaire le ménage et revenirLe 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.

Ordre de destruction des membres de Worker et durée de vie de la fileLe 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.Détruire WorkerDétruire thread_, déclaré plus tardDemande d'arrêt et joinConfirmer que le travailleur est sortiDé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
Frontières d'exception pour un élément et pour tout le threadL'é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.Exception d'un élémentException de la prise, etc.Boucle du travailleurPrendre dans la fileTraiter un élémentEnregistrer à la frontière intérieure et continuerEnregistrer à la frontière extérieure et sortirLa 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
Choisir entre synchronisation standard et intégration Win32Le 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.NonOuiVérifier les exigences de synchronisationAttente Win32 ou inter-processusMutex standard et RAII par défautChoisir des objets Win32Verrou 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.

Séparer le traitement des threads de DLL dans des fonctions explicitesNe 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.DllMainVerrou du chargeur tenuPas de démarrage, de synchronisation ni de join iciFonction explicite hors de DllMainGé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.

Attente circulaire par notification synchrone entre UI et travailleurLorsque l'UI attend la fin du travailleur tandis que le travailleur attend le traitement de SendMessage, une attente circulaire en résulte.Attend la fin du travailleurAttend l'achèvement de SendMessageThread d'UIThread travailleurNotification du travailleurDéléguer avec PostMessageTraité 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.

Préparer conception et observation puis essayer sous stressConfirmer 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.Confirmer partage, verrous, durée de vie et arrêtPréparer journaux et dumpsEssayer en variant charge et ordre d'exécutionRapporter les problèmes trouvés à la conception

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.

  1. std::thread n’est-il pas utilisé nu (jthread est-il possible, le join est-il garanti aussi sur les chemins d’exception) ?
  2. detach() n’est-il pas utilisé ?
  3. 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 ?
  4. Peut-on affirmer qu’il n’y a nulle part d’accès mutable partagé sans synchronisation (= comportement indéfini) ?
  5. N’y a-t-il pas de lock() / unlock() écrits à la main, et les verrous multiples sont-ils pris ensemble avec scoped_lock ?
  6. Tous les condition_variable::wait sont-ils à prédicat ?
  7. Les drapeaux partagés n’utilisent-ils pas volatile (sont-ils en std::atomic) ?
  8. 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 ?
  9. Le future de std::async n’est-il pas jeté ?
  10. DllMain ne 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

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.

Références

  1. 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

  2. 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

  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

  4. 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

  5. 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

  6. 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. ↩

  7. 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. ↩

  8. 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. ↩

  9. 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. ↩

  10. 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. ↩

  11. 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). ↩

  12. 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

  13. 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. ↩

  14. 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 récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

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.

Retour au blog