Partie 1
Introduction
Dans la première partie de
l'article, nous avons découvert la notion de thread et surtout
nous avons fait le tour des fonctions Win32 qui permettent de les gérer.
Dans cette seconde partie, nous allons discuter des problèmes
liés à l'exécution concurrente de plusieurs threads
au sein d'une même application. En effet ce n'est pas le tout
de créer 50 threads, encore faut il pouvoir avoir une emprise
sur la manière, l'ordre dans lequel ils s'exécutent.
Voici la liste des points que nous allons aborder dans cet article
:
L'accès simultané à une ressource globale unique
L'accès simultané à une ressource globale multiple
Un thread commence son exécution quand un précédent
se termine
Un thread commence son exécution sur base d'un événement
extérieur
L'accès
simultané à une ressource globale unique.
Au sein d'une application, tous les threads utilisent le même
espace d'adressage virtuel. En d'autres termes, et pour être clair,
deux threads provenant d'une même application peuvent partager
une ou plusieurs zones mémoires. Pratiquement une telle zone
mémoire est :
Une variable globale de l'application
Un buffer alloué dynamiquement par un appel à malloc,
new, HeapAlloc ou autre fonction d'allocation de mémoire
Lorsque deux threads accèdent une même zone mémoire,
il est possible qu'ils essaient de changer ou de lire sa valeur en même
temps. Trois cas peuvent se poser :
Deux lecteurs : les deux threads accèdent la variable en lecture.
Ce cas n'est pas très courant, néanmoins il ne pose pas
de problèmes particuliers.
Un lecteur, un écrivain : un thread est maître de la valeur
de la zone mémoire, l'autre ne fait que lire son contenu.
Deux écrivains : les deux threads sont susceptibles de modifier
la valeur de la zone mémoire.
Cas du lecteur et de l'écrivain.
Pour comprendre la situation, considérez le code suivant :
DWORD g_dwTemperature;
DWORD WINAPI Reader(LPVOID p)
{
for (;;)
{
if (g_dwTemperature <= 70)
{
// L'eau n'est pas trop chaude
LaverLinge(); // durée : 30 min
}
}
}
DWORD WINAPI Writer(LPVOID p)
{
for (;;)
{
// Toute les minutes la température de l'eau
//augmemente de 1 degré.
g_dwTemperature++ ;
Sleep(60000) ;
}
}
|
Le problème est le suivant : imaginons que au moment ou le thread
1 (Reader) est sur le point de commencer un lavage, il détecte
une température de 60°. Cette dernière est acceptable
puisque inférieure à 70°, le thread 1 lance donc la
procédure de lavage. Cette procédure dure 30 minutes.
Pendant ces 30 minutes, le thread 2 ne perd pas son temps et continue
à chauffer l'eau. Après exactement 11 minutes, la température
aura atteint 71° ce qui n'est plus une valeur acceptable pour le
linge. Malheureusement la fonction LaverLinge va encore s'exécuter
pendant 19 minutes. Pas besoin de vous faire un dessin pour vous faire
comprendre que le linge ne pourra plus être mis que par poupée
barbie ;-)
Mon exemple est un peu tiré par les cheveux je l'admet mais
il illustre bien le problème. Ce problème arrive généralement
sur des intervalles de temps beaucoup plus petit que la deemi-heure.
Le principe est que entre le moment ou un thread lit la valeur et va
l'utiliser, un autre peut l'avoir changée.
Pour solutionner ce problème, il suffit de se dire que lorsque
un lavage est en cours, on se limite à garder la température
stationnaire. Comment peut-on s'y prendre ?
La technique du jeton
C'est très simple. Nous allons créer un jeton et décréter
que pour modifier la température de l'eau de 1°, il faut
impérativement s'approprier le jeton. Evidemment l'intérêt
du jeton est que seul un thread peut en avoir la propriété
à un moment donné.
Pour obtenir le jeton, le thread doit en faire la demande. Si le jeton
est disponible (càd qu'il n'est pris par aucun autre thread),
il lui est attribué. Si le jeton n'est pas disponible, le thread
qui a fait la demande est mis en veille par le système jusqu'à
ce que le soit à nouveau disponible.
Revenons à notre exemple ! Si le thread 2 effectue un demande
du jeton avant d'augmenter la température, on peut facilement
le contrôler. En effet il suffit au thread 1 de prendre le jeton
avant de commencer à laver le linge et de le rendre à
la fin de la lessive. Ainsi le thread 2 ne pourra jamais obtenir le
jeton durant la période de lessivage. Problème résolu.
C'est bien beau tout cela mais comment j'implémente mon jeton
?
Le système Win32 propose deux implémentations d'un jeton
simple : le mutex et la section critique. Les opérations que
nous voulons exécuter avec un jeton sont les suivantes : créer,
obtenir, rendre et détruire.
Implémentation sous forme de mutex
Le tableau ci-dessous donne les fonctions Win32 associées au
mutex pour les 4 opérations :
| Opération |
Fonction Win32 |
Remarques |
| Créer |
CreateMutext |
-On peut obtenir le jeton dès sa création
-On peut nommer le jeton |
| Obtenir |
Wait functions :
- WaitForSingleObject
- ... |
Si le jeton n'est pas dispo, les fonctions wait bloquent
le thread appellant jusqu'à ce que le jeton soit libre ou
qu'il soit détruit ou encore qu'un certain délai se
soit écoulé. |
| Rendre |
ReleaseMutex |
|
| Détruire |
CloseHandle |
|
Et voici donc notre exemple modifié en conséquence :
DWORD g_dwTemperature;
HANDLE g_hMutex;
g_hMutex = CreateMutex(NULL,FALSE,NULL);
....
DWORD WINAPI Reader(LPVOID p)
{
for (;;)
{
__try
{
// Obtenir le jeton
WaitForSingleObject(g_hMutex,INFINITE) ;
if (g_dwTemperature <= 70)
{
// L'eau n'est pas trop chaude
LaverLinge(); // durée : 30 min
}
}
__finally
{
// Rendre le jeton après que la lessive soit terminée
ReleaseMutex(g_hMutex);
}
}
}
DWORD WINAPI Writer(LPVOID p)
{
for (;;)
{
// Toute les minutes la température de l'eau
// augmemente de 1 degré.
__try
{
Sleep(60000) ;
WaitForSingleObject(g_hMutex,INFINITE) ;
g_dwTemperature++ ;
}
__finally
{
ReleaseMutex(g_hMutex);
}
}
}
|
Important :
Dans l'exemple ci-dessus, outre le fait que j'ai introduit la notion
de jeton, j'ai aussi mis en place une gestion d'exception au moyen du
__try __finally. Ceci est primordial : si toutefois une exception est
généré dans la fonction LaverLinge, je rendrai
quand même le jeton proprement. Dans le cas contraire une exception
non gérée dans la fonction LaverLinge fera exploser le
thread 1 et celui-ci n'aura jamais l'occasion de rendre le jeton. Ce
qui aura pour effet que le thread 2 sera bloqué à jamais.
Implémentation sous forme de section critique.
Contrairement au mutex qui est un objet du noyau, la section critique
est une simple structure en mémoire. La finalité de la
section critique est la même que celle du mutex. Le seul intérêt
d'une section critique est qu'elle ne nécessite aucun service
du noyau, elle consomme moins de ressources systèmes et est donc
plus performante.
Elle a cependant une limitation: elle ne peut servir que à synchroniser
des threads qui font partie d'un même processus. Un mutex ,par
contre, peut se voir attribuer un nom lors de l'appel Win32 CreateMutex.
Dans ce cas, un thread résidant dans un autre processus peut
obtenir un handle sur ce même mutex en appellant CreateMutex ou
OpenMutex avec le même nom.
| Opération |
Fonction Win32 |
Remarques |
| Créer |
Déclarer une structure CRITICAL_SECTION |
-Une CS est donc une simple variable
-Cette var doit être initialisée par un appel à
InitializeCriticalSection |
| Obtenir |
EnterCriticalSection ou TryEnterCriticalSection |
|
| Rendre |
LeaveCriticalSection |
|
| Détruire |
DeleteCriticalSection |
|
Et revoici notre exemple revu et corrigé à la sauce "section
critique" :
DWORD g_dwTemperature;
CRITICAL_SECTION g_cs;
// Ne pas oublier d'initialiser la structure
InitializeCriticalSection(&g_cs);
...
DWORD WINAPI Reader(LPVOID p)
{
for (;;)
{
__try
{
EnterCriticalSection(&g_cs);
if (g_dwTemperature <= 70)
{
// L'eau n'est pas trop chaude
LaverLinge(); // durée : 30 min
}
}
__finally
{
LeaveCriticalSection(&g_cs);
}
}
}
DWORD WINAPI Writer(LPVOID p)
{
for (;;)
{
// Toute les minutes la température de l'eau
// augmemente de 1 degré.
__try
{
EnterCriticalSection(&g_cs);
g_dwTemperature++ ;
Sleep(60000) ;
}
__finally
{
LeaveCriticalSection(&g_cs);
}
}
}
|
Le cas des deux écrivains :
Le cas de deux threads qui modifient une même variable globale
pose un problème identique. Les deux threads peuvent vouloir
la mettre à jour en même temps. Dans ce cas, le dernier
à modifier la variable sera le vainqueur. Pour éviter
cette situation, même remède : un mutex ou une section
critique.
L'accès
simultané à une ressource globale multiple
Le cas d'une ressource multiple est relativement similaire au cas de
la ressource unique. Mais tout d'abord qu'est ce qu'une ressource multiple
?
Une ressource multiple est une ressource qui peut être utilisée
simultanément par N threads. Ce qu'on veut dans ce cas, c'est
que le N+1ème thread soit bloqué en attendant que un des
N premiers thread libère sa ressource.
L'exemple que j'ai choisi pour illustrer mon propos est le suivant
:
Il existe une fonction de communication appelée AcquireCommunicationCanal.
Cette dernière, comme son nom l'indique, ouvre un canal de communication
avec un serveur de données. Je sais en outre que je peux ouvrir
au maximum 3 canaux simultanément. Autre précision, AcquireCommunicationCanal
n'est pas bloquante. Si pas de canaux dispos, elle renvoie NULL.
Dans le code que je vous propose, j'ai un lot de 10 threads qui veulent
communiquer avec le serveur de données.
Voici le code erroné qu'on pourrait avoir envie d'écrire
en première instance :
for (int i=1; i<=10; i++)
{
CloseHandle(CreateThread(NULL,0,COMMFct,NULL,0,NULL));
}
. . .
DWORD WINAPI COMMFct(LPVOID pData)
{
HANDLE hCanal;
. . .
hCanal = AcquireCommunicationCanal();
if (hCanal)
SendData(hCanal,szData);
. . .
}
|
Si on trace ce code, on va vite se rendre compte que en lançant
tous les threads en pagaille, seuls trois vont obtenir un canal de communication
et les septs autres ne communiqueront pas.
La dessus, on j'en entends déjà certains : " ho
mais c'est pas grave, on va effectuer une boucle et on va appeler sans
relâche AcquireCommunicationCanal jusqu'à ce qu'on obtienne
un handle valide ". Là je réponds : " Oui mais
il y a beaucoup mieux ". En effet une pareille solution n'est ni
plus ni moins d'un système de pooling qui gaspille du temps CPU
pour pas grand-chose.
Une meilleure solution consisterait à s'assurer que seuls trois
threads soient en mesure d'appeler la fonction AcquireCommunicationCanal.
Nous voici donc de retour avec nos jetons ;-) Pour se sortir de la
situation, nous allons créer un jeton muni d'un compteur. Ce
compteur nous allons l'initialiser à trois.
Quand un thread va vouloir obtenir le jeton, le système va vérifier
le compteur. Si ce dernier est supérieur à zéro,
le thread obtiendra une instance du jeton et le compteur sera décrémenté
d'une unité. Si par contre le compteur est déjà
à zéro, le thread sera mis en attente jusqu'à ce
qu'un autre rende son jeton. Effectivement rendre un jeton, incrémente
le compteur.
Le jeton multi-instance ou sémaphore.
| Opération |
Fonction Win32 |
Remarques |
| Créer |
CreateSemaphore |
On détermine le nombre total de jetons. On
peut se réserver 1 ou plusieurs jetons d'emblée. |
| Obtenir |
Wait functions :
- WaitForSingleObject
- ..
|
|
| Rendre |
ReleaseSemaphore |
|
| Detruire |
CloseHandle |
|
Voici directement le code utilisant un sémaphore, si vous avez
compris l'utilisation du mutex, vous n'aurez aucun mal à comprendre
le code ci-dessous.
HANDLE g_hSema;
// 3 jetons et tous les trois dispo
g_hSema = CreateSemaphore(NULL,3,3,NULL);
for (int i=1; i<=10; i++)
{
CloseHandle(CreateThread(NULL,0,COMMFct,NULL,0,NULL));
}
. . .
DWORD WINAPI COMMFct(LPVOID pData)
{
HANDLE hCanal;
. . .
__try
{
// Prendre 1 jeton
WaitForSingleObject(g_hSema,INFINITE);
hCanal = AcquireCommunicationCanal();
SendData(hCanal,szData);
}
__finally
{
// Et le rendre ensuite
ReleaseSemaphore(hSema,1,NULL);
}
. . .
}
|
Dans ce cas ci, 3 threads vont obtenir un jeton (ou instance du sémaphore)
et 7 vont être bloqués. Ils se débloqueront au fur
et à mesure que les threads rendront leurs jetons. Voici une
manière relativement élégante de gérer 3
ressources pour 10 threads !
Pour la dernière partie de cet article, je vais donner deux
autres exemples courants de synchronisation de threads.
Un
thread démarre lorsqu'un autre se termine
Situation : Un thread A récolte des informations d'un port
série. Un thread B traite les données récoltées
par A.
Contrainte : Le thread B ne doit travailler que lorsque le thread
A a terminé son travail de réception.
Solution : Le thread B doit attendre que le thread A se soit
terminé avant de traiter les données. Pour détecter
la fin d'un thread, on peut utiliser le handle sur ce thread. En effet
le handle d'un thread est synchronisable, càd qu'il possède
un état signalé/non signalé. Un thread est non
signalé lorsqu'il est en cours d'exécution. Il est signalé
dès qu'il s'arrête. Evidemment cela tombe rudement bien,
parce que les api WaitForXXX me permettent de savoir lorsqu'un objet
devient signalé.
Exemple :
HANDLE h1,h2;
h1 = CreateThread(NULL,0, ThreadA,NULL,0,NULL);
// on passe le handle du thread A au thread B
h2 = CreateThread(NULL,0, ThreadB,(LPVOID)h1,0,NULL);
DWORD WINAPI ThreadA(LPVOID pdata)
{
HANDLE hCOM;
hCOM = CreateFile(
);
ReadFile(hCOM,
);
return 0;
}
DWORD WINAPI ThreadB(LPVOID pdata)
{
// On récupère le handle du thread A
HANDLE hThreadA = (HANDLE) pdata;
// Attendre que le thread A soit signalé (terminé)
WaitForSingleObject(hThreadA, INFINITE) ;
// Traiter les données
...
}
|
Un
thread démarre son traitement sur base d'un évènement
extérieur
Situation : Un thread A et un thread B désire se passer
la main régulièrement.
Contrainte : Quand un thread travaille, l'autre est bloqué
et vice et versa
Solution : Pour synchroniser les deux threads, nous allons utiliser
deux objets du noyau de type Event. Un event est un objet synchronisable
et donc un thread peut s'enquérir de son statut signalé/non-signalé.
Le gros intérêt de l'Event est que nous pouvons totalement
contrôler son statut. Win32 offre des fonctions qui permettent
de le signaler ou de ne pas le signaler.
Il existe deux types d'Event. L'Event automatique et l'Event manuel.
La finalité des deux est identique. La différence réside
dans leurs aptitudes à repasser au statut non-signalé.
Un Event automatique repasse non-signalé dès que un thread
qui était en attente est débloqué. Très
utile quand vous voulez être sûr de ne débloquer
qu'un seul thread à la fois.
Un Event manuel débloquera et laissera passer tous les threads
tant qu'il sera signalé. Pour le remettre à son statut
non-signalé, vous devrez effectuer un appel à la fonction
Win32 ResetEvent.
Voici un exemple de code qui illustre mon propos. Un thread A et un
thread B se passent la main ad vitam eternam.
HANDLE g_h1,g_h2;
// Event pour thread 1 créé directement avec statut signalé
// Ceci me permet de faire en sorte que ce soit le thread
// 1 qui commence le travail
g_Thread1GoToWork = CreateEvent(NULL,FALSE,TRUE,NULL);
// Event pour thread 2 non-signalé
g_Thread2GoToWork = CreateEvent(NULL,FALSE,FALSE,NULL);
CloseHandle(CreateThread(NULL,0, ThreadA,NULL,0,NULL));
CloseHandle(CreateThread(NULL,0, ThreadB,NULL,0,NULL));
. . .
DWORD WINAPI ThreadA(LPVOID pdata)
{
for(;;)
{
// Attendre signal du thread 2
WaitForSingleObject(g_Thread1GoToWork,INFINITE);
. .
// Faire qqch
// Ensuite donner ordre au thread2 de se réveiller
SetEvent(g_Thread2GoToWork);
}
return 0;
}
DWORD WINAPI ThreadB(LPVOID pdata)
{
for(;;)
{
// Attendre évènement du thread 1
WaitForSingleObject(g_Thread2GoToWork,INFINITE);
. . .
// Faire qqch
// Ensuite repasser la main au thread 1
SetEvent(g_Thread1GoToWork);
}
return 0;
}
|
Quelques
remarques concernant les exemples de code de cet article
1. Dans tous les appels aux fonctions WaitForSingleObject, j'ai opté
pour une attente infinie. Je l'ai fait pour la simplicité des
exemples. Ceci dit vous pouvez spécifier un timeout pour l'attente
d'un objet. Si toutefois, l'objet n'a pas été signalé
dans le temps imparti, WaitForSingleObjet renverra la valeur WAIT_OBJECT_TIMEOUT
2. Ceci m'amène naturellement à la seconde constatation.
Je n'ai jamais testé le code de retour de WaitForSingleObject.
Ce n'est pas à conseiller. Si je fais une attente infinie, je
ne risque pas d'obtenir un timeout, ceci dit il se pourrait que l'objet
sur lequel j'effectue mon attente soit détruit, dans ce cas WaitForSingleObject
me reverra un code WAIT_OBJECT_ABANDONNED. Bref testez le code retour
des fonctions WaitForXXX.
3. De manière générale, je vous conseille la lecture
du MSDN concernant les fonctions Win32 que j'ai exposées ici.
En effet cet article est bien loin de couvrir toutes les finesses de
la synchronisation.
Conclusion
Que retenir de tout cela ?
Tout d'abord que la synchronisation des threads se concrétise
dans 95% des cas par une attente sur un objet du noyau dit synchronisable.
L'attente se fait par l'appel à une fonction du genre WaitForSingleObject,
WaitForMultipleObjects, MsgWaitForMultipleObjects
Si l'objet
est non-signalé, ces fonctions sont bloquantes, si l'objet est
signalé l'attente se termine.
Ensuite connaître les différents types d'objets synchronisables
est un plus. Pour chaque type d'objet, Microsoft défini la signification
de l'état signalé/non signalé.
Voici quelques exemples :
| Type d'objet |
Statut |
Signification |
| Process |
Non signalé |
Le process est actif |
| |
Signalé
|
Le process est terminé |
| Thread |
Non signalé |
Le thread est actif |
| |
Signalé |
Le thread est terminé |
| Socket |
Non signalé |
Pas d'événement sur le socket |
| |
Signalé |
Un événement a eu lieu sur le socket
(données dispo, ...) |
| Event |
Non signalé |
C'est vous qui donnez la signification |
| |
Signalé |
C'est vous qui donnez la signification |
Laurent
Docquir