België en Luxemburg*
Home Microsoft Belux|Siteplan
Microsoft*
Zoeken op Microsoft.com :
|Site Francophone|Belux Home|Newsletter for developers

Multi-threading et synchronisation: seconde partie


S'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 :
  1. 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.
  2. 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.
  3. 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érationFonction Win32Remarques
Créer CreateMutex
  • 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.
Voici le tableau des fonctions liées aux sections critiques :
OpérationFonction Win32Remarques
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érationFonction Win32Remarques
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  
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 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é.
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é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'objetStatutSignification
ProcessNon-signaléLe process est actif
 SignaléLe process est terminé
ThreadNon-signaléLe thread est actif
 SignaléLe thread est terminé
SocketNon-signaléPas d'évènement sur le socket
 SignaléUn évènement a eu lieu sur le socket (données dispo ...)
EventNon-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.

©2005 Microsoft Corporation. Alle rechten voorbehouden. Gebruiksrechtenovereenkomst |Handelsmerken |Privacyverklaring
Microsoft