Bonnes pratiques du multithreading : édition C — écrire en sécurité à la manière Win32
· Mis à jour le: · Go Komura · Windows, Multithreading, Langage C, Win32 API, Application métier, Diagnostic 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.22175891)
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 — écrire en sécurité à la manière Win32. KomuraSoft LLC. https://comcomponent.com/fr/blog/multithreading-best-practices-c/
- DOI (archive enregistrée)
- 10.5281/zenodo.22175891
- DOI (dernière version enregistrée)
- 10.5281/zenodo.22175892
« Un processus résident écrit en C ne se fige qu’à l’arrêt. » « On veut ajouter des threads à une vieille application de commande d’équipement. » « On arrête les threads avec TerminateThread, mais de temps en temps tout le processus ne répond plus. » Quand on écrit du multithreading en C, le problème n’est pas tant de faire tourner le travail en parallèle que de savoir qui protège les données partagées, comment les threads attendent, et comment ils se terminent.
C n’a pas de mécanisme analogue au RAII de C++ qui libère automatiquement un verrou ou un handle à la sortie d’une portée. Sans l’aide des exceptions ni des templates non plus, choisir les bonnes API ne suffit pas : la discipline d’acquisition, de libération et d’attente de la fin doit être inscrite dans la structure du code.
Cet article est l’édition langage C de la série pratique sur le multithreading. Il s’adresse aux développeurs qui utilisent C et l’API Win32, et projette les principes de conception multithread sur des API concrètes : éviter de fabriquer des threads en masse, réduire l’état mutable partagé, fixer la discipline des verrous, et concevoir d’abord comment arrêter.
Il traite de la création avec _beginthreadex, du choix des objets de synchronisation, de l’arrêt coopératif qui ne s’appuie pas sur TerminateThread, et des contraintes de DllMain. Les informations sont à jour en août 2026. L’article se lit de manière autonome, mais les mêmes principes sont déclinés dans d’autres langages dans « l’édition .NET », « l’édition C++ » et « l’édition Java ».
Partir de ce qui vous bloque
| Ce qui vous bloque | Ce qu’il faut vérifier d’abord | Où lire |
|---|---|---|
| Je veux ajouter des threads / faire tourner un grand nombre de travaux courts | Quand utiliser ses propres threads plutôt que le pool de threads | Créer des threads |
| Les compteurs ne tombent pas juste / les données partagées se corrompent | Les conceptions qui réduisent le partage, et l’appariement des verrous et des opérations atomiques aux données | Réduire l’état partagé, Choisir la synchronisation |
| Ajouter un verrou a figé le programme | Ce que le verrou protège, ce qui s’exécute pendant qu’on le tient, et l’ordre d’acquisition | Discipline des verrous |
| Ça fige à l’arrêt / on utilise une terminaison forcée | Séparer la demande d’arrêt de la confirmation de sortie, et si un thread en attente peut encore s’arrêter | Arrêt coopératif |
| Les travailleurs sont réveillés par un événement, mais le travail est mal réparti | La différence de notification entre un seul travailleur et plusieurs | Portée de l’exemple d’arrêt |
| Ça fige au déchargement de la DLL | Si les threads sont démarrés, arrêtés et rejoints en dehors de DllMain | Contraintes de DllMain |
| Je veux partager du code avec Linux / je n’arrive pas à reproduire un bug | Les exigences de portabilité, plus la revue de conception, les journaux et les essais de charge | L’option C11, Vérification et débogage |
S’il s’agit d’une première conception de ce type, couvrez les préalables C au chapitre 2, décidez des unités d’exécution et des données partagées aux chapitres 3 à 5, et vérifiez le chemin d’arrêt au chapitre 6. Pour revoir du code existant, la liste de contrôle de la fin convient aussi.
1. D’abord la conclusion
Choisir où le travail s’exécute selon sa nature
Les threads que vous créez vous-même et qui appellent le CRT se créent avec _beginthreadex. Si un thread créé avec CreateThread appelle le CRT, le CRT peut mettre fin au processus en cas de manque de mémoire. Pour paralléliser un grand nombre de travaux courts, utilisez CreateThreadpoolWork du pool de threads Windows plutôt que de créer vos propres threads encore et encore.123
Ne terminez jamais un thread du pool avec ExitThread ou TerminateThread. Un thread de pool est un emplacement d’exécution emprunté comme callback : faites le ménage et rendez-le.4
Séparer la protection des données partagées et l’attente
Dans un processus, le verrou par défaut est un verrou SRW ; choisissez une CRITICAL_SECTION lorsque l’acquisition récursive est nécessaire. Utiliser un Mutex pour une exclusion intra-processus fréquente paie le coût des transitions noyau.5
Mettez à jour les variables uniques avec la famille de fonctions Interlocked. Ajouter volatile seul ne garantit ni l’atomicité ni la synchronisation nécessaire. La plupart des fonctions Interlocked s’accompagnent d’une barrière mémoire complète. Pour l’attente, utilisez une variable de condition, ou un événement avec une fonction d’attente, afin que le polling par Sleep ne gaspille pas le CPU et la réactivité.67
Décider comment arrêter et l’ordre de libération avant de démarrer
N’utilisez pas TerminateThread ; concevez un arrêt coopératif avec un événement d’arrêt et WaitForMultipleObjects. La terminaison forcée peut corrompre l’état des verrous, du tas et des DLL, et c’est la cible de l’avertissement d’analyse de code C6258. Ne passez pas à la libération des ressources du seul fait qu’un arrêt a été demandé ; ne fermez les handles qu’après avoir confirmé que le thread s’est terminé.89
Ne créez pas de threads, ne synchronisez pas et n’attendez pas la fin de threads dans DllMain. Fournissez des fonctions explicites d’initialisation et d’arrêt en dehors du verrou du chargeur.10
Si la portabilité est exigée, C11 est aussi une option. En août 2026, <threads.h> est utilisable à partir de VS 2022 17.8 et <stdatomic.h> est experimental. Pour du code réservé à Windows, l’approche API Win32 de cet article est la base.11
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 (23 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. Pourquoi le multithreading est difficile — conditions de concurrence et interblocages
Les premières choses à saisir sont la condition de concurrence, où le résultat dépend de l’ordre d’exécution, et l’interblocage, où un cycle d’attentes arrête tout progrès. Distinguez ces deux avant d’apprendre les API.
Une condition de concurrence (race condition) est un bug dont le résultat change selon l’ordre dans lequel plusieurs threads atteignent un morceau de code donné.
Prenez count++ sur un compteur partagé. Même si c’est une seule expression, sans synchronisation rien ne garantit que « lire, additionner, réécrire » s’exécute de façon indivisible. Si deux threads lisent la même valeur, chacun additionne et chacun réécrit, l’une des additions est perdue.6 La figure 1 montre un ordre d’exécution où le compteur devait être incrémenté deux fois à partir de 10 et se retrouve pourtant à 11.
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: count = 11 après deux incrémentations<br/>l'addition du thread A a été perdue
Figure 1 : La condition de concurrence classique où un compteur partagé perd une addition. Si un autre thread s’intercale entre les trois étapes de count++, celui qui réécrit plus tard écrase l’autre
Un interblocage est un état dans lequel deux threads attendent chacun le verrou que l’autre détient, et aucun 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 seul les arrête tous les deux 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 un anneau, chaque thread de l’anneau s’arrête pour toujours
Ces défauts dépendent du timing. Un ordre d’exécution qui n’apparaît presque jamais sur une machine de développement peut apparaître de façon répétée chez un client, avec un autre nombre de cœurs et une autre charge. La raison pour laquelle un bug cesse de se reproduire dès qu’un débogueur est attaché, c’est que l’observation change le timing.
Aussi, chaque principe qui suit part de « réduire les endroits qui ont besoin de synchronisation » avant « synchroniser correctement ».
2.1. Ce que C suppose — le langage ne vous protège de rien
En C, parce que le langage n’a aucun mécanisme pour faire respecter ces principes, ils doivent être écrits explicitement comme une discipline.
Concevoir la libération jusqu’à chaque sortie de fonction
Il n’y a pas d’équivalent du RAII de C++, donc libérer les verrous et appeler CloseHandle sur les handles est quelque chose que vous garantissez vous-même. Utilisez le schéma goto cleanup qui fait passer toutes les sorties d’une fonction par un seul endroit, ou une convention d’écrire chaque acquisition appariée à sa libération. L’essentiel est une structure dans laquelle ajouter plus tard un return anticipé ne peut pas sauter la libération.
Même lorsque les lectures et écritures simples sont atomiques, la synchronisation reste nécessaire
Le besoin d’éviter les courses de données est le même qu’en C++. Sous Windows, une lecture ou une écriture simple d’une variable 32 bits correctement alignée est atomique, mais cela seul ne garantit ni la synchronisation de l’accès ni l’ordonnancement des opérations mémoire environnantes. Une variable 64 bits sur Windows 32 bits, une opération composée lire-et-additionner, et la cohérence de plusieurs variables ne peuvent pas non plus être traitées comme une lecture ou une écriture simple.12
Ne prenez pas « ça se trouve que ça marche » pour une preuve ; énoncez explicitement la synchronisation nécessaire avec un verrou ou Interlocked. Cette discipline reste nécessaire lorsque le compilateur ou le niveau d’optimisation change.
Écrire la propriété jusqu’au point de remise
Indiquez dans le commentaire de fonction « quel thread écrit ce tampon » et « à partir de quand il appartient à qui ». La discipline de propriété est aussi importante que le choix du primitive de synchronisation. Le découpage et les files décrits plus loin ne s’utilisent en sécurité qu’une fois cette frontière décidée.
3. Créer des threads — _beginthreadex, et rien d’autre
3.1. Pourquoi CreateThread est inadapté
L’API native Win32 est CreateThread, mais la consigne officielle est qu’un thread qui appelle des fonctions du CRT (bibliothèque d’exécution C) se crée avec _beginthreadex. _beginthreadex initialise les données internes par thread que le CRT utilise avant de démarrer l’exécution.2
Si un thread créé avec CreateThread appelle une fonction du CRT, le CRT peut mettre fin au processus en cas de manque de mémoire.1 printf, malloc et strtok sont aussi des fonctions du CRT, donc en pratique la règle de base peut être « les threads que l’on écrit soi-même en C utilisent _beginthreadex ».
Le créateur est responsable jusqu’à ce que le handle ait été attendu et fermé
Évitez aussi _beginthread (sans le ex). Si le thread se termine tôt, le handle renvoyé devient invalide et peut désigner un autre thread. Choisissez _beginthreadex, dont le handle peut être passé aux API de synchronisation, et faites attendre la fin par l’appelant puis CloseHandle.13
L’extrait suivant montre le flux de création et de jonction. Il suppose que l’application fournit la définition de WorkerContext, l’initialisation de ctx et le traitement d’échec ; ce n’est pas un programme complet qui compile tout seul. Si la création échoue, ne passez pas à l’attente, et en cas de succès gardez le ctx utilisé par le travailleur valide jusqu’à confirmation de la sortie.
#include <process.h>
static unsigned __stdcall WorkerMain(void* arg)
{
WorkerContext* ctx = (WorkerContext*)arg;
/* ... la boucle d'attente des chapitres 5 et 6 ... */
return 0;
}
HANDLE hThread = (HANDLE)_beginthreadex(
NULL, 0, WorkerMain, &ctx, 0, NULL);
if (hThread == NULL) { /* traitement d'échec */ }
/* ...après la demande d'arrêt... */
WaitForSingleObject(hThread, INFINITE); /* jonction */
CloseHandle(hThread);
3.2. Les travaux courts vont au pool de threads Windows
Lorsque vous voulez « soumettre un grand nombre de petits travaux » ou que vous « créez et détruisez des threads de courte durée encore et encore », utilisez le pool de threads Windows. Dans l’API disponible à partir de Windows Vista, créez un objet de travail avec CreateThreadpoolWork et soumettez-le avec SubmitThreadpoolWork. Les travailleurs du pool exécutent le callback, donc la gestion du nombre de threads est laissée à l’OS et il n’est plus besoin de créer et détruire un thread à soi pour chaque travail.3
Restaurer l’état du thread emprunté avant de revenir
Les règles officielles sont : ne pas terminer un thread du pool avec TerminateThread / ExitThread ; restaurer ce que le callback a modifié, comme le TLS ou la priorité du thread, avant de revenir ; et garder les handles d’attente valides jusqu’à ce que le pool ait fini de les utiliser.4
Gérer le nombre de threads ne limite pas à lui seul les soumissions
Même si le pool gère le nombre de threads, cela n’empêche pas une conception où les callbacks non exécutés s’accumulent sans borne. Dans un processus résident où les soumissions dépassent durablement le traitement, ajoutez une limite d’admission telle qu’un sémaphore, ou une file bornée, du côté application.
Décidez aussi si le côté qui soumet attend ou si la soumission est refusée lorsque la file est pleine. C’est de la backpressure qui empêche de convertir la surcharge en consommation mémoire, et c’est la même idée que le tampon borné du chapitre 5.
4. Minimiser l’état mutable partagé — découper, lecture seule, remise
Avant de choisir un primitive de synchronisation, demandez-vous si l’on peut réduire les endroits où plusieurs threads touchent les mêmes données mutables. Il y a trois moyens : découper, rendre en lecture seule, et remettre via une file.
Découper par thread, fusionner à la fin
Dans une agrégation parallèle, plutôt que de faire écrire chaque thread dans un compteur partagé à chaque fois, construisez des sous-totaux dans des variables locales ou des tampons dédiés. Fusionner une seule fois à la fin avec quelque chose comme InterlockedAdd réduit les écritures vers l’état partagé de « chaque itération » à « une fois par thread ».
Cela réduit à la fois le coût de synchronisation et les occasions de contention. La décision du § 2.1, « quel thread possède ce tampon », devient telle quelle la conception du découpage.
Rendre en lecture seule une fois l’initialisation achevée
La configuration et les tables construites au démarrage et jamais modifiées ensuite peuvent être lues depuis plusieurs threads une fois l’initialisation achevée. Ne laissez pas la frontière vague : terminez l’initialisation avant qu’aucun thread ne démarre, ou, pour une initialisation paresseuse, utilisez InitOnceExecuteOnce.5
L’essentiel n’est pas « censé être en lecture seule » mais de rendre explicite dans le code quand l’initialisation se termine et à partir de quand plus rien n’est modifié.
Transformer une variable partagée touchée des deux côtés en une remise par file
Faites transiter le flux de données entre threads par une file producteur/consommateur plutôt que d’opérer sur une variable partagée des deux côtés. Pour C et Win32, il existe un exemple d’implémentation officiel qui combine un tampon circulaire borné avec SleepConditionVariableCS.7
Plafonner la capacité donne aussi une backpressure naturelle : le producteur attend lorsque la production dépasse la consommation.
5. Choisir les objets de synchronisation et la discipline des verrous
Win32 a de nombreux types de primitives de synchronisation, et le mauvais choix coûte à la fois en performance et en correction. La consigne officielle est résumée en un diagramme.5
flowchart TB
S{"Synchroniser<br/>entre processus ?"} -->|"Oui"| Q2{"Pour quoi ?"}
Q2 -->|"Exclusion mutuelle"| MTX["Mutex nommé"]
Q2 -->|"Limiter l'accès simultané"| SEM["Sémaphore nommé"]
Q2 -->|"Notifier un événement"| EVT["Événement nommé"]
S -->|"Non (intra-processus)"| Q3{"Acquisition récursive<br/>par le même thread nécessaire ?"}
Q3 -->|"Oui"| CS["CRITICAL_SECTION"]
Q3 -->|"Non"| Q4{"Code C++ axé<br/>sur la portabilité ?"}
Q4 -->|"Oui"| STD["std::mutex /<br/>std::shared_mutex"]
Q4 -->|"Non"| SRW["Verrou SRW (le choix par défaut)"]
Figure 3 : Comment choisir un primitive de synchronisation Win32. La première branche est « est-ce que cela traverse les processus », et le point clé est de ne pas choisir un objet noyau (Mutex) lorsque ce n’est pas le cas
| Primitive | Portée | Caractéristiques | Où l’utiliser |
|---|---|---|---|
| Verrou SRW | Intra-processus | Rapide (se termine normalement en mode utilisateur), taille d’un pointeur, non récursif | Le défaut pour le code nouveau. AcquireSRWLockShared permet aussi des lectures partagées |
| CRITICAL_SECTION | Intra-processus | Rapide (tourne, puis attend dans le noyau), récursif | Lorsque le même thread a besoin d’une acquisition récursive |
| Mutex | Intra-processus / inter-processus | Toujours un objet noyau, lent | Exclusion inter-processus (nommé), et usage conjoint avec WaitForMultipleObjects |
| Sémaphore | Intra-processus / inter-processus | Objet noyau | Limiter l’accès simultané à un pool de ressources |
| Événement | Intra-processus / inter-processus | Objet noyau | Notifier que « quelque chose s’est produit » (pas pour protéger des données) |
| Fonctions Interlocked | Intra-processus (inter-processus aussi, via mémoire partagée) | Opérations atomiques sans verrou | Compteurs, drapeaux, permutation de pointeurs6 |
Les objets noyau ne sont pas réservés à l’usage inter-processus
Ce que montre la figure 3, c’est qu’on ne choisit pas un Mutex pour un verrou intra-processus sans raison. Les événements peuvent servir à la notification intra-processus, et les sémaphores à limiter la concurrence. L’événement d’arrêt du chapitre 6 est aussi un objet noyau sans nom.
L’absence de nom ne confine pas non plus nécessairement un objet à un seul processus. Par héritage de handle ou duplication avec DuplicateHandle, le même objet peut s’utiliser depuis plusieurs processus. Le nommage est un moyen représentatif de laisser un autre processus rouvrir le même objet.
Penser Interlocked séparément selon l’opération, l’alignement et la durée de vie
Rendre indivisible l’opération sur une variable unique
InterlockedIncrement / InterlockedExchange / InterlockedCompareExchange effectuent une opération sur une variable unique de façon indivisible. Ce sont le pendant de la classe Interlocked de l’édition .NET et de std::atomic de l’édition C++, et la plupart des fonctions s’accompagnent aussi d’une barrière mémoire complète.6
Ne supposez pas qu’ajouter volatile seul donne l’atomicité ou la synchronisation nécessaire. Pour garder plusieurs variables cohérentes ensemble, protégez-les avec un verrou SRW ou une CRITICAL_SECTION.
Respecter l’alignement de la variable cible
La cible d’une fonction Interlocked doit être alignée sur sa frontière naturelle : une frontière de 4 octets pour une valeur 32 bits, de 8 octets pour une valeur 64 bits. Le comportement sur une cible non alignée est imprévisible.12
Ne visez pas un champ d’une structure #pragma pack ni d’un tampon mappé directement sur un format filaire ; déclarez les compteurs et drapeaux comme des variables ordinaires que le compilateur aligne correctement.
Permuter un pointeur ne protège pas la durée de vie des anciennes données
Ce que InterlockedExchangePointer et les fonctions similaires rendent indivisible, c’est la permutation du pointeur elle-même. Si l’écrivain permute le pointeur et appelle free sur l’ancien bloc juste après qu’un lecteur a obtenu l’ancien pointeur, le lecteur accède à de la mémoire libérée.
Permutation et récupération sont des problèmes distincts. Il faut une procédure, comme un verrou ou un comptage de références sûr, qui ne récupère pas l’ancien bloc tant que les lecteurs n’en ont pas fini. En cas de doute, protégez-le avec un verrou SRW.
Variables de condition : vérifier la condition après le réveil
Une file producteur/consommateur à tampon borné utilise une variable de condition. Initialisez-la avec InitializeConditionVariable ; le consommateur attend avec SleepConditionVariableCS combiné à une CRITICAL_SECTION, et le producteur le réveille avec WakeConditionVariable. C’est la forme de l’exemple d’implémentation officiel.7 En combinaison avec un verrou SRW, utilisez SleepConditionVariableSRW.
L’essentiel est une boucle qui revérifie la condition à l’intérieur du verrou après le réveil et attend à nouveau si la condition est fausse. Il y a des réveils parasites sans aucune notification, et au moment où un thread se réveille un autre consommateur peut déjà avoir pris l’élément. « Réveillé » et « la file n’est pas vide » ne sont pas la même chose.
C’est l’outil pour construire en C la même structure que le canal du § 4.3 de l’édition .NET et la BlockingQueue du chapitre 4 de l’édition C++.
5.1. Discipline des verrous — trois principes quels que soient les primitives
Choisir correctement le primitive n’empêche pas les courses s’il n’y a pas de discipline dans la façon de l’utiliser.
1. Figer la correspondance entre données et verrou
Attribuez un verrou SRW ou une CRITICAL_SECTION à chaque ensemble de données mutables à protéger. Prenez ce même verrou à tous les endroits qui touchent les données.
En C, la pratique d’écrire « cette structure est protégée par g_lockFoo » dans l’en-tête est particulièrement utile. En revue, vérifiez cette correspondance sous forme de tableau.
2. Ne pas exécuter de travail lent ni d’appels externes pendant qu’on tient un verrou
Restreignez l’intérieur d’un verrou à la lecture et à l’écriture des données qu’il protège. Les E/S fichiers, les communications réseau et les invocations de callback tout en le tenant encore allongent le temps de détention. Si l’appelé acquiert un autre verrou, cela peut aussi créer l’attente circulaire de la figure 2.
3. Figer l’ordre d’acquisition pour plusieurs verrous
Lorsque deux verrous ou plus sont pris, définissez une hiérarchie de verrous pour que chaque thread suive le même ordre. Le document de bonnes pratiques DLL explique aussi que l’inversion de l’ordre d’acquisition invite à l’interblocage et que la hiérarchie doit être suivie de façon cohérente.10
6. Concevoir comment arrêter — ne pas utiliser TerminateThread
6.1. Ce que TerminateThread casse
TerminateThread termine le thread cible sans lui laisser exécuter aucun nettoyage en mode utilisateur. L’état qu’il détenait n’est pas nécessairement nettoyé en sécurité.8
| Moment où le thread a été terminé | Ce qui peut arriver |
|---|---|
| Pendant qu’il détenait une section critique | Le verrou n’est jamais libéré, et les autres threads continuent d’attendre |
| Pendant qu’il allouait de la mémoire sur le tas | Le verrou de tas reste tenu, et les allocations mémoire suivantes s’arrêtent |
| Pendant qu’il manipulait l’état global d’une DLL | L’état interne de la DLL est corrompu |
Officiellement, elle est positionnée comme « une fonction dangereuse à n’utiliser que dans les cas les plus extrêmes », et l’analyse de code la détecte aussi comme avertissement C6258.89
Trouver TerminateThread en enquêtant sur la cause d’une application qui « fige de temps en temps tout le processus » est un spectacle réellement courant en pratique. Si vous le trouvez, c’est quelque chose à réparer.
6.2. La forme correcte : un événement d’arrêt plus WaitForMultipleObjects
Dans l’arrêt coopératif, le côté qui arrête n’efface pas le thread ; il demande un arrêt et laisse le travailleur lui-même faire le ménage et sortir. La forme établie est de créer un événement d’arrêt à réinitialisation manuelle et de faire attendre au travailleur le signal de travail et le signal d’arrêt en même temps. Le matériel officiel C6258 décrit aussi de surveiller un événement et de se terminer soi-même.9
Traitez demande d’arrêt → nettoyage du travailleur → confirmation de la sortie du thread → libération des handles et des ressources partagées comme des étapes distinctes. Mettre l’événement d’arrêt à l’état signalé ne signifie pas que le thread s’est terminé.
Le code suivant est aussi un extrait explicatif. La création des événements et le contrôle d’échec, l’exclusion mutuelle de la file, et les implémentations de ProcessNextItem, LogLastError et Cleanup sont omises. Les événements et la file ne sont pas détruits pendant l’attente ou le traitement, et lisez-le dans l’hypothèse d’une notification de travail pour un seul travailleur, expliquée plus bas.
HANDLE hStopEvent; /* CreateEvent(NULL, TRUE, FALSE, NULL): réinitialisation manuelle */
HANDLE hWorkEvent; /* CreateEvent(NULL, FALSE, FALSE, NULL): réinitialisation automatique.
Revient tout seul à non signalé à la réception (avec une
réinitialisation manuelle, une fois signalé l'attente passerait
tout droit et tournerait en boucle occupée sur une file vide) */
static unsigned __stdcall WorkerMain(void* arg)
{
HANDLE waits[2] = { hStopEvent, hWorkEvent };
for (;;) {
DWORD r = WaitForMultipleObjects(2, waits, FALSE, INFINITE);
if (r == WAIT_FAILED) { /* p. ex. un handle invalide. Laissé tel quel, cela tourne à fond */
LogLastError(); /* Enregistrer GetLastError() et sortir */
break;
}
if (r == WAIT_OBJECT_0) /* Arrêt demandé */
break;
if (r == WAIT_OBJECT_0 + 1) { /* Travail disponible */
/* Passer aussi l'événement d'arrêt à ProcessNextItem : s'il attend longtemps
à l'intérieur d'un élément et ne peut pas y observer l'arrêt, l'arrêt est otage de cet élément */
while (ProcessNextItem(hStopEvent)) { /* Traiter un élément de la file. FALSE si vide */
/* Vérifier aussi la demande d'arrêt pendant le vidage. Sans cela
on ne peut pas s'arrêter tant que le travail s'accumule (famine d'arrêt) */
if (WaitForSingleObject(hStopEvent, 0) == WAIT_OBJECT_0)
break;
}
}
}
Cleanup(); /* Faire son propre nettoyage soi-même */
return 0; /* Se terminer soi-même */
}
BOOL StopWorkers(HANDLE* threads, DWORD count)
{
BOOL ok = TRUE;
if (!SetEvent(hStopEvent)) { /* Si la demande d'arrêt n'est pas passée, */
LogLastError(); /* ne pas entrer dans une jonction sans borne */
return FALSE;
}
/* La demande d'arrêt est maintenant levée pour tout le monde d'un coup */
for (DWORD i = 0; i < count; i++) {
if (WaitForSingleObject(threads[i], INFINITE) == WAIT_OBJECT_0) {
CloseHandle(threads[i]); /* Fermer seulement les handles dont la jonction a été confirmée */
} else {
LogLastError(); /* WAIT_FAILED : p. ex. un handle invalide */
ok = FALSE; /* Ne pas rapporter « tout le monde s'est arrêté » */
}
}
return ok; /* Si FALSE, ne pas passer à la libération des ressources partagées */
}
Confirmer que l’attente a réussi avant de quitter la jonction
StopWorkers vérifie d’abord que SetEvent a réussi. Si la demande d’arrêt n’a pas pu être livrée, elle ne doit pas passer à une jonction sans borne. Pour chaque thread aussi, elle ne ferme que les handles pour lesquels WaitForSingleObject a renvoyé WAIT_OBJECT_0, et si une attente échoue elle ne rapporte pas « tout le monde s’est arrêté ». Si la valeur de retour est FALSE, ne passez pas à la libération des ressources partagées.
La raison pour laquelle l’attente de sortie se fait un thread à la fois, c’est que WaitForMultipleObjects a une limite supérieure de MAXIMUM_WAIT_OBJECTS (64) sur le nombre de handles qu’il peut attendre d’un coup. Avec un tableau au-delà de cette limite, l’attente peut renvoyer WAIT_FAILED. S’il s’agit seulement d’attendre que tout le monde finisse, une boucle qui attend un à un n’a pas cette contrainte sur la longueur du tableau.
flowchart TB
OWNER["Côté qui arrête"] -->|"SetEvent(hStopEvent)"| SE["Événement d'arrêt<br/>(réinitialisation manuelle, visible de tous)"]
SE --> W1["Travailleur 1<br/>attend l'arrêt et le travail à la fois<br/>avec WaitForMultipleObjects"]
SE --> W2["Travailleur 2<br/>attend l'arrêt et le travail à la fois<br/>avec WaitForMultipleObjects"]
W1 --> C1["Fait le ménage et revient lui-même"]
W2 --> C2["Fait le ménage et revient lui-même"]
C1 --> J["Le côté qui arrête attend les handles de threads et joint<br/>ce n'est qu'alors qu'on peut dire arrêté"]
C2 --> J
Figure 4 : Le schéma de l’événement d’arrêt. Utiliser un événement à réinitialisation manuelle pour l’arrêt signifie qu’un SetEvent réveille d’un coup tous les travailleurs en attente. Chaque thread décide comment il se termine, et l’achèvement de la jonction est ce qui compte comme arrêté
Tenir les réglages et l’ordre de l’événement d’arrêt
Faites de l’événement d’arrêt un événement à réinitialisation manuelle, pour qu’un SetEvent soit visible de tous les travailleurs. Son rôle diffère de celui de la notification de travail.
Placez aussi l’événement d’arrêt en premier dans le tableau d’attente de WaitForMultipleObjects. Dans une attente avec bWaitAll à FALSE, comme dans cet exemple, lorsque plusieurs objets sont signalés, le handle d’indice plus petit a la priorité. Enfin, le côté qui arrête ne ferme un handle de thread qu’après en avoir confirmé la jonction.
Un seul travailleur : réveiller avec un événement et vider la file
La forme ci-dessus, « réveiller avec un événement à réinitialisation automatique et vider toute la file », est pour une configuration à un seul travailleur. Un événement à réinitialisation automatique ne compte pas le nombre de travaux, peu importe le nombre de SetEvent. Il n’y a qu’un état signalé, donc les signaux consécutifs fusionnent.
Si cette forme est utilisée telle quelle avec plusieurs travailleurs, un seul d’entre eux peut se réveiller et traiter un lot de travail en série.
Plusieurs travailleurs : faire correspondre un permis de sémaphore à un travail
Si plusieurs travailleurs partagent la file, passez le signal de travail à un sémaphore. Le producteur augmente le compteur avec ReleaseSemaphore(hSem, 1, NULL) pour chaque élément mis en file, et le consommateur prend exactement un élément par attente réussie. Parce qu’une attente réussie consomme un permis, la correspondance avec le nombre d’éléments est préservée.
Dans ce cas, ne gardez pas la boucle de vidage intégral de l’exemple. Si un travailleur vide la file tout en ne consommant qu’un permis, un autre travailleur se réveille sur le permis restant face à une file vide, ou le ReleaseSemaphore du producteur échoue en dépassant le compte maximal. « Un permis = un travail » est le préalable.
Rendre l’arrêt observable même lorsqu’un élément prend longtemps
Vérifier l’arrêt seulement entre les éléments ne suffit pas. Si ProcessNextItem fait une longue attente bloquante en interne, passez-y aussi l’événement d’arrêt et attendez les deux ensemble, ou fixez un délai d’expiration fini.
Sinon l’arrêt attend indéfiniment parce qu’un élément ne se termine jamais. StopAsync de l’édition .NET et jthread avec join de l’édition C++ suivent la même idée.
Prévoir un chemin d’arrêt côté E/S pour les E/S bloquantes
Un thread en attente d’E/S sur un tube, un socket ou un port série ne peut pas, tel quel, revenir vérifier l’événement d’arrêt. Le côté E/S a aussi besoin d’un chemin qui termine l’attente, y compris attendre sur OVERLAPPED conjointement à un événement, et annuler l’E/S avec CancelIoEx.
Un exemple concret est traité dans « Les pièges des applications de communication série ».
7. DllMain et le verrou du chargeur — un champ de mines quand on écrit des DLL
Les composants partagés écrits en C deviennent souvent des DLL, et les DLL portent une contrainte qui leur est propre : le verrou du chargeur. Le chargeur de l’OS appelle DllMain en tenant le verrou du chargeur, donc y faire l’une des choses suivantes provoque des interblocages ou des plantages.10
- Synchroniser avec d’autres threads (acquérir des verrous, attendre la fin de threads)
- Appeler
LoadLibrary/FreeLibrary, directement ou indirectement - Créer des threads (dangereux dès qu’il y a de la synchronisation) ou appeler
ExitThread
Fournir une fonction d’arrêt explicite en dehors de DllMain
« Attendre la fin des travailleurs dans DllMain au déchargement de la DLL » a l’air juste au premier abord mais est un interblocage classique. Le thread qui se termine a aussi besoin du verrou du chargeur pour livrer DLL_THREAD_DETACH, donc lui et DllMain s’attendent mutuellement.10
Une DLL qui possède des threads expose des fonctions d’initialisation et d’arrêt telles que MyLib_Init / MyLib_Shutdown et démarre et joint ses threads en dehors de DllMain. L’appelant décharge la DLL après avoir confirmé l’arrêt via la fonction d’arrêt. DllMain elle-même se conçoit comme un stub aussi proche que possible du vide.10
8. L’option des threads C11 — où en est-on
Pour partager du code avec d’autres systèmes d’exploitation sans dépendre de Win32, <threads.h> et <stdatomic.h> de C11 sont une option. En regardant le support MSVC séparément, en août 2026, le tableau est le suivant.11
| Fonctionnalité | Statut dans MSVC | Conditions à vérifier |
|---|---|---|
<threads.h> (thrd_create / mtx_lock / cnd_wait) |
Pris en charge dans Visual Studio 2022 17.8 | /std:c11 et un SDK Windows correspondant |
<stdatomic.h> |
experimental | L’option /experimental:c11atomics |
Il est important de ne pas supposer que les threads et les opérations atomiques sont au même stade de support.
Si partager du code avec Linux est une exigence, les threads C11 (ou un enveloppeur pthread) ont de la valeur, mais pour une base de code réservée à Windows l’approche Win32 de cet article a l’avantage en volume d’information, en retours d’expérience et en facilité de débogage. Quel que soit le choix, les principes de conception jusqu’ici (réduire le partage, apparier les verrous aux données, arrêter de façon coopérative) ne changent pas.
9. Vérification et débogage — se préparer en supposant que ça ne se reproduira pas
Même lorsque les tests ordinaires passent, on ne peut pas dire qu’il n’y a pas de course, parce qu’il reste possible que « l’ordre d’exécution problématique ne se soit simplement pas produit ». Préparez-vous en trois couches : vérifier la conception, rendre les anomalies observables, et secouer l’ordre d’exécution avec de la charge.
Revue de conception : apparier données, verrous et chemins d’arrêt
Vérifiez, sous forme de tableau, les données mutables partagées, les verrous qui les protègent, l’ordre d’acquisition pour plusieurs verrous, et les travailleurs que l’événement d’arrêt atteint. C’est le travail de voir si la discipline du § 5.1 correspond au code réel.
Si cette correspondance ne peut pas s’écrire, « ça marche » ne peut pas servir de base pour dire que la conception est achevée.
Observation : enregistrer où les threads attendent avec délais, journaux et dumps
Plutôt que de faire de chaque attente un INFINITE inconditionnel, fixez des délais aux points clés et journalisez lorsque le temps est écoulé. Cela transforme un état qui ne faisait qu’attendre en un échec que l’on peut investiguer. Un délai d’expiration, toutefois, n’est pas une confirmation qu’un thread est sorti, et à lui seul il ne rend pas sûre la libération des ressources partagées.
Sur un hang, capturez un dump, regardez la pile de chaque thread, et vérifiez si les attentes de verrous forment un cycle. Pour les erreurs autour des DLL, utilisez aussi Application Verifier, officiellement recommandé.10 Pour mettre en place journaux et dumps, voir « Concevoir les applications Windows pour laisser des journaux et des dumps en cas de plantage ».
Essais de charge : essayer des ordres d’exécution rares sur une machine de développement
Des essais de stress tels que tourner longtemps avec plus de threads que de cœurs, randomiser l’ordre de traitement et insérer des délais artificiels augmentent les chances qu’un défaut apparaisse. Vérifiez aussi la combinaison d’une compilation release optimisée et d’une charge élevée.
Les essais de charge ne remplacent pas la revue de conception. Combinez les trois couches pour vous rapprocher d’un état où la cause peut se retracer lorsqu’un bug se reproduit.
10. Synthèse — liste de contrôle de l’édition C
- Chaque thread est-il créé avec
_beginthreadex(sans mélange deCreateThread/_beginthread) ? - Les handles de threads sont-ils joints (
WaitForSingleObject) avantCloseHandle? - Fabrique-t-on en masse ses propres threads pour des travaux de courte durée (pourraient-ils être soumis à l’API du pool de threads) ?
- L’exclusion intra-processus se fait-elle avec un verrou SRW / CRITICAL_SECTION (plutôt que de détourner un Mutex) ?
- Les compteurs et drapeaux partagés se mettent-ils à jour avec les fonctions Interlocked plutôt qu’en s’appuyant sur
volatile? - Reste-t-il du polling par
Sleep(a-t-il été remplacé par une variable de condition ou une attente d’événement) ? TerminateThread(terminaison forcée d’un autre thread) est-il absent partout ? Les travailleurs se terminent-ils par unreturnde la fonction de thread plutôt que par un appel àExitThread(pour que le nettoyage du CRT s’exécute correctement via_endthreadex) ?- Chaque travailleur a-t-il un chemin d’arrêt d’événement d’arrêt plus
WaitForMultipleObjects, et les threads bloqués en E/S peuvent-ils aussi être réveillés ? - La libération des verrous et des handles est-elle garantie sur chaque chemin de retour (la discipline
goto cleanup) ? DllMainévite-t-elle de créer des threads, de synchroniser et d’attendre la fin de threads ?
À la place de l’aide du langage, en multithreading C le choix des API et la discipline deviennent tels quels la qualité. _beginthreadex, les verrous SRW, Interlocked et l’événement d’arrêt — faites de cet ensemble de quatre le défaut, et même en C vous pouvez concevoir de façon à vous éloigner de « ça fige de temps en temps ».
Articles associés
- Bonnes pratiques du multithreading : édition .NET
- Bonnes pratiques du multithreading : édition C++
- Bonnes pratiques du multithreading : édition Java
- Pièges de la mémoire partagée et bonnes pratiques
- Pourquoi préférer les attentes d’événement à Sleep(1) sous Windows
- Les pièges des applications de communication série
- Concevoir les applications Windows pour laisser des journaux et des dumps en cas de plantage
Domaines de conseil associés
KomuraSoft LLC assure des revues de conception multithread pour les processus résidents, les applications de commande d’équipement et les DLL écrits en C, l’investigation de cause (analyse de dumps) des hangs et plantages dus à TerminateThread ou à des verrous non libérés, et le conseil technique sur l’ajout de threads à du code C héritage.
- Conseil technique et revue de conception
- Investigation de bugs et analyse de cause
- Développement d’applications Windows
- Nous contacter
Références
-
Microsoft Learn, CreateThread function. Sur le fait que les threads d’un exécutable qui appelle le CRT doivent être gérés avec _beginthreadex / _endthreadex plutôt que CreateThread / ExitThread, et que le CRT peut mettre fin au processus en situation de mémoire basse lorsqu’un thread créé avec CreateThread appelle le CRT. ↩ ↩2
-
Microsoft Learn, Multithreading with C and Win32. Sur le fait que les programmes qui appellent la bibliothèque CRT doivent démarrer les threads avec _beginthread / _beginthreadex plutôt que CreateThread / ExitThread de Win32 ; que la famille _beginthread initialise les variables par thread du CRT ; et que SuspendThread peut arrêter un thread pendant qu’il accède à des structures de données internes du CRT, ce qui peut mener à un interblocage. ↩ ↩2
-
Microsoft Learn, CreateThreadpoolWork function. Sur la création d’un objet de travail avec CreateThreadpoolWork et l’exécution du callback par un thread travailleur du pool à chaque appel de SubmitThreadpoolWork ; sur la possibilité de spécifier l’environnement d’exécution avec un environnement de callback (TP_CALLBACK_ENVIRON) ; et sur la disponibilité à partir de Windows Vista. ↩ ↩2
-
Microsoft Learn, Thread Pools. Sur le fait que le pool de threads convient aux applications qui exécutent de façon asynchrone un grand nombre de travaux courts ou qui créent fréquemment des threads de courte durée ; sur les composants de la nouvelle API de pool de threads redessinée dans Vista ; et sur les bonnes pratiques de ne jamais terminer un thread du pool avec TerminateThread ni d’appeler ExitThread depuis un callback, de nettoyer tout état créé dans un callback avant de revenir, et de garder les handles d’attente en vie jusqu’à ce que le pool ait fini de les utiliser. ↩ ↩2
-
Microsoft Learn, About Synchronization. Sur la consigne de choix des primitives de synchronisation Win32 : les verrous SRW comme défaut pour le code nouveau, de la taille d’un pointeur et se terminant normalement en mode utilisateur ; CRITICAL_SECTION pour les cas d’acquisition récursive ; Mutex toujours un objet noyau, utilisé pour la synchronisation nommée inter-processus et conjointement avec WaitForMultipleObjects ; l’usage d’un Mutex pour la synchronisation intra-processus étant une « erreur courante » nettement plus lente sous des opérations fréquentes ; et les sémaphores servant à limiter l’accès simultané à un pool de ressources et les événements à la notification. ↩ ↩2 ↩3
-
Microsoft Learn, Interlocked Variable Access. Sur le fait que les fonctions Interlocked synchronisent l’accès à une variable partagée par plusieurs threads et effectuent l’opération de façon indivisible ; que InterlockedIncrement / Decrement regroupent lecture, addition et réécriture en une seule opération atomique, car sans synchronisation un incrément simultané de deux threads peut en perdre un ; sur la famille InterlockedExchange / InterlockedCompareExchange ; sur l’usage possible entre threads de processus différents lorsque la variable est en mémoire partagée ; et sur le fait que la plupart des fonctions Interlocked fournissent une barrière mémoire complète, avec des variantes Acquire / Release pour choisir la sémantique d’ordonnancement. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Using Condition Variables. Sur l’exemple d’implémentation d’une file producteur/consommateur utilisant un tampon circulaire borné protégé par une CRITICAL_SECTION ; sur la structure dans laquelle InitializeConditionVariable crée la variable de condition, le consommateur attend avec SleepConditionVariableCS, et l’autre côté est réveillé avec WakeConditionVariable ; et sur le support des variables de condition à partir de Windows Vista. ↩ ↩2 ↩3
-
Microsoft Learn, TerminateThread function. Sur le fait que TerminateThread termine le thread cible sans lui laisser exécuter aucun code en mode utilisateur ; que la section critique n’est pas libérée si la cible en détenait une ; que le verrou de tas n’est pas libéré si le thread allouait de la mémoire sur le tas ; que l’état de kernel32 ou l’état global d’une DLL peut être corrompu ; et qu’il s’agit d’« une fonction dangereuse à n’utiliser que dans les cas les plus extrêmes », à n’appeler que si l’on connaît exactement et contrôle le code que le thread cible pourrait exécuter. ↩ ↩2 ↩3
-
Microsoft Learn, Warning C6258. Sur le fait que l’avertissement d’analyse de code C6258 détecte l’usage de TerminateThread ; que TerminateThread ne permet pas un nettoyage correct du thread ; et que la procédure de terminaison correcte est montrée comme créant un événement avec CreateEvent, faisant surveiller l’état de l’événement par chaque thread avec WaitForSingleObject, et faisant terminer l’exécution du thread lui-même une fois l’événement signalé. ↩ ↩2 ↩3
-
Microsoft Learn, Dynamic-Link Library Best Practices. Sur le fait que DllMain est appelée pendant que le verrou du chargeur est tenu, ce qui place de sérieuses restrictions sur les API appelables ; que synchroniser avec d’autres threads à l’intérieur de DllMain mène à l’interblocage ; que l’appel de LoadLibrary est interdit ; sur le schéma dans lequel attendre la fin d’un thread à l’intérieur de DllMain pendant le déchargement de la DLL s’interbloque avec la livraison de DLL_THREAD_DETACH de ce thread ; sur le DllMain idéal proche d’un stub vide, l’initialisation étant différée autant que possible ; et sur la définition d’une hiérarchie de verrous avec le verrou du chargeur au sommet. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. Sur le tableau de conformité de la bibliothèque standard C montrant les threads C11 (threads.h) pris en charge dans Visual Studio 2022 17.8 ; sur stdatomic.h traité comme experimental (l’option /experimental:c11atomics) ; et sur le support compilateur C11 / C17 exigeant Visual Studio 2019 16.8 ou ultérieur ainsi qu’un SDK Windows correspondant. ↩ ↩2
-
Microsoft Learn, Interlocked Variable Access. Sur le fait qu’une lecture ou une écriture simple d’une variable 32 bits correctement alignée est atomique alors que la synchronisation (l’ordonnancement) de l’accès n’est pas garantie ; qu’une lecture ou une écriture simple d’une variable 64 bits est atomique sous Windows 64 bits mais n’est pas garantie sous Windows 32 bits ; et que les variables d’autres tailles ne sont garanties atomiques sur aucune plateforme. ↩ ↩2
-
Microsoft Learn, _beginthread, _beginthreadex. Sur les raisons pour lesquelles _beginthreadex est plus sûr que _beginthread : un thread créé avec _beginthread peut laisser le handle renvoyé invalide, éventuellement en désignant un autre thread, s’il se termine tôt ; le handle de _beginthreadex doit être fermé par l’appelant avec CloseHandle et sa validité est garantie ; _beginthreadex permet de passer le handle aux API de synchronisation ; la fonction de thread renvoie un code de sortie de thread sous la convention d’appel __stdcall ; et lier contre le CRT multithread est requis. ↩
Articles associés
Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.
Bonnes pratiques du multithreading : édition C++ — éliminer les accidents avec RAII et jthread
En C++, une course de données est un comportement indéfini. Piège du destructeur de std::thread, arrêt avec jthread et stop_token, scoped...
Bonnes pratiques du multithreading : édition .NET — ce qu'il faut décider avant d'ajouter des threads
Empêcher les threads .NET/C# de planter ou de figer. S'appuyer sur Task, réduire l'état mutable partagé, verrouiller avec discipline, arr...
API du pool de threads Win32 — de la concurrence sans créer de threads, avec CreateThreadpoolWork
Vous multipliez les CreateThread dans le code natif ? Cet article explique, à partir des sources primaires, l'API du pool de threads Win3...
DllMain et le verrou du chargeur — la vraie raison pour laquelle on vous dit de « ne rien faire dans l'initialisation d'une DLL »
Pourquoi il ne faut pas appeler LoadLibrary ni synchroniser avec d'autres threads depuis DllMain. À partir des sources primaires, cet art...
Ce qu'est vraiment « Ne répond pas » — comment Windows juge qu'une application est bloquée, et comment concevoir des applications qui ne le sont pas
Le « Ne répond pas » de Windows est un mécanisme dans lequel l'OS juge qu'une fenêtre n'a pas récupéré de message pendant 5 secondes et l...
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.
- Faut-il utiliser CreateThread ou _beginthreadex ?
- Pour un thread qui appelle des fonctions du CRT (bibliothèque d'exécution C), c'est _beginthreadex. _beginthreadex démarre le thread après avoir initialisé les données internes que le CRT utilise pour chaque thread. La documentation officielle indique explicitement que si un thread créé avec CreateThread appelle une fonction du CRT, le CRT peut mettre fin au processus en cas de manque de mémoire. En pratique, un thread d'une application écrite en C appelle presque toujours, quelque part, une fonction du CRT (printf, malloc, strtok, etc.), donc on peut retenir sans risque « toujours _beginthreadex ». Notez que _beginthread (sans « ex ») comporte un piège : si le thread se termine tôt, le handle devient invalide ; on choisit donc là aussi _beginthreadex.
- Ne faut-il vraiment jamais arrêter un thread avec TerminateThread ?
- Non, il ne faut pas. TerminateThread efface le thread ciblé sans jamais lui laisser exécuter la moindre ligne de code en mode utilisateur : s'il détenait une section critique, elle n'est jamais libérée ; s'il était en train d'allouer de la mémoire sur le tas, le verrou de tas reste tenu ; s'il manipulait l'état global d'une DLL, cet état est corrompu. La documentation officielle la décrit explicitement comme « une fonction dangereuse à n'utiliser que dans les cas les plus extrêmes », et l'analyse de code la détecte via l'avertissement C6258. L'arrêt correct est un arrêt coopératif : créer un événement d'arrêt, faire surveiller cet événement par chaque thread via WaitForSingleObject / WaitForMultipleObjects, et laisser chaque thread faire son propre nettoyage avant de se terminer.
- J'utilisais un Mutex pour l'exclusion mutuelle au sein d'un processus. Qu'est-ce qui ne va pas ?
- Cela fonctionne, mais au prix d'une perte de performance importante. Le Mutex de Win32 est toujours un objet noyau, donc chaque acquisition et chaque libération entraîne une transition vers le mode noyau. Pour une exclusion mutuelle au sein d'un processus, un verrou SRW ou une CRITICAL_SECTION, qui restent en mode utilisateur (et ne retombent en attente noyau qu'en cas de contention), sont nettement plus rapides ; la documentation officielle décrit d'ailleurs explicitement l'usage de Mutex pour la synchronisation au sein d'un processus comme une « erreur courante ». Le Mutex trouve sa place lorsqu'il faut une exclusion mutuelle entre processus via un objet nommé, ou lorsqu'on veut attendre simultanément d'autres objets noyau avec WaitForMultipleObjects.
- Peut-on utiliser threads.h et stdatomic.h de C11 sous Windows ?
- Avec MSVC, les threads C11 (threads.h) sont pris en charge depuis Visual Studio 2022 17.8 (nécessite /std:c11 et le SDK Windows correspondant). En revanche, stdatomic.h reste traité comme expérimental, à un stade nécessitant l'option /experimental:c11atomics (selon le tableau officiel de conformité en date d'août 2026). Cela peut constituer une option si la portabilité est la priorité absolue, mais pour une base de code réservée à Windows, écrire avec l'API Win32 (_beginthreadex, verrou SRW, variables de condition, Interlocked) reste réaliste, en termes de retours d'expérience et de volume d'information disponible.
- Mettre volatile suffit-il à rendre un indicateur partagé sûr ?
- Non. Le volatile du C se contente d'empêcher certaines optimisations du compilateur (comme la mise en cache dans un registre) ; il ne garantit ni l'atomicité de l'opération, ni l'ordre mémoire entre processeurs. La simple lecture ou écriture d'une variable 32 bits correctement alignée est certes atomique en elle-même sous Windows, mais un « lire, additionner, réécrire » se décompose en plusieurs étapes, et l'ordre par rapport aux opérations mémoire environnantes n'est pas défini. Utilisez les fonctions de la famille Interlocked pour mettre à jour un compteur ou un indicateur partagé. Comme la plupart des fonctions Interlocked s'accompagnent d'une barrière mémoire complète, vous obtenez du même coup une garantie d'ordre. Pour protéger plusieurs variables ensemble, utilisez un verrou SRW ou une CRITICAL_SECTION.
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.