si tu as suivi le cours précédent, tu sais maintenant créer un thread avec CreateThread. C’est bien, mais ce n’est en réalité que le début. Le vrai problème arrive dès qu’on fait travailler plusieurs threads sur les memes données. A ce moment-là, on ne parle plus seulement d’exécution concurrente, on parle surtout de coordination. Et c’est précisément la que beaucoup de programmes deviennent fragiles.
Pourquoi la synchronisation existe
Tant qu’un thread travaille seul dans son coin, tout va bien. Le code peut rester simple. Mais dans un vrai programme, plusieurs threads finissent très souvent par partager des ressources communes. Cela peut etre une variable globale, une structure allouée sur le heap, un compteur, une file de travail, ou meme un objet plus complexe.
Dans un meme processus, les threads partagent notamment :
- la mémoire du processus
- les variables globales et statiques
- le heap
- les objets et structures accessibles par pointeur
Parfois le programme plante franchement. Parfois il ne plante pas, ce qui est encore pire, parce qu’il donne l’impression de fonctionner alors qu’il produit un état incohérent. Ce genre de bug est typiquement le plus pénible à retrouver, car il ne se reproduit pas forcément à chaque exécution.
Ce phénomène porte un nom : la race condition.
Autrement dit, la synchronisation ne sert pas à “faire joli” ni à compliquer inutilement le code. Elle sert à éviter qu’un programme multithread devienne instable, non déterministe, ou carrément faux.
Les mécanismes de synchronisation fournis par Win32
Windows propose plusieurs outils de synchronisation. Ils ne sont pas interchangeables, et surtout ils ne répondent pas tous au meme besoin. L’erreur classique du débutant consiste à croire qu’un mutex peut tout faire et qu’il suffit de le mettre partout. En pratique, il faut choisir l’outil adapté au problème.
Nous allons voir ici les mécanismes les plus courants :
- Critical Section
- Mutex
- SRW Lock
- Semaphore
- Event
La Critical Section est l’un des mécanismes les plus utilisés en Win32 lorsqu’on travaille à l’intérieur d’un seul processus. Son but est simple : empêcher plusieurs threads d’entrer en meme temps dans une portion de code qui manipule une ressource partagée.
Elle présente plusieurs caractéristiques importantes :
- elle est rapide
- elle est conçue pour de la synchronisation intra-processus
- elle n’est pas faite pour etre partagée entre plusieurs processus
Exemple :
Code: Select all
CRITICAL_SECTION cs;
InitializeCriticalSection(&cs);
EnterCriticalSection(&cs);
// section critique
sharedValue++;
LeaveCriticalSection(&cs);
DeleteCriticalSection(&cs);
En pratique, on utilise une Critical Section quand :
- tout se passe dans un seul processus
- la ressource protégée est locale à ce processus
- on cherche quelque chose de léger et rapide
Le mutex remplit une fonction proche de celle d’une Critical Section : garantir qu’un seul thread à la fois puisse accéder à une ressource protégée. Mais il est plus lourd, car il repose sur un véritable objet noyau.
Cette différence a des conséquences :
- un mutex peut etre partagé entre plusieurs processus
- il est en général plus couteux qu’une Critical Section
- il s’appuie sur un objet noyau donc sur une mécanique plus lourde
Code: Select all
HANDLE hMutex = CreateMutex(NULL, FALSE, NULL);
WaitForSingleObject(hMutex, INFINITE);
// section protégée
ReleaseMutex(hMutex);
CloseHandle(hMutex);
En revanche, il ne faut pas l’utiliser machinalement partout, car ce n’est pas l’option la plus légère.
Le SRW Lock
Le SRW Lock, pour Slim Reader/Writer Lock, est un mécanisme plus moderne. Son intérêt principal est d’autoriser plusieurs lecteurs simultanés tant qu’aucun thread n’écrit, tout en garantissant un accès exclusif lorsqu’une écriture doit avoir lieu.
Autrement dit :
- plusieurs threads peuvent lire en meme temps
- un seul thread peut écrire
- pendant l’écriture, les autres doivent attendre
Exemple :
Code: Select all
SRWLOCK lock = SRWLOCK_INIT;
// lecture
AcquireSRWLockShared(&lock);
// lire les données
ReleaseSRWLockShared(&lock);
// écriture
AcquireSRWLockExclusive(&lock);
// modifier les données
ReleaseSRWLockExclusive(&lock);
Le Semaphore
Le sémaphore n’a pas exactement le meme rôle qu’un mutex. Il ne sert pas à donner un accès exclusif à un seul thread, mais à limiter le nombre de threads pouvant accéder simultanément à une ressource donnée.
On peut le voir comme un compteur. Tant que le compteur reste supérieur à zéro, un thread peut entrer. Lorsqu’un thread quitte la ressource, il rend un “jeton”, ce qui permet à un autre d’entrer à son tour.
Exemple :
Code: Select all
HANDLE hSem = CreateSemaphore(NULL, 2, 2, NULL);
WaitForSingleObject(hSem, INFINITE);
// accès à la ressource
ReleaseSemaphore(hSem, 1, NULL);
CloseHandle(hSem);
Le sémaphore est utile dans des cas comme :
- un pool de connexions
- un nombre limité de ressources identiques
- une restriction volontaire du parallélisme
L’event joue un rôle différent. Il ne protège pas directement une ressource. Il sert à signaler qu’un état ou qu’une étape a été atteint. C’est donc un mécanisme de coordination plus que de protection.
En pratique, un thread peut signaler un event pour indiquer à un autre thread qu’il peut continuer.
Il existe deux grands modes :
- auto-reset
- manual-reset
Code: Select all
HANDLE hEvent = CreateEvent(NULL, FALSE, FALSE, NULL);
// thread A
SetEvent(hEvent);
// thread B
WaitForSingleObject(hEvent, INFINITE);
CloseHandle(hEvent);
Attendre un objet : WaitForSingleObject
WaitForSingleObject est l’une des fonctions les plus centrales de la synchronisation Win32. Elle permet de bloquer un thread jusqu’à ce qu’un objet devienne signalé.
Selon le type d’objet, cela peut vouloir dire des choses différentes. On peut attendre par exemple :
- un mutex
- un event
- un sémaphore
- un thread
Code: Select all
WaitForSingleObject(hThread, INFINITE);
Le point important à comprendre, c’est que cette fonction n’est pas limitée aux seuls verrous. Elle sert de point commun à une grande partie du modèle de synchronisation Win32.
Attendre plusieurs objets : WaitForMultipleObjects
Quand il faut attendre plusieurs objets à la fois, Windows fournit WaitForMultipleObjects. Cette fonction devient utile dès qu’un thread doit réagir à plusieurs signaux possibles, ou quand une boucle de travail doit pouvoir s’interrompre proprement selon plusieurs conditions.
Signature simplifiée :
Code: Select all
WaitForMultipleObjects(
count,
handles,
FALSE,
INFINITE
);
Code: Select all
HANDLE handles[2] = { hEvent1, hEvent2 };
WaitForMultipleObjects(2, handles, FALSE, INFINITE);
Ce type d’appel est très pratique pour :
- attendre plusieurs events
- surveiller un thread de travail et un signal d’arret
- gérer proprement plusieurs sources de réveil
La synchronisation ne pardonne pas grand chose. Le code compile, parfois tourne, puis casse uniquement sous charge ou sur une machine différente. C’est pour ça qu’il faut éviter un certain nombre d’erreurs très courantes.
Parmi les fautes classiques, on retrouve :
- créer trop de threads sans vraie nécessité
- mettre des mutex partout sans réfléchir au besoin réel
- oublier de libérer un verrou
- laisser une section critique durer trop longtemps
- mélanger plusieurs mécanismes sans stratégie claire
- performances médiocres
- blocages
- bugs aléatoires
- code difficile à raisonner et à maintenir
Le deadlock est probablement l’un des problèmes les plus classiques du code multithread. Le principe est simple à comprendre, meme si en pratique il peut etre pénible à diagnostiquer.
Imaginons ceci :
- le thread A possède la ressource 1 et attend la ressource 2
- le thread B possède la ressource 2 et attend la ressource 1
Pour réduire le risque de deadlock, quelques règles simples aident beaucoup :
- toujours acquérir plusieurs verrous dans le meme ordre
- garder les sections protégées aussi courtes que possible
- éviter de verrouiller sans avoir clairement identifié ce qu’on protège
- réduire le nombre de dépendances croisées entre threads
Ce qu’il faut retenir
Dès qu’un programme fait intervenir plusieurs threads sur des données communes, la synchronisation devient obligatoire. Sans elle, le comportement du code cesse d’etre prévisible.
Chaque mécanisme a son rôle :
- la Critical Section est rapide et adaptée à l’intra-processus
- le mutex est plus lourd mais peut servir entre processus
- le SRW Lock convient bien aux scénarios lecture fréquente / écriture rare
- le sémaphore limite le nombre d’accès simultanés
- l’event sert à signaler ou coordonner des étapes
- WaitForSingleObject permet d’attendre un objet
- WaitForMultipleObjects permet d’en attendre plusieurs
Dans les prochains cours, on pourra aller plus loin avec des cas concrets de race condition, des exemples de deadlock volontaire, leur détection, et surtout la manière d’écrire un code multithread propre au lieu d’empiler des verrous au hasard.
