Synchronisation des Threads Avancées

Développement système natif en c/c++ avec win32 ...

Moderator: Rick

Post Reply
Hydraxx
Site Admin
Posts: 46
Joined: Mon Jan 12, 2026 4:04 pm
Location: France
Contact:

Synchronisation des Threads Avancées

Post by Hydraxx »

Salut, :D

aujourd’hui on va continuer avec un sujet vraiment important en Win32 : la synchronisation avancée des threads.

Le cours de base sur la synchronisation t’apprend à utiliser quelques outils classiques, comme les Critical Sections, les mutex, les semaphores ou les events. C’est indispensable, mais ce n’est pas encore suffisant si tu veux comprendre ce qui se passe réellement quand plusieurs threads manipulent les memes données, ou quand tu veux écrire du code plus propre, plus rapide, plus robuste, et surtout plus proche de la manière dont Windows moderne fonctionne réellement.

Ici on va donc aller plus loin. On va parler des opérations atomiques, des compare-exchange, du rôle de la mémoire, du RAII, des condition variables, de WaitOnAddress, des barriers, des waits alertables, des APC, des waitable timers, des pièges des threads GUI, et d’une vision plus globale des dispatcher objects.

Pourquoi la synchronisation avancée est importante

Quand on commence le multithreading, on pense souvent que le vrai problème c’est “empêcher deux threads d’entrer en meme temps dans une zone”. En réalité, le sujet est plus large.

Il faut aussi comprendre :
  • ce qui est atomique et ce qui ne l’est pas
  • ce que le compilateur et le CPU peuvent réordonner
  • comment attendre proprement sans brûler du CPU
  • comment réveiller les bons threads au bon moment
  • comment éviter les deadlocks, les lost wakeups, les races subtiles
  • comment choisir entre mécanisme user-mode léger et objet noyau plus lourd
  • comment écrire du code qui se nettoie correctement meme en cas d’erreur
Autrement dit, la synchronisation avancée, ce n’est pas juste “plus de fonctions”. C’est une meilleure compréhension du modèle réel.

1) Les opérations atomiques : base absolue

Avant meme de parler de lock, il faut bien fixer une idée : une opération qui parait simple en C ou C++ n’est pas forcément atomique.

Exemple :

Code: Select all

value++;
En logique machine, cela correspond en gros à :
  • lire value
  • ajouter 1
  • réécrire value
Si deux threads font cela en meme temps sans protection, on peut perdre une mise à jour.

Exemple classique :
  • thread A lit 5
  • thread B lit 5
  • thread A écrit 6
  • thread B écrit 6
Résultat final : 6 au lieu de 7.

C’est l’un des exemples les plus simples de race condition.

Les APIs Interlocked de base

Windows fournit toute une famille d’opérations atomiques, généralement préfixées par Interlocked.

APIs très importantes :
  • InterlockedIncrement
  • InterlockedDecrement
  • InterlockedExchange
  • InterlockedExchangeAdd
  • InterlockedCompareExchange
  • InterlockedCompareExchangePointer
  • InterlockedExchangePointer
  • InterlockedAnd
  • InterlockedOr
  • InterlockedXor
  • InterlockedBitTestAndSet
  • InterlockedBitTestAndReset
Exemple simple :

Code: Select all

volatile LONG value = 0;
InterlockedIncrement(&value);
Ici, la mise à jour est atomique au sens voulu par l’API.

Pourquoi c’est puissant :
  • pas de mutex
  • pas de wait noyau
  • pas de blocage long
  • très rapide
  • utilisé partout dans Windows et dans beaucoup de code système
Interlocked sur 64 bits et pointeurs

Il faut aussi connaitre les variantes adaptées :
  • InterlockedIncrement64
  • InterlockedDecrement64
  • InterlockedCompareExchange64
  • InterlockedExchange64
  • InterlockedCompareExchangePointer
  • InterlockedExchangePointer
C’est important parce qu’en code 64 bits, un pointeur ou une valeur 64 bits ne doit pas être traité comme un simple LONG 32 bits.

2) InterlockedCompareExchange : la primitive clé

S’il fallait retenir une seule primitive atomique comme fondation conceptuelle, ce serait souvent celle-ci :

Code: Select all

InterlockedCompareExchange(&value, newValue, expected);
Traduction simple :

Code: Select all

“remplace value par newValue seulement si value == expected”
La fonction retourne en général l’ancienne valeur observée.

Autrement dit, c’est un test-et-remplacement atomique.

Pourquoi c’est fondamental :
  • on peut construire des verrous simples
  • on peut faire des structures lock-free
  • on peut publier un état sans race évidente
  • on peut implémenter des transitions atomiques d’état
Exemple conceptuel d’un lock très simplifié :

Code: Select all

while (InterlockedCompareExchange(&lock, 1, 0) != 0)
{
    SwitchToThread();
}
Ici, on tente de faire passer lock de 0 à 1. Si l’ancienne valeur n’était pas 0, quelqu’un possède déjà le verrou.

Ce n’est pas forcément le meilleur lock du monde, mais pédagogiquement c’est fondamental.

3) Atomicité ne veut pas dire tout comprendre : la mémoire compte aussi

Une erreur fréquente consiste à croire que si une opération est atomique, tout le problème est réglé. En réalité, il faut aussi penser à l’ordre de visibilité mémoire.

Le CPU et le compilateur peuvent réordonner certaines opérations si rien ne l’interdit explicitement. C’est pour cela qu’il faut connaitre des notions comme :
  • memory ordering
  • barrières mémoire
  • acquire / release
  • fences
Coté Win32, plusieurs briques sont bonnes à connaitre :
  • MemoryBarrier
  • ReadBarrier
  • WriteBarrier
  • YieldProcessor
MemoryBarrier impose une barrière mémoire plus forte. Selon le cas, les fonctions Interlocked servent déjà aussi de points de synchronisation suffisants pour certaines garanties.

Il faut retenir la règle mentale suivante :
  • l’atomicité protège une opération
  • mais la synchronisation mémoire protège aussi l’ordre dans lequel les autres threads voient les effets
volatile : attention à ne pas lui faire dire trop de choses

Dans le monde Windows / MSVC, on voit souvent volatile utilisé avec les Interlocked ou dans certains vieux codes système. Il faut être prudent : volatile n’est pas un remplacement universel des vraies primitives de synchronisation.

Il faut le voir plutot comme :
  • un indicateur de lecture / écriture spéciale pour le compilateur
  • pas un remplacement d’un mutex
  • pas une solution miracle contre toutes les races
Autrement dit, un code correct multi-thread ne repose pas simplement sur volatile.

4) RAII : éviter les bugs humains

L’un des vrais problèmes du multithreading, ce n’est pas seulement le scheduler ou les API. C’est aussi l’humain.

Exemple dangereux :

Code: Select all

EnterCriticalSection(&cs);

if (error)
    return;

// ...
LeaveCriticalSection(&cs);
Si on sort avant LeaveCriticalSection, on oublie la libération du verrou, et on peut provoquer un deadlock.

La solution idiomatique en C++ est le RAII.

Exemple simple :

Code: Select all

class AutoLock
{
    CRITICAL_SECTION* cs;

public:
    AutoLock(CRITICAL_SECTION* c) : cs(c)
    {
        EnterCriticalSection(cs);
    }

    ~AutoLock()
    {
        LeaveCriticalSection(cs);
    }
};
Utilisation :

Code: Select all

{
    AutoLock lock(&cs);

    if (error)
        return;
}
Ici, le destructeur garantit la libération du verrou.

Pourquoi c’est important :
  • évite les oublis
  • gère mieux les retours anticipés
  • gère mieux les exceptions C++
  • rend le code plus propre
Le meme raisonnement s’applique aussi à SRWLOCK, HANDLE, HKEY, ou d’autres ressources.

5) Condition Variables : attendre sans brûler le CPU

Il existe un problème classique : un thread a besoin d’attendre qu’une condition logique devienne vraie.

Exemple :

Code: Select all

“la file n’est plus vide”
La mauvaise solution serait de faire une boucle active ou semi-active :

Code: Select all

while (queue.empty())
{
    Sleep(1);
}
Pourquoi c’est mauvais :
  • gaspille du CPU
  • ajoute de la latence
  • n’est pas précis
  • ne scale pas bien
La bonne solution, c’est une condition variable.

Windows fournit :
  • CONDITION_VARIABLE
  • InitializeConditionVariable
  • SleepConditionVariableCS
  • SleepConditionVariableSRW
  • WakeConditionVariable
  • WakeAllConditionVariable
Exemple de principe avec Critical Section :

Code: Select all

CONDITION_VARIABLE cv;
CRITICAL_SECTION cs;

InitializeConditionVariable(&cv);
InitializeCriticalSection(&cs);
Attente :

Code: Select all

EnterCriticalSection(&cs);

while (queue.empty())
{
    SleepConditionVariableCS(&cv, &cs, INFINITE);
}

/* la condition est vraie ici */

LeaveCriticalSection(&cs);
Réveil :

Code: Select all

EnterCriticalSection(&cs);

/* modifier la queue */

WakeConditionVariable(&cv);

LeaveCriticalSection(&cs);
Pourquoi le while est obligatoire

C’est un point capital.

On n’écrit pas :

Code: Select all

if (queue.empty())
    SleepConditionVariableCS(...);
mais bien :

Code: Select all

while (queue.empty())
    SleepConditionVariableCS(...);
Pourquoi :
  • un réveil peut survenir alors que la condition a déjà été consommée par un autre thread
  • le thread réveillé doit toujours revalider la condition logique
  • la vraie règle est : on attend la condition, pas simplement le signal
C’est l’un des points les plus importants du modèle des condition variables.

WakeConditionVariable contre WakeAllConditionVariable

APIs :

Code: Select all

WakeConditionVariable(&cv);
WakeAllConditionVariable(&cv);
Différence :
  • WakeConditionVariable réveille un seul thread en attente
  • WakeAllConditionVariable réveille tous les threads en attente
WakeAll peut être utile quand un changement d’état global concerne potentiellement tous les waiters. Mais il faut faire attention : réveiller tout le monde inutilement peut créer du bruit et de la contention.

Condition Variables avec SRWLOCK

Windows fournit aussi une variante pensée pour SRWLOCK :
  • SleepConditionVariableSRW
C’est utile quand on combine :
  • SRWLOCK partagé / exclusif
  • condition variable
Exemple typique :

Code: Select all

SRWLOCK lock = SRWLOCK_INIT;
CONDITION_VARIABLE cv;

AcquireSRWLockExclusive(&lock);

while (!condition)
{
    SleepConditionVariableSRW(&cv, &lock, INFINITE, 0);
}

ReleaseSRWLockExclusive(&lock);
Il faut bien comprendre le mode pris sur le SRWLOCK selon le cas.

6) WaitOnAddress : mécanisme très léger et très puissant

On arrive maintenant à une primitive plus “expert”.

WaitOnAddress permet d’attendre directement sur une valeur mémoire en user-mode, sans objet noyau dédié. C’est extrêmement intéressant pour certaines synchronisations légères.

APIs importantes :
  • WaitOnAddress
  • WakeByAddressSingle
  • WakeByAddressAll
Exemple simple :

Code: Select all

int value = 0;
int expected = 0;

WaitOnAddress(&value, &expected, sizeof(value), INFINITE);

/* ailleurs */
value = 1;
WakeByAddressSingle(&value);
Pourquoi c’est puissant :
  • pas d’objet noyau à créer
  • très faible coût
  • très bon pour construire des mécanismes légers
  • utilisé en interne par Windows pour certaines primitives
Mais il faut très bien comprendre son modèle :
  • on attend tant que la mémoire correspond à une valeur attendue
  • quand la valeur change, on réévalue
  • il faut souvent le combiner avec des opérations atomiques
Pattern classique : CAS + WaitOnAddress

Un pattern moderne typique ressemble à :
  • on tente une transition d’état avec InterlockedCompareExchange
  • si ça échoue et qu’on doit attendre, on utilise WaitOnAddress
  • quand l’état change, le producteur appelle WakeByAddressSingle ou All
Ce genre de pattern est très important dans les mécanismes légers modernes.

7) Synchronization Barriers

Autre besoin classique : plusieurs threads doivent tous arriver à un meme point avant que l’un d’eux, ou eux tous, puissent continuer.

C’est exactement le rôle d’une barrière.

APIs importantes :
  • InitializeSynchronizationBarrier
  • EnterSynchronizationBarrier
  • DeleteSynchronizationBarrier
Structure importante :
  • SYNCHRONIZATION_BARRIER
Exemple de principe :

Code: Select all

SYNCHRONIZATION_BARRIER barrier;

InitializeSynchronizationBarrier(&barrier, 4, -1);

/* dans chaque thread */
EnterSynchronizationBarrier(&barrier, 0);

/* tous continuent après que les 4 soient arrivés */
Utilité typique :
  • algorithmes parallèles par phases
  • rendu ou calcul en étapes
  • tests synchronisés
  • pipelines multi-thread simples
Quand on a fini :

Code: Select all

DeleteSynchronizationBarrier(&barrier);
Il ne faut pas l’oublier.

8) WAIT_ABANDONED : cas critique réel

Quand on attend un mutex avec WaitForSingleObject ou équivalent, on peut recevoir :

Code: Select all

WAIT_ABANDONED
Ce cas arrive quand un thread meurt sans avoir libéré le mutex qu’il détenait.

La signification est très importante :
  • le mutex est libéré pour que le système puisse continuer
  • mais l’état logique des données protégées peut être incohérent
Autrement dit :

Code: Select all

WAIT_ABANDONED = “tu as récupéré le mutex, mais les données peuvent être corrompues”
Il ne faut donc jamais traiter cela comme un simple succès banal.

APIs de wait concernées :
  • WaitForSingleObject
  • WaitForMultipleObjects
  • WaitForSingleObjectEx
  • WaitForMultipleObjectsEx
Constantes importantes :
  • WAIT_OBJECT_0
  • WAIT_TIMEOUT
  • WAIT_FAILED
  • WAIT_ABANDONED
9) Dispatcher Objects : vision globale

Une chose très importante à comprendre en Windows, c’est que beaucoup d’objets de synchronisation ou d’exécution appartiennent à une meme grande famille logique : les dispatcher objects du noyau.

Dans cette logique, on retrouve notamment :
  • mutex
  • event
  • semaphore
  • thread
  • process
  • waitable timer
Pourquoi c’est important :
  • ils partagent une logique commune de signalé / non signalé
  • ils peuvent être attendus avec WaitForSingleObject ou WaitForMultipleObjects
  • cela donne au modèle de synchronisation Windows une forte cohérence
C’est une idée très importante si tu veux lire Windows Internals ou comprendre le noyau plus profondément.

10) SignalObjectAndWait : éviter une race subtile

Il existe un piège classique quand on veut faire :
  • signaler un objet
  • puis attendre un autre
Si on le fait en deux appels séparés, une fenêtre de course peut exister entre les deux opérations.

Windows fournit donc :
  • SignalObjectAndWait
Exemple :

Code: Select all

SignalObjectAndWait(h1, h2, INFINITE, FALSE);
Idée :

Code: Select all

signaler h1
et passer en attente sur h2
comme une seule opération logique
C’est très utile dans certains protocoles de rendez-vous entre threads.

11) Waits alertables et APC

Un thread Windows peut attendre de manière normale… ou de manière alertable.

APIs importantes :
  • SleepEx
  • WaitForSingleObjectEx
  • WaitForMultipleObjectsEx
  • SignalObjectAndWait
Exemple :

Code: Select all

WaitForSingleObjectEx(handle, INFINITE, TRUE);
Le TRUE signifie ici : wait alertable.

Pourquoi c’est important :
  • un thread en wait alertable peut exécuter des APC user-mode
  • certaines I/O asynchrones ou certains mécanismes système s’appuient dessus
API liée très importante :
  • QueueUserAPC
Le concept :
  • on queue une APC vers un thread
  • le thread doit entrer dans un état alertable pour l’exécuter en user-mode
C’est un sujet important en système, I/O avancée et aussi en sécurité.

12) Waitable Timers

Sleep est simple, mais il a des limites. Parfois on veut un vrai objet temporel que l’on peut attendre comme n’importe quel autre dispatcher object.

APIs importantes :
  • CreateWaitableTimer
  • CreateWaitableTimerEx
  • SetWaitableTimer
  • CancelWaitableTimer
  • OpenWaitableTimer
Exemple d’idée :

Code: Select all

HANDLE hTimer = CreateWaitableTimerW(NULL, FALSE, NULL);
Puis :

Code: Select all

SetWaitableTimer(hTimer, &dueTime, 0, NULL, NULL, FALSE);
WaitForSingleObject(hTimer, INFINITE);
Pourquoi c’est mieux qu’un simple Sleep dans certains cas :
  • c’est un vrai objet de wait
  • on peut le combiner avec WaitForMultipleObjects
  • on peut l’intégrer dans un protocole de synchronisation plus riche
  • c’est plus flexible
13) GUI threads : piège important

Les threads GUI ont une boucle de messages. Si on les bloque naïvement sur un wait classique, on peut geler toute l’interface.

Le problème :
  • le thread GUI ne pompe plus ses messages
  • la fenêtre ne répond plus
  • l’application parait figée
Dans ce cas, il faut souvent utiliser :
  • MsgWaitForMultipleObjects
  • MsgWaitForMultipleObjectsEx
  • PeekMessage
  • GetMessage
L’idée est :

Code: Select all

attendre des objets
tout en continuant à traiter la file de messages
C’est un point essentiel si tu touches à des threads GUI en Win32 natif.

14) Spin, yield et attente active

Toutes les attentes ne passent pas immédiatement par des waits noyau. Dans certains scénarios de très courte contention, on peut utiliser des attentes actives très brèves.

APIs / mécanismes utiles à connaitre :
  • YieldProcessor
  • SwitchToThread
  • Sleep(0)
  • InitializeCriticalSectionAndSpinCount
Idée générale :
  • si l’attente est probablement très courte, un peu de spin peut coûter moins cher qu’un passage noyau
  • si l’attente devient longue, il faut bloquer proprement
Le spin n’est donc pas “mauvais” en soi. Il est utile dans certains contextes très ciblés. Mais le spin aveugle est une catastrophe.

15) SRW Locks : verrou moderne léger

Même si tu as déjà vu les SRW Locks, il faut les replacer ici dans la synchronisation avancée.

APIs importantes :
  • InitializeSRWLock
  • AcquireSRWLockShared
  • AcquireSRWLockExclusive
  • TryAcquireSRWLockShared
  • TryAcquireSRWLockExclusive
  • ReleaseSRWLockShared
  • ReleaseSRWLockExclusive
Structure :
  • SRWLOCK
Pourquoi c’est intéressant :
  • plusieurs lecteurs simultanés
  • un seul écrivain
  • léger
  • entièrement user-mode dans beaucoup de cas
  • très adapté avec les condition variables
C’est une primitive importante du Win32 moderne.

16) One-Time Initialization

Un autre problème de synchronisation avancée, moins visible mais très fréquent, est l’initialisation unique thread-safe.

Windows fournit :
  • INIT_ONCE
  • InitOnceInitialize
  • InitOnceExecuteOnce
  • InitOnceBeginInitialize
  • InitOnceComplete
Cela permet de remplacer proprement beaucoup de patterns “if (!initialized) { lock; if (!initialized) init; }” qui sont souvent mal écrits.

C’est une primitive très utile pour le code système ou les bibliothèques.

17) Structures et types à connaitre absolument

Pour ce chapitre, il faut reconnaitre rapidement :
  • CRITICAL_SECTION
  • SRWLOCK
  • CONDITION_VARIABLE
  • SYNCHRONIZATION_BARRIER
  • INIT_ONCE
  • OVERLAPPED
  • FILETIME
  • HANDLE
  • LONG
  • LONG64
  • LONGLONG
Et coté résultats de wait :
  • WAIT_OBJECT_0
  • WAIT_TIMEOUT
  • WAIT_FAILED
  • WAIT_ABANDONED
18) APIs à connaitre absolument

Pour les opérations atomiques :
  • InterlockedIncrement
  • InterlockedDecrement
  • InterlockedExchange
  • InterlockedExchangeAdd
  • InterlockedCompareExchange
  • InterlockedCompareExchangePointer
  • InterlockedIncrement64
  • InterlockedDecrement64
  • InterlockedCompareExchange64
  • MemoryBarrier
Pour les waits et dispatcher objects :
  • WaitForSingleObject
  • WaitForMultipleObjects
  • WaitForSingleObjectEx
  • WaitForMultipleObjectsEx
  • SignalObjectAndWait
Pour les condition variables :
  • InitializeConditionVariable
  • SleepConditionVariableCS
  • SleepConditionVariableSRW
  • WakeConditionVariable
  • WakeAllConditionVariable
Pour WaitOnAddress :
  • WaitOnAddress
  • WakeByAddressSingle
  • WakeByAddressAll
Pour les barriers :
  • InitializeSynchronizationBarrier
  • EnterSynchronizationBarrier
  • DeleteSynchronizationBarrier
Pour SRWLOCK :
  • InitializeSRWLock
  • AcquireSRWLockShared
  • AcquireSRWLockExclusive
  • TryAcquireSRWLockShared
  • TryAcquireSRWLockExclusive
  • ReleaseSRWLockShared
  • ReleaseSRWLockExclusive
Pour les waits alertables et APC :
  • QueueUserAPC
  • SleepEx
  • WaitForSingleObjectEx
  • WaitForMultipleObjectsEx
Pour les timers :
  • CreateWaitableTimer
  • CreateWaitableTimerEx
  • SetWaitableTimer
  • CancelWaitableTimer
Pour GUI :
  • MsgWaitForMultipleObjects
  • MsgWaitForMultipleObjectsEx
Pour l’initialisation unique :
  • InitOnceInitialize
  • InitOnceExecuteOnce
  • InitOnceBeginInitialize
  • InitOnceComplete
19) Erreurs classiques

Comme souvent en synchronisation, les erreurs les plus graves viennent d’un mauvais modèle mental.

Parmi les fautes fréquentes :
  • penser que value++ est “forcément sûr”
  • utiliser volatile comme si c’était un lock
  • oublier de libérer un verrou sur un chemin d’erreur
  • remplacer une vraie attente par Sleep dans une boucle
  • utiliser if au lieu de while avec une condition variable
  • ignorer WAIT_ABANDONED
  • faire des waits bloquants dans un thread GUI
  • confondre atomicité et ordre mémoire
  • abuser du spin là où un vrai wait serait plus adapté
20) Ce qu’il faut retenir

La synchronisation avancée en Win32 repose sur plusieurs idées clés :
  • les opérations atomiques sont la base absolue
  • InterlockedCompareExchange est une primitive conceptuelle majeure
  • le RAII réduit énormément les bugs humains
  • les condition variables permettent d’attendre proprement une condition logique
  • WaitOnAddress apporte une attente très légère et moderne
  • les barriers synchronisent plusieurs threads à une phase commune
  • WAIT_ABANDONED doit toujours être pris au sérieux
  • les dispatcher objects donnent une vision unifiée des waits Windows
  • les waits alertables et les APC comptent beaucoup en système
  • les waitable timers et les mécanismes GUI ont leurs propres pièges
Conclusion

Le cours de base t’apprend à utiliser quelques outils de synchronisation. Ce complément t’apprend à comprendre le modèle plus profondément, à choisir des primitives plus adaptées, et à raisonner comme un développeur système plutôt que comme quelqu’un qui “essaie des locks jusqu’à ce que ça marche”.

Quand tu maitrises vraiment tout ça :
  • tu évites beaucoup de bugs invisibles
  • tu comprends mieux les performances
  • tu écris un code multithread plus propre
  • tu es beaucoup plus proche du vrai niveau système Windows
Et surtout, tu commences à voir que la synchronisation n’est pas juste une collection d’API, mais une vraie discipline de raisonnement sur l’exécution concurrente.

A la prochaine pour un nouveau cours 8-)

Who is online

Users browsing this forum: No registered users and 1 guest