|
Multi-threading et synchronisation: seconde partieS'applique à:
- Windows CE Embedded 3.0 et 4.x
- Embedded Visual C++ 3.0
Résumé: Laurent discute des problèmes liés à l'exécution concurrente de plusieurs threads au sein d'une même application.
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
| CreateMutex | -
On peut obtenir le jeton dès sa création
-
On peut nommer le jeton
| |
Obtenir
|
Wait functions :
| -
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.
Voici le tableau des fonctions liées aux sections critiques :
| 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 par 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 :
|
| |
Rendre
| ReleaseSemaphore |
| |
Détruire
| 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 termineSituation : 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é.
L'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érieurSituation : 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-
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
- 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.
- 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 |
Sur l'auteur
Laurent a débuté sur un Sinclair ZX Spectrum à l'age de 12 ans. C'est à
ce moment qu'il a attrapé le virus de la programmation. Il a ensuite
migré sur Atari ST (une machine formidable, d'après lui). Sur ST il a
principalement fait du GFA Basic et du Lattice C.
En 1996, il a rejoint Ezos,
une société spécialisée dans les technologies Microsoft. Ses
compétences sont principalement situées en Win32, COM/DCOM. Depuis
1999, après un bref séjour chez Microsoft à Seattle, Laurent s'est
spécialisé dans le développement d'applications mobiles pour les
appareils Windows CE. En dehors du travail, il a deux passions : - Tout d'abord la famille! Sa femme et lui ont trois merveilleux petits diables: Antoine, Thomas & Simon.
- Une collection de vieux micro-ordinateurs que vous pouvez découvrir sur le net à l'adresse suivante: http://www.chez.com/ldocquir/. Si toutefois vous êtes en mesure de lui donner petit un coup de main, n'hésitez pas à le contacter.
Laurent est aussi modérateur d'un forum CodePPC dédié au développement C++ pour Windows CE.
Vous pouvez contacter Laurent aux adresses suivantes: ldocquir@hotmail.com ou ldo@ezos.com.
|
|