Bonnes pratiques du multithreading en pratique — édition langage C — écrire en toute sécurité à la manière de l'API Win32

· · Windows, Multithreading, Langage C, Win32 API, Application métier, Diagnostic de bugs, Conception

« J’écris en C un processus résident qui pilote un équipement. » « Il a fallu ajouter des threads à une application C vieille de vingt ans. » « On arrête les threads avec TerminateThread, mais parfois tout le processus se fige. » — le multithreading en langage C est l’univers où le langage offre le moins de soutien. Pas d’exceptions, pas de RAII, pas de templates : toute la correction de la synchronisation repose sur le choix des API et la discipline des appels.

Cet article est l’édition langage C de notre série pratique sur le multithreading. Destiné aux développeurs qui écrivent en C avec l’API Win32, il traduit dans les outils de Win32 les principes de conception du multithreading — cesser de multiplier les threads, réduire l’état mutable partagé, discipliner les verrous, concevoir la façon d’arrêter dès le départ — et couvre, sur la base de sources primaires datant d’août 2026, la création de threads (_beginthreadex), le choix des objets de synchronisation, une conception d’arrêt sans TerminateThread, et les contraintes de DllMain. Il est écrit pour se lire de façon autonome. Les mêmes principes déclinés dans d’autres langages existent aussi : « édition .NET », « édition C++ », « édition Java ».

1. La conclusion, d’abord

  • La création de threads se fait avec _beginthreadex, pas avec CreateThread. Si un thread appelant le CRT est créé avec CreateThread, le CRT peut mettre fin au processus en cas de manque de mémoire.12
  • Pour les verrous au sein d’un processus, le verrou SRW est le choix par défaut ; CRITICAL_SECTION uniquement lorsqu’une acquisition récursive est nécessaire. Utiliser un Mutex pour l’exclusion mutuelle au sein d’un processus est une « erreur courante », toujours accompagnée d’une transition vers le mode noyau.3
  • La mise à jour d’une variable unique se fait avec les fonctions de la famille Interlocked. volatile ne garantit ni l’atomicité ni l’ordre. La plupart des fonctions Interlocked s’accompagnent d’une barrière mémoire complète.4
  • Pour l’attente synchronisée, utilisez les variables de condition (famille SleepConditionVariableCS) ou un événement combiné à une fonction d’attente. Une boucle de scrutation avec Sleep gaspille à la fois le CPU et la réactivité.5
  • N’utilisez pas TerminateThread. C’est une fonction dangereuse qui peut corrompre les verrous, le tas et l’état des DLL, et elle est ciblée par l’avertissement d’analyse de code C6258. Concevez l’arrêt comme un arrêt coopératif via « un événement d’arrêt + WaitForMultipleObjects ».67
  • Pour paralléliser de courtes tâches, confiez-les au pool de threads Windows (CreateThreadpoolWork) plutôt qu’à des threads maison. Ne terminez jamais un thread du pool avec ExitThread / TerminateThread.89
  • Ne créez pas de threads, ne synchronisez pas et n’attendez pas la fin de threads dans DllMain. Comme DllMain est appelé pendant que le verrou du chargeur (loader lock) est détenu, c’est un terreau à interblocages.10
  • Le <threads.h> de C11 est utilisable à partir de VS 2022 17.8, mais <stdatomic.h> est encore expérimental. Pour du code réservé à Windows, adopter les usages de l’API Win32 reste l’approche réaliste.11

2. Pourquoi le multithreading est-il difficile ? — Conditions de concurrence et interblocages

Les problèmes qu’introduit le multithreading se ramènent, quel que soit le langage, à deux types fondamentaux.

Une condition de concurrence (race condition) est un bug où le résultat change selon l’ordre dans lequel plusieurs threads atteignent un morceau de code donné. L’exemple classique est le compteur partagé : l’expression count++ se décompose, au niveau du langage machine, en trois étapes — « lecture → addition → réécriture ». Si deux threads entrent simultanément dans ces trois étapes, l’addition de l’un est écrasée et perdue par la réécriture de l’autre.4 Le résultat change à chaque exécution, et il est impossible de prédire lequel se produira.

Thread BVariable partagée countThread AThread BVariable partagée countThread Acount = 10count = 11 malgré deux additionsl'addition du thread A a été perdueLecture (10)Lecture (10)Addition locale (11)Addition locale (11)Réécriture (11)Réécriture (11)

Figure 1 : condition de concurrence typique où une addition est perdue sur un compteur partagé. Si un autre thread s’intercale entre les trois étapes de count++, celui qui réécrit en dernier écrase l’autre.

Un interblocage (deadlock) est une situation où deux threads attendent chacun le verrou détenu par l’autre, si bien qu’aucun des deux ne peut avancer. Le thread A détient le verrou 1 et attend le verrou 2, le thread B détient le verrou 2 et attend le verrou 1 — cela suffit à les figer tous les deux pour toujours.

attend la libération du verrou 2attend la libération du verrou 1Thread Adétient le verrou 1Thread Bdétient le verrou 2

Figure 2 : l’attente circulaire d’un interblocage. Dès que les flèches d’attente forment une boucle, tous les threads de la boucle s’arrêtent pour toujours.

Les deux dépendent du timing : une combinaison d’ordres d’exécution qui, sur une machine de développement, ne se produit qu’une fois sur des dizaines de milliers d’essais, peut survenir chaque jour sur une machine cliente au nombre de cœurs et au timing différents. Le fait que « ça ne se reproduit plus une fois le débogueur branché » vient aussi de ce que l’observation modifie le timing — un comportement typique des bugs de concurrence. C’est pourquoi tous les principes de cet article visent, avant même « bien synchroniser », à « réduire les endroits où la synchronisation est nécessaire ».

2.1. Les prémisses propres au C — le langage ne protège de rien

Comme le langage C ne dispose d’aucun mécanisme pour « faire respecter » ces principes, il faut les expliciter comme une discipline.

Premièrement, construire la garantie de libération dans la structure du code. En l’absence d’équivalent au RAII de C++, la libération des verrous et l’appel à CloseHandle sur les handles sont garantis par le patron goto cleanup, qui unifie le point de sortie de la fonction, ou par une convention de codage qui écrit systématiquement l’acquisition et la libération en paire. L’ajout ultérieur d’un return précoce qui fait fuir un verrou est un accident typique en C.

Deuxièmement, le traitement des accès concurrents aux données est le même qu’en C++. La simple lecture ou écriture d’une variable 32 bits correctement alignée est bien atomique sous Windows, mais au-delà — variables 64 bits (sous Windows 32 bits), opérations composées, cohérence entre plusieurs variables — rien n’est garanti.12 Un code qui « fonctionne par hasard » se casse au moindre changement de compilateur ou de niveau d’optimisation.

Troisièmement, définir la propriété. La culture consistant à préciser explicitement, dans les commentaires de fonction, « quel thread écrit dans ce tampon, et à partir de quand il appartient à qui » compte, dans le multithreading en C, tout autant que le choix des primitives de synchronisation.

3. Comment créer des threads — _beginthreadex, l’unique bon choix

3.1. Pourquoi CreateThread ne convient pas

L’API native de Win32 est CreateThread, mais la directive officielle est de créer avec _beginthreadex tout thread qui appelle des fonctions du CRT (bibliothèque d’exécution C). _beginthreadex initialise les données internes utilisées par le CRT pour chaque thread avant de démarrer celui-ci. Si un thread créé avec CreateThread appelle une fonction du CRT, le CRT peut, en cas de manque de mémoire, mettre fin au processus.12 Comme printf, malloc et strtok font tous partie du CRT, en pratique « un thread écrit en C utilise toujours _beginthreadex ».

Évitez également _beginthread (sans « ex »). Il comporte un piège : si le thread créé se termine tôt, le handle retourné devient invalide (et peut pointer vers un autre thread) ; _beginthreadex, dont le handle peut être passé à une API de synchronisation, est plus sûr. Le handle retourné par _beginthreadex doit être fermé par l’appelant avec CloseHandle.13

#include <process.h>

static unsigned __stdcall WorkerMain(void* arg)
{
    WorkerContext* ctx = (WorkerContext*)arg;
    /* ... boucle d'attente des chapitres 5 et 6 ... */
    return 0;
}

HANDLE hThread = (HANDLE)_beginthreadex(
    NULL, 0, WorkerMain, &ctx, 0, NULL);
if (hThread == NULL) { /* traitement de l'échec */ }
/* ...après la demande d'arrêt... */
WaitForSingleObject(hThread, INFINITE);  /* jointure */
CloseHandle(hThread);

3.2. Les tâches courtes vers le pool de threads Windows

Si vous voulez « lancer une grande quantité de petites tâches » ou que vous « créez et détruisez sans cesse des threads de courte durée », utilisez le pool de threads Windows (l’API de pool de threads apparue avec Vista) plutôt que des threads maison. En créant un objet de travail avec CreateThreadpoolWork et en le soumettant via SubmitThreadpoolWork, les threads workers du pool exécutent le rappel (callback) en parallèle.8 La gestion du nombre de threads est laissée à l’OS, et le coût de création/destruction des threads disparaît. C’est la réponse en C au principe « ne créez pas vos threads vous-même ».

La discipline à respecter en utilisant le pool est également documentée officiellement : ne pas terminer un thread du pool avec TerminateThread / ExitThread, restaurer avant de rendre la main tout état créé dans le rappel (TLS, priorité du thread, etc.), et garder en vie les handles d’attente jusqu’à ce que le pool ait fini de s’en servir.9 Autre remarque pratique : le pool ne limite que le nombre de threads workers, et les rappels soumis via SubmitThreadpoolWork mais pas encore exécutés peuvent s’accumuler sans limite. Dans une configuration résidente où les soumissions dépassent durablement la capacité de traitement, mettez en place côté application une limitation d’entrée par sémaphore ou une file à capacité, de manière à ce que le côté soumission attende ou soit rejeté une fois pleine (c’est de la pression exercée en amont, pour ne pas transformer la surcharge en consommation mémoire — le même principe que la conception de file du chapitre 5).

4. Minimiser l’état mutable partagé — diviser, rendre en lecture seule, transmettre

La concurrence ne survient que lorsque « plusieurs threads » et « des données mutables partagées » se rencontrent. Avant même de choisir les primitives de synchronisation (chapitre suivant), demandez-vous d’abord s’il n’est pas possible de réduire ce partage. Il existe trois familles de moyens.

Diviser. Dans une agrégation parallèle, plutôt que chaque thread écrive dans un compteur partagé, constituez un sous-total dans une variable locale par thread (ou un tampon alloué par thread), et ne les fusionnez qu’une seule fois à la fin, par exemple avec InterlockedAdd. Les écritures dans le partagé passent alors de « à chaque itération » à « une fois par thread », réduisant d’un ordre de grandeur à la fois le coût de synchronisation et la fenêtre de concurrence. Expliciter, comme au 2.1, « à quel thread appartient ce tampon » devient directement le plan de la division.

Rendre en lecture seule. Les configurations et tables construites au démarrage puis jamais réécrites peuvent être lues sans danger par n’importe quel thread, une fois l’initialisation terminée. « Terminez l’initialisation avant le démarrage de tous les threads », ou, si une initialisation différée est nécessaire, utilisez l’initialisation à usage unique de Win32 (InitOnceExecuteOnce), afin que la frontière « à partir de quand cela devient lecture seule » soit explicite dans le code.3

Transmettre. Faites en sorte que le flux de données entre threads passe par une file producteur/consommateur, plutôt que par des variables partagées touchées des deux côtés. En C, l’implémentation par variable de condition du chapitre 5 (tampon circulaire fini + SleepConditionVariableCS) est directement l’exemple d’implémentation officiel, et un tampon à capacité limitée constitue aussi naturellement une pression en amont : « si la production dépasse la consommation, le producteur attend ».5

5. Choisir les objets de synchronisation et la discipline des verrous

Les primitives de synchronisation de Win32 sont nombreuses, et un mauvais choix nuit à la fois à la performance et à la correction. Voici les directives officielles résumées en un schéma.3

OuiExclusion mutuelleLimiter le nombre d'accès simultanésNotification d'événementNon (au sein d'un processus)OuiNonOuiNonSynchroniser entreplusieurs processus ?Quel usage ?Mutex nomméSémaphore nomméÉvénement nomméAcquisition récursivedu même thread nécessaire ?CRITICAL_SECTIONCode C++ orientéportabilité ?std::mutex /std::shared_mutexVerrou SRW (choix par défaut)

Figure 3 : comment choisir une primitive de synchronisation Win32. La première bifurcation est « est-ce entre plusieurs processus », et le point essentiel est de ne pas choisir un objet noyau (Mutex) quand on ne franchit pas de processus.

Primitive Portée Caractéristiques Cas d’usage
Verrou SRW Au sein d’un processus Rapide (reste normalement en mode utilisateur), taille d’un pointeur, non récursif Choix par défaut pour le nouveau code. AcquireSRWLockShared permet aussi le partage en lecture
CRITICAL_SECTION Au sein d’un processus Rapide (attente active puis attente noyau), récursif Quand une acquisition récursive du même thread est nécessaire
Mutex Au sein d’un processus / entre processus Toujours un objet noyau, donc lent Exclusion mutuelle entre processus (nommé), combinaison avec WaitForMultipleObjects
Sémaphore Au sein d’un processus / entre processus Objet noyau Limitation du nombre d’accès simultanés à un pool de ressources
Événement Au sein d’un processus / entre processus Objet noyau Notification qu’« il s’est passé quelque chose » (pas pour protéger des données)
Fonctions Interlocked Au sein d’un processus (entre processus aussi si mémoire partagée) Opérations atomiques sans verrou Compteurs, indicateurs, échange de pointeurs4

Une précision sur le tableau et le schéma. Les objets noyau tels qu’événements, sémaphores et Mutex peuvent tout à fait servir à la synchronisation au sein d’un processus, s’ils sont créés sans nom (l’événement d’arrêt du chapitre 6 est justement un événement sans nom). Objet noyau ne signifie pas « réservé exclusivement à l’inter-processus ». Et « sans nom » ne signifie pas non plus « forcément limité à un seul processus » : en faisant hériter le handle par un processus enfant, ou en le dupliquant vers un autre processus avec DuplicateHandle, un même objet noyau sans nom peut être utilisé depuis plusieurs processus. La compréhension exacte est que le nommage n’est qu’un des moyens représentatifs de permettre à plusieurs processus de rouvrir le même objet. La bifurcation de la figure 3 illustre le point essentiel « ne pas choisir un objet noyau pour un verrou au sein d’un processus » ; la notification (événement) ou la limitation du nombre simultané (sémaphore) au sein d’un processus restent, elles, correctement servies par un objet noyau sans nom.

La famille Interlocked correspond à la classe Interlocked de l’édition .NET, et à std::atomic de l’édition C++. InterlockedIncrement / InterlockedExchange / InterlockedCompareExchange opèrent de façon indivisible sur une variable unique, et comme la plupart de ces fonctions s’accompagnent d’une barrière mémoire complète, elles fournissent aussi une garantie d’ordre.4 « C’est bon puisque j’ai mis volatile » est un malentendu : volatile ne garantit ni l’atomicité ni l’ordre (voir la FAQ). Une autre condition préalable est l’alignement. La variable ciblée par une fonction Interlocked doit être alignée sur une frontière naturelle (4 octets pour une valeur 32 bits, 8 octets pour une valeur 64 bits) ; sans cela, le comportement devient imprévisible.12 Ne ciblez jamais avec Interlocked un champ d’une structure marquée #pragma pack, ni un champ situé dans un tampon qui mappe directement un format de communication. Limitez les compteurs et indicateurs à des variables déclarées normalement (c’est-à-dire alignées par le compilateur). Par ailleurs, l’échange de pointeur via InterlockedExchangePointer et consorts appelle une vigilance particulière. Seul l’échange lui-même est indivisible : personne ne garantit la durée de vie de l’ancien bloc une fois échangé. Si le lecteur charge l’ancien pointeur juste avant que l’écrivain ne l’échange puis appelle free, c’est un accès à de la mémoire libérée. Une conception qui met à jour des données partagées par échange de pointeur ne tient que couplée à un protocole de récupération — verrou, comptage de références, etc. (dans le doute, protéger avec un verrou SRW reste le choix sûr).

Pour l’attente synchronisée, on dispose des variables de condition. On les crée avec InitializeConditionVariable ; le côté consommateur s’endort avec SleepConditionVariableCS (couplé à une CRITICAL_SECTION), et le côté producteur le réveille avec WakeConditionVariable — c’est la forme de l’exemple d’implémentation officiel d’une file producteur/consommateur à tampon fini.5 Un point de discipline important : une fois réveillé, revérifiez toujours la condition (la file n’est-elle pas vide) à l’intérieur du verrou, et bouclez vers l’attente si elle est fausse. Les variables de condition connaissent des réveils intempestifs (spurious wakeups) sans notification, et un autre consommateur peut avoir déjà pris l’élément au moment du réveil ; « réveillé » ne signifie donc pas forcément « condition remplie ». C’est l’outil pour construire en C la même structure que les Channels du 4.3 de l’édition .NET, ou le BlockingQueue du chapitre 4 de l’édition C++. Si vous couplez avec un verrou SRW, utilisez SleepConditionVariableSRW.

5.1. La discipline des verrous — trois principes valables quel que soit le choix

Même en choisissant correctement la primitive, sans discipline d’utilisation, la concurrence ne peut être évitée.

  • Décider une correspondance un-à-un entre « quelles données » et « quel verrou les protège ». Associez un verrou (verrou SRW ou CRITICAL_SECTION) à chaque ensemble de données mutables à protéger, et prenez le même verrou à tous les endroits qui touchent ces données. En C, il est particulièrement efficace d’indiquer explicitement dans un commentaire d’en-tête que « cette structure est protégée par g_lockFoo ».
  • Ne rien faire de long ou d’externe pendant qu’un verrou est détenu. La seule chose autorisée pendant qu’un verrou est tenu, c’est lire et écrire les données protégées. Faire des E/S fichier, du réseau ou appeler un rappel tout en gardant le verrou allonge le temps de détention, et si le code appelé tente de prendre un autre verrou, cela crée l’attente circulaire de la figure 2.
  • Fixer l’ordre d’acquisition de plusieurs verrous. Là où deux verrous ou plus sont pris, imposez comme règle que tous les threads les prennent dans le même ordre (une hiérarchie de verrous). Le document de bonnes pratiques sur les DLL précise explicitement qu’une inversion de l’ordre (lock order inversion) produit des interblocages difficiles à déboguer, et qu’il faut définir une hiérarchie et la suivre de façon cohérente.10

6. Concevoir la façon d’arrêter — sans TerminateThread

6.1. Ce que TerminateThread détruit

TerminateThread efface le thread ciblé sans jamais lui laisser exécuter le moindre code en mode utilisateur. Les conséquences énumérées par la documentation officielle sont sévères. Si le thread ciblé détenait une section critique, elle n’est jamais libérée ; s’il était en train d’opérer sur le tas, le verrou de tas reste tenu (tous les threads appelant ensuite malloc se bloquent) ; s’il manipulait l’état global d’une DLL, cet état est corrompu. La position officielle est qu’il s’agit d’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.67

Trouver TerminateThread en enquêtant sur la cause d’une application qui « se fige parfois entièrement » est, dans la pratique, une scène vraiment fréquente. Si vous le trouvez, c’est un point à corriger.

6.2. La bonne forme : événement d’arrêt + WaitForMultipleObjects

La règle éprouvée pour un arrêt coopératif en C consiste à créer un unique événement d’arrêt à réinitialisation manuelle, et à faire attendre simultanément à chaque thread worker « le signal de travail » et « le signal d’arrêt ». La documentation de l’avertissement C6258 elle-même présente cette forme (créer un événement, chaque thread surveille l’événement avec WaitForSingleObject et se termine de lui-même) comme la bonne méthode de terminaison.7

HANDLE hStopEvent;   /* CreateEvent(NULL, TRUE, FALSE, NULL): réinitialisation manuelle */
HANDLE hWorkEvent;   /* CreateEvent(NULL, FALSE, FALSE, NULL): réinitialisation automatique.
                        revient automatiquement à non signalé dès la réception (en
                        réinitialisation manuelle, une fois signalé l'attente passerait
                        toujours au travers, créant une boucle active qui tourne 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) {          /* handle invalide, etc. Laissé tel quel, tourne à vide à plein régime */
            LogLastError();              /* consigner GetLastError() puis sortir */
            break;
        }
        if (r == WAIT_OBJECT_0)          /* demande d'arrêt */
            break;
        if (r == WAIT_OBJECT_0 + 1) {    /* du travail est disponible */
            /* Passer aussi l'événement d'arrêt à ProcessNextItem : si l'attente interne
               d'un élément est longue, ne pas pouvoir y observer l'arrêt prend
               l'arrêt en otage d'un seul élément */
            while (ProcessNextItem(hStopEvent)) {  /* traite un élément de la file. FALSE si vide */
                /* vérifier la demande d'arrêt même pendant la purge. Sans cela,
                   impossible de s'arrêter tant que du travail continue de
                   s'accumuler (famine de l'arrêt) */
                if (WaitForSingleObject(hStopEvent, 0) == WAIT_OBJECT_0)
                    break;
            }
        }
    }
    Cleanup();                           /* faire soi-même son propre nettoyage */
    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 parvenue, */
        LogLastError();                      /* il ne faut pas entrer dans une jointure sans limite */
        return FALSE;
    }
    /* la demande d'arrêt est maintenant levée pour tout le monde à la fois */
    for (DWORD i = 0; i < count; i++) {
        if (WaitForSingleObject(threads[i], INFINITE) == WAIT_OBJECT_0) {
            CloseHandle(threads[i]);         /* ne fermer que ceux dont la jointure a pu être confirmée */
        } else {
            LogLastError();                  /* WAIT_FAILED : handle invalide, etc. */
            ok = FALSE;                      /* ne pas rapporter que « tout le monde s'est arrêté » */
        }
    }
    return ok;   /* si FALSE, ne pas passer à la libération des ressources partagées */
}

Il y a une raison de faire les jointures côté arrêt avec des WaitForSingleObject un par un. Le nombre de handles que WaitForMultipleObjects peut attendre en une fois est plafonné à MAXIMUM_WAIT_OBJECTS (64) ; lui passer un tableau plus grand fait échouer l’attente elle-même avec WAIT_FAILED, et on finit par fermer les handles en croyant avoir attendu tout le monde alors qu’on n’a en fait attendu personne. Pour simplement attendre la fin de tous, une boucle un par un, sans limite, est la solution sûre.

SetEvent(hStopEvent)Le côté qui arrêteÉvénement d'arrêt(réinitialisation manuelle : visible de tous)Worker 1 :attend simultanément l'arrêt et le travailvia WaitForMultipleObjectsWorker 2 :attend simultanément l'arrêt et le travailvia WaitForMultipleObjectsse nettoie et fait return lui-mêmese nettoie et fait return lui-mêmele côté qui arrête attend les handles de thread et rejointc'est seulement ici qu'on peut dire « arrêté »

Figure 4 : le patron de l’événement d’arrêt. En utilisant un événement à réinitialisation manuelle pour l’arrêt, un seul SetEvent réveille d’un coup tous les workers en attente. Chaque thread décide lui-même de sa façon de finir, et l’arrêt n’est considéré effectif qu’une fois la jointure terminée.

Trois points sont essentiels. Faire de l’événement d’arrêt un événement à réinitialisation manuelle (un seul SetEvent le rend visible à tous les workers) ; placer l’événement d’arrêt en tête du tableau d’attente (en cas de signalement simultané, l’arrêt est prioritaire) ; et le côté qui arrête doit toujours attendre la jointure du handle de thread avant de fermer le handle.

Deux remarques sur le périmètre d’application. Premièrement, cette forme « événement + purge complète » convient à une configuration à un seul worker. Un événement à réinitialisation automatique, quel que soit le nombre de fois où on lui applique SetEvent, ne peut exprimer qu’« un état signalé » (des signaux consécutifs fusionnent), et avec plusieurs workers, un seul se réveille et traite la rafale en série. Si vous répartissez la file entre plusieurs workers, remplacez le signal de travail par un sémaphore, et incrémentez le compteur avec ReleaseSemaphore(hSem, 1, NULL) à chaque élément déposé. Comme une attente réussie sur un sémaphore en consomme le compteur d’une unité, on obtient la correspondance correcte « autant d’éléments déposés, autant de workers en attente qui se réveillent un par un » (cet usage relève du sémaphore, au même titre que « limiter le nombre d’accès simultanés à un pool de ressources » dans le tableau de la figure 3). Mais si vous passez au sémaphore, adaptez aussi le côté consommateur à la règle « une attente réussie = un seul élément traité de la file ». Si vous laissez tel quel la boucle de purge complète de l’exemple ci-dessus, une seule attente ne consomme qu’une seule permission alors qu’elle vide la file entière, et la comptabilité se dérègle : les permissions restantes réveillent d’autres workers sur une file vide, et le ReleaseSemaphore du producteur échoue par dépassement de plafond. Respecter la correspondance « une permission = une tâche » est la prémisse du schéma par sémaphore. Deuxièmement, faire passer aussi le traitement d’un seul élément par le chemin d’arrêt. Si ProcessNextItem comporte une attente bloquante longue en interne, passez-y également l’événement d’arrêt pour l’attendre en superposition, ou ajoutez un délai d’expiration fini. Se contenter de vérifier entre les éléments laisse un trou : « l’arrêt attend indéfiniment parce qu’un seul élément ne se termine pas ». C’est exactement ce que disent le StopAsync de l’édition .NET, ou jthread + join de l’édition C++.

Un thread qui attend sur une E/S bloquante (tube, socket, port série) ne peut pas venir consulter l’événement ; il faut alors soit faire aussi de l’E/S une attente superposée avec OVERLAPPED + événement, soit concevoir un réveil de l’E/S via CancelIoEx (pour un exemple concret sur la communication série, voir « Les pièges des applications de communication série »).

7. DllMain et le verrou du chargeur — le champ de mines de l’écriture de DLL

Les composants partagés écrits en C deviennent souvent des DLL, où existe une contrainte propre appelée verrou du chargeur (loader lock). DllMain est appelé par le chargeur de l’OS alors que celui-ci détient le verrou du chargeur ; faire les choses suivantes à l’intérieur provoque donc des interblocages ou des plantages.10

  • Se synchroniser avec un autre thread (acquisition de verrou, attente de la fin d’un thread)
  • Appeler LoadLibrary / FreeLibrary (directement ou indirectement)
  • Créer un thread (dangereux si cela implique une synchronisation) ou appeler ExitThread

« Attendre dans DllMain la fin d’un thread worker au moment du déchargement de la DLL » a tout l’air d’être correct, mais c’est un interblocage typique (le thread qui se termine tente de prendre le verrou du chargeur pour la livraison de DLL_THREAD_DETACH, et les deux s’attendent mutuellement). Pour une DLL qui possède des threads, publiez des fonctions d’initialisation et de terminaison explicites comme MyLib_Init / MyLib_Shutdown, et faites-y le démarrage et la jointure des threads. L’idéal pour DllMain est d’être un stub quasi vide.10

8. L’option des threads C11 — état des lieux

Si vous voulez « écrire en C portable, sans dépendre de Win32 », les options sont le <threads.h> de C11 (thrd_create / mtx_lock / cnd_wait) et <stdatomic.h>. Selon le tableau de conformité officiel, l’état du support sous MSVC est le suivant : <threads.h> est pris en charge depuis Visual Studio 2022 17.8 (nécessite /std:c11 et le SDK correspondant), tandis que <stdatomic.h> reste expérimental, à un stade nécessitant l’option /experimental:c11atomics.11

Si le partage de code avec Linux est une exigence, les threads C11 (ou un wrapper pthread) ont leur valeur, mais pour une base de code réservée à Windows, les usages Win32 présentés dans cet article ont l’avantage en termes de volume d’information, de retours d’expérience et de facilité de débogage. Quel que soit le choix, les principes de conception vus jusqu’ici (réduire le partage, faire correspondre verrous et données, arrêt coopératif) ne changent pas.

9. Vérification et débogage — se préparer en partant du principe que « ça ne se reproduira pas »

On ne peut pas espérer découvrir les bugs de concurrence par des tests. Un test ordinaire compte comme réussie une exécution « qui, par chance, n’est pas entrée en concurrence ». Pensez la préparation en trois couches.

La première ligne de défense est la conception. En revue, vérifiez sous forme de tableau « quelles sont les données mutables partagées », « quel verrou protège chacune (le tableau de correspondance du 5.1) », « l’ordre d’acquisition des verrous est-il unique », et « l’événement d’arrêt atteint-il tous les workers ». Une conception pour laquelle ce tableau ne peut pas être rempli n’est pas encore terminée, même si elle fonctionne.

Deuxièmement, rendez les anomalies observables. Plutôt qu’un INFINITE inconditionnel pour les attentes, ajoutez des délais d’expiration aux endroits clés et consignez les dépassements dans un journal : un blocage éternel se transforme ainsi en échec détectable. Sur le lieu d’un blocage, prélevez un dump, vérifiez la pile de tous les threads, et cherchez si les attentes de verrous ne forment pas un cycle mutuel. Pour les erreurs liées aux DLL, l’inspection avec Application Verifier est officiellement recommandée.10 La mise en place des dumps et journaux est traitée dans « Concevoir la conservation des journaux et des dumps en cas de plantage d’une application Windows ».

Troisièmement, secouez le système par la charge. Des tests de stress — faire tourner longtemps plus de threads que de cœurs, randomiser l’ordre de traitement, insérer des délais artificiels — sont des moyens réalistes d’augmenter, sur une machine de développement, la probabilité de « tomber » sur une concurrence. N’oubliez pas non plus les tests de reproduction sur une build de release optimisée et sous forte charge.

10. Conclusion — liste de contrôle pour le langage C

  1. Toutes les créations de threads utilisent-elles _beginthreadex (pas de CreateThread / _beginthread mélangés) ?
  2. Le handle de thread est-il fermé avec CloseHandle seulement après jointure (WaitForSingleObject) ?
  3. Ne multiplie-t-on pas les threads maison pour des tâches de courte durée (ne peut-on pas les confier à l’API de pool de threads) ?
  4. L’exclusion mutuelle au sein du processus utilise-t-elle un verrou SRW / CRITICAL_SECTION (pas de mauvais usage de Mutex) ?
  5. Les compteurs et indicateurs partagés reposent-ils sur la famille Interlocked plutôt que sur volatile ?
  6. Reste-t-il des scrutations par Sleep (ont-elles été remplacées par une variable de condition ou une attente d’événement) ?
  7. TerminateThread (l’arrêt forcé d’un autre thread) est-il totalement absent ? Les workers se terminent-ils par un return depuis la fonction de thread plutôt que par un appel à ExitThread (pour que le nettoyage du CRT s’exécute correctement via _endthreadex) ?
  8. Le chemin d’arrêt « événement d’arrêt + WaitForMultipleObjects » existe-t-il pour tous les workers, et peut-on aussi réveiller un thread en pleine E/S bloquante ?
  9. La libération des verrous et handles est-elle garantie sur tous les chemins de retour (discipline du goto cleanup) ?
  10. DllMain ne crée-t-il pas de threads, ne se synchronise-t-il pas, n’attend-il pas la fin de threads ?

Faute de soutien du langage, dans le multithreading en C, le choix des API et la discipline deviennent directement la qualité. En prenant comme défaut ce quatuor — _beginthreadex, verrou SRW, Interlocked, événement d’arrêt — même en C, on peut concevoir un système qui prend ses distances avec le « ça se fige parfois ».

Articles connexes

Domaines de conseil associés

合同会社小村ソフト (Komura Software LLC) prend en charge la revue de conception multithread de processus résidents, d’applications de pilotage d’équipements et de DLL écrits en C, l’investigation des causes (analyse de dump) de blocages et de plantages dus à TerminateThread ou à des fuites de verrous, ainsi que le conseil technique pour l’ajout de threads à du code C hérité.

Références

  1. Microsoft Learn, CreateThread function. Sur le fait qu’un thread d’un exécutable appelant le CRT doit être géré avec _beginthreadex / _endthreadex plutôt qu’avec CreateThread / ExitThread, et que si un thread créé avec CreateThread appelle le CRT, celui-ci peut mettre fin au processus en cas de mémoire faible.  2

  2. Microsoft Learn, Multithreading with C and Win32. Sur le fait qu’un programme appelant la bibliothèque CRT doit démarrer ses threads avec _beginthread / _beginthreadex et non avec le CreateThread / ExitThread de Win32 ; que la famille _beginthread initialise les variables par thread du CRT ; et que SuspendThread peut arrêter un thread en train d’accéder aux structures de données internes du CRT et provoquer un interblocage.  2

  3. Microsoft Learn, About Synchronization. Sur les directives de choix des primitives de synchronisation Win32 : le verrou SRW comme choix par défaut pour le nouveau code, de la taille d’un pointeur et restant normalement en mode utilisateur ; CRITICAL_SECTION à utiliser quand une acquisition récursive est nécessaire ; Mutex, toujours un objet noyau, à utiliser pour la synchronisation nommée entre processus et en combinaison avec WaitForMultipleObjects ; le fait qu’utiliser Mutex pour la synchronisation au sein d’un processus soit une « erreur courante » qui ralentit considérablement les opérations fréquentes ; et le fait que le sémaphore serve à limiter le nombre d’accès simultanés à un pool de ressources, l’événement à la notification.  2 3

  4. Microsoft Learn, Interlocked Variable Access. Sur le fait que les fonctions Interlocked synchronisent l’accès à une variable partagée entre plusieurs threads et effectuent l’opération de façon indivisible ; qu’InterlockedIncrement / Decrement réunissent lecture, addition et réécriture en une seule opération atomique, alors que sans synchronisation, l’incrémentation simultanée par deux threads peut en perdre une ; la famille de fonctions InterlockedExchange / InterlockedCompareExchange, etc. ; le fait qu’une variable en mémoire partagée puisse être utilisée entre threads de processus différents ; et que la plupart des fonctions Interlocked fournissent une barrière mémoire complète, avec des versions Acquire / Release permettant de choisir la sémantique d’ordre.  2 3 4

  5. Microsoft Learn, Using Condition Variables. Sur l’exemple d’implémentation d’une file producteur/consommateur par tampon circulaire fini protégé par une CRITICAL_SECTION ; la structure où l’on crée une variable de condition avec InitializeConditionVariable, le consommateur attend avec SleepConditionVariableCS et est réveillé par le producteur avec WakeConditionVariable ; et le fait que les variables de condition soient prises en charge à partir de Windows Vista.  2 3

  6. Microsoft Learn, TerminateThread function. Sur le fait que TerminateThread termine le thread ciblé sans jamais lui laisser exécuter le moindre code en mode utilisateur ; que si la cible détenait une section critique, elle n’est pas libérée ; que si elle était en train d’allouer de la mémoire sur le tas, le verrou de tas n’est pas libéré ; que l’état de kernel32 ou l’état global d’une DLL peuvent être corrompus ; et que c’est une « fonction dangereuse, à n’utiliser que dans les cas les plus extrêmes », qu’il ne faut appeler que si l’on maîtrise et contrôle entièrement le code que le thread ciblé peut exécuter.  2

  7. Microsoft Learn, Warning C6258. Sur le fait que l’avertissement d’analyse de code C6258 détecte l’usage de TerminateThread ; que TerminateThread ne permette pas un nettoyage correct du thread ; et que la bonne méthode de terminaison indiquée consiste à créer un événement avec CreateEvent, chaque thread surveillant l’état de l’événement avec WaitForSingleObject et terminant lui-même son exécution une fois l’état signalé.  2 3

  8. Microsoft Learn, CreateThreadpoolWork function. Sur le fait de créer un objet de travail avec CreateThreadpoolWork, chaque appel à SubmitThreadpoolWork faisant exécuter le rappel par un thread worker du pool ; que l’environnement d’exécution puisse être spécifié via l’environnement de rappel (TP_CALLBACK_ENVIRON) ; et que cela soit disponible à partir de Windows Vista.  2

  9. Microsoft Learn, Thread Pools. Sur le fait que le pool de threads convienne aux applications qui exécutent de façon asynchrone une grande quantité de tâches courtes ou qui créent fréquemment des threads de courte durée ; les composants de la nouvelle API de pool de threads redessinée sous Vista ; et, en bonnes pratiques, le fait de ne pas terminer un thread du pool avec TerminateThread ni appeler ExitThread depuis un rappel, de nettoyer avant de rendre la main tout état créé dans le rappel, et de garder en vie les handles d’attente jusqu’à ce que le pool ait fini de s’en servir.  2

  10. Microsoft Learn, Dynamic-Link Library Best Practices. Sur le fait que DllMain soit appelé pendant que le verrou du chargeur est détenu, ce qui impose de sévères contraintes sur les API appelables ; que se synchroniser avec un autre thread à l’intérieur de DllMain provoque des interblocages ; que l’appel à LoadLibrary soit interdit ; le schéma où, au déchargement d’une DLL, attendre dans DllMain la fin d’un thread crée un interblocage mutuel avec la livraison de DLL_THREAD_DETACH au thread qui se termine ; le fait que l’idéal pour DllMain soit un stub quasi vide, et que l’initialisation doive être différée autant que possible ; et qu’il faille définir une hiérarchie de verrous plaçant le verrou du chargeur au sommet.  2 3 4 5

  11. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. Sur le tableau de conformité des fonctionnalités de la bibliothèque standard C, indiquant que les threads C11 (threads.h) sont pris en charge depuis Visual Studio 2022 17.8, que stdatomic.h est traité comme expérimental (option /experimental:c11atomics), et que le support du compilateur pour C11 / C17 nécessite Visual Studio 2019 16.8 ou ultérieur ainsi que le SDK Windows correspondant.  2

  12. Microsoft Learn, Interlocked Variable Access. Sur le fait que la simple lecture ou écriture d’une variable 32 bits correctement alignée soit atomique, sans que la synchronisation (l’ordre) des accès soit pour autant garantie ; que la simple lecture ou écriture d’une variable 64 bits soit atomique sous Windows 64 bits mais non garantie sous Windows 32 bits ; et que pour les variables d’autres tailles, l’atomicité ne soit garantie sur aucune plateforme.  2

  13. Microsoft Learn, _beginthread, _beginthreadex. Sur les raisons pour lesquelles _beginthreadex est plus sûr que _beginthread : le fait que si un thread créé avec _beginthread se termine tôt, le handle déjà retourné devient invalide et peut pointer vers un autre thread ; que le handle de _beginthreadex doit être fermé par l’appelant avec CloseHandle, ce qui garantit sa validité ; qu’avec _beginthreadex on peut passer le handle à une API de synchronisation ; que la fonction de thread retourne un code de fin de thread selon la convention __stdcall ; et qu’il faut établir un lien vers la version multithread du CRT. 

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.

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.

Retour au blog