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
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++;
- lire value
- ajouter 1
- réécrire value
Exemple classique :
- thread A lit 5
- thread B lit 5
- thread A écrit 6
- thread B écrit 6
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
Code: Select all
volatile LONG value = 0;
InterlockedIncrement(&value);
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
Il faut aussi connaitre les variantes adaptées :
- InterlockedIncrement64
- InterlockedDecrement64
- InterlockedCompareExchange64
- InterlockedExchange64
- InterlockedCompareExchangePointer
- InterlockedExchangePointer
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);
Code: Select all
“remplace value par newValue seulement si value == expected”
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
Code: Select all
while (InterlockedCompareExchange(&lock, 1, 0) != 0)
{
SwitchToThread();
}
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
- MemoryBarrier
- ReadBarrier
- WriteBarrier
- YieldProcessor
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
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
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);
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);
}
};
Code: Select all
{
AutoLock lock(&cs);
if (error)
return;
}
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
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”
Code: Select all
while (queue.empty())
{
Sleep(1);
}
- gaspille du CPU
- ajoute de la latence
- n’est pas précis
- ne scale pas bien
Windows fournit :
- CONDITION_VARIABLE
- InitializeConditionVariable
- SleepConditionVariableCS
- SleepConditionVariableSRW
- WakeConditionVariable
- WakeAllConditionVariable
Code: Select all
CONDITION_VARIABLE cv;
CRITICAL_SECTION cs;
InitializeConditionVariable(&cv);
InitializeCriticalSection(&cs);
Code: Select all
EnterCriticalSection(&cs);
while (queue.empty())
{
SleepConditionVariableCS(&cv, &cs, INFINITE);
}
/* la condition est vraie ici */
LeaveCriticalSection(&cs);
Code: Select all
EnterCriticalSection(&cs);
/* modifier la queue */
WakeConditionVariable(&cv);
LeaveCriticalSection(&cs);
C’est un point capital.
On n’écrit pas :
Code: Select all
if (queue.empty())
SleepConditionVariableCS(...);
Code: Select all
while (queue.empty())
SleepConditionVariableCS(...);
- 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
WakeConditionVariable contre WakeAllConditionVariable
APIs :
Code: Select all
WakeConditionVariable(&cv);
WakeAllConditionVariable(&cv);
- WakeConditionVariable réveille un seul thread en attente
- WakeAllConditionVariable réveille tous les threads en attente
Condition Variables avec SRWLOCK
Windows fournit aussi une variante pensée pour SRWLOCK :
- SleepConditionVariableSRW
- SRWLOCK partagé / exclusif
- condition variable
Code: Select all
SRWLOCK lock = SRWLOCK_INIT;
CONDITION_VARIABLE cv;
AcquireSRWLockExclusive(&lock);
while (!condition)
{
SleepConditionVariableSRW(&cv, &lock, INFINITE, 0);
}
ReleaseSRWLockExclusive(&lock);
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
Code: Select all
int value = 0;
int expected = 0;
WaitOnAddress(&value, &expected, sizeof(value), INFINITE);
/* ailleurs */
value = 1;
WakeByAddressSingle(&value);
- 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
- 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
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
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
- SYNCHRONIZATION_BARRIER
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 */
- algorithmes parallèles par phases
- rendu ou calcul en étapes
- tests synchronisés
- pipelines multi-thread simples
Code: Select all
DeleteSynchronizationBarrier(&barrier);
8) WAIT_ABANDONED : cas critique réel
Quand on attend un mutex avec WaitForSingleObject ou équivalent, on peut recevoir :
Code: Select all
WAIT_ABANDONED
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
Code: Select all
WAIT_ABANDONED = “tu as récupéré le mutex, mais les données peuvent être corrompues”
APIs de wait concernées :
- WaitForSingleObject
- WaitForMultipleObjects
- WaitForSingleObjectEx
- WaitForMultipleObjectsEx
- WAIT_OBJECT_0
- WAIT_TIMEOUT
- WAIT_FAILED
- WAIT_ABANDONED
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
- 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
10) SignalObjectAndWait : éviter une race subtile
Il existe un piège classique quand on veut faire :
- signaler un objet
- puis attendre un autre
Windows fournit donc :
- SignalObjectAndWait
Code: Select all
SignalObjectAndWait(h1, h2, INFINITE, FALSE);
Code: Select all
signaler h1
et passer en attente sur h2
comme une seule opération logique
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
Code: Select all
WaitForSingleObjectEx(handle, INFINITE, TRUE);
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
- QueueUserAPC
- on queue une APC vers un thread
- le thread doit entrer dans un état alertable pour l’exécuter en user-mode
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
Code: Select all
HANDLE hTimer = CreateWaitableTimerW(NULL, FALSE, NULL);
Code: Select all
SetWaitableTimer(hTimer, &dueTime, 0, NULL, NULL, FALSE);
WaitForSingleObject(hTimer, INFINITE);
- 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
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
- MsgWaitForMultipleObjects
- MsgWaitForMultipleObjectsEx
- PeekMessage
- GetMessage
Code: Select all
attendre des objets
tout en continuant à traiter la file de messages
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
- 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
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
- SRWLOCK
- plusieurs lecteurs simultanés
- un seul écrivain
- léger
- entièrement user-mode dans beaucoup de cas
- très adapté avec les condition variables
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
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
- WAIT_OBJECT_0
- WAIT_TIMEOUT
- WAIT_FAILED
- WAIT_ABANDONED
Pour les opérations atomiques :
- InterlockedIncrement
- InterlockedDecrement
- InterlockedExchange
- InterlockedExchangeAdd
- InterlockedCompareExchange
- InterlockedCompareExchangePointer
- InterlockedIncrement64
- InterlockedDecrement64
- InterlockedCompareExchange64
- MemoryBarrier
- WaitForSingleObject
- WaitForMultipleObjects
- WaitForSingleObjectEx
- WaitForMultipleObjectsEx
- SignalObjectAndWait
- InitializeConditionVariable
- SleepConditionVariableCS
- SleepConditionVariableSRW
- WakeConditionVariable
- WakeAllConditionVariable
- WaitOnAddress
- WakeByAddressSingle
- WakeByAddressAll
- InitializeSynchronizationBarrier
- EnterSynchronizationBarrier
- DeleteSynchronizationBarrier
- InitializeSRWLock
- AcquireSRWLockShared
- AcquireSRWLockExclusive
- TryAcquireSRWLockShared
- TryAcquireSRWLockExclusive
- ReleaseSRWLockShared
- ReleaseSRWLockExclusive
- QueueUserAPC
- SleepEx
- WaitForSingleObjectEx
- WaitForMultipleObjectsEx
- CreateWaitableTimer
- CreateWaitableTimerEx
- SetWaitableTimer
- CancelWaitableTimer
- MsgWaitForMultipleObjects
- MsgWaitForMultipleObjectsEx
- InitOnceInitialize
- InitOnceExecuteOnce
- InitOnceBeginInitialize
- InitOnceComplete
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é
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
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
A la prochaine pour un nouveau cours
