Introduire une séquence entre deux threads

Le code qui suit est vieux et n'est pas adapté aux réalités de la programmation depuis l'avènement de C++ 11.

Il arrive qu'on veuille rédiger un programme dans lequel coexistent plusieurs threads, et qu'on veuille qu'un thread donné ne pose une action que si un autre thread lui en a donné le signal. Pensons par exemple à un système où le traitement et l'affichage sont deux tâches séparées, où l'affichage doit être rafraîchi fréquemment, et où on ne veut pas que le thread d'affichage n'opère quelque traitement que ce soit si le thread de traitement n'en signale pas le besoin.

Ce type d'interaction se modélise avantageusement avec des événements. Le mot événement doit être pris ici au sens de l'outil de synchronisation du même nom, pas au sens d'une méthode rappelée par le thread donnant le signal.

Un exemple suit (écrit avec la notation de Microsoft Windows)...

Programme principal (version 0)
#include <windows.h>
unsigned long __stdcall ThreadA(void *);
unsigned long __stdcall ThreadB void *);
#include <cassert>
class Sync
{
   HANDLE evenement_;
   bool fin_;
public:
   // on aurait pu utiliser un événement « auto reset »
   Sync()
      : fin_{}, evenement_{CreateEvent(0, FALSE, FALSE, 0)}
   {
      assert(evenement_ != ERROR_INVALID_HANDLE);
   }
   // on pourrait utiliser un mutex commun dans attendre_evenement()
   // et dans provoquer_evenement()
   void attendre_evenement() const
   {
      WaitForSingleObject(evenement_, INFINITE);
      ResetEvent(evenement_);
   }
   void provoquer_evenement() const
      { SetEvent(evenement_); }
   bool est_termine() const noexcept
      { return fin_; }
   void terminer() noexcept
      { fin_ = true; }
   ~Sync() noexcept
      { CloseHandle(evenement_); }
};
#include <iostream>
int main()
{
   using std::cin;
   Sync sync;
   HANDLE hTh[] =
   {
      CreateThread(0, 0, ThreadA, &sync, 0, 0),
      CreateThread(0, 0, ThreadB, &sync, 0, 0)
   };
   enum { N = sizeof(hTh)/sizeof(*hTh) };
   // lire une touche avant de terminer
   char c;
   cin >> c;
   sync.terminer ();
   WaitForMultipleObjects(hTh, N, INFINITE, TRUE);
}
Thread A Thread B
// Certaines fonctions sont omises cas elles
// ne sont pas importantes pour notre propos
unsigned long __stdcall ThreadA(void *p)
{
   auto &sync = *static_cast<Sync*>(p);
   while (!sync.est_termine())
   {
      FaireTraitement(); // peu importe
      if (IlFautReafficher())
         sync.provoquer_evenement();
      Sleep(0);
   }
   // Un dernier réaffichage pour
   // débloquer le Thread B au besoin
   sync.provoquer_evenement();
   return {};
}
// Certaines fonctions sont omises cas elles
// ne sont pas importantes pour notre propos
unsigned long __stdcall ThreadB(void *p)
{
   auto &sync = *static_cast<Sync*>(p);
   while (!sync.est_termine())
   {
      // Bloquer le thread jusqu'à ce qu'il
      // faille rafraîchir l'affichage
      sync.attendre_evenement();
      RafraichirAffichage();
   }
   return {};
}

Un problème de cet exemple est qu'il exploite une mécanique non portable (celle des événements de la plateforme Microsoft Windows). En soi, ce n'est pas dramatique, mais on ne pourrait pas nécessairement le transposer à d'autres plateformes sans implémenter manuellement une équivalent local de cette mécanique (voir ici pour des exemples). De même, un produit comme Java qui se veut portable à toutes les plateformes pourrait vouloir spécifiquement éviter de se lier à des outils trop locaux.

Une stratégie plus portable pour en arriver à un effet semblable serait d'utiliser une variable pour représenter la demande de réaffichage du thread A au thread B. Par contre, on tend trop facilement à faire une erreur un peu bête, qui est d'utiliser un booléen pour assurer le passage d'un signal.

Ceci fonctionne dans les cas simples :

Programme principal (version 1a)
#include <windows.h>
unsigned long __stdcall ThreadA (void *);
unsigned long __stdcall ThreadB (void *);
class Sync
{
   bool signal_;
   bool fin_;
public:
   Sync() noexcept
      : fin_{}, signal_{}
   {
   }
   // on pourrait utiliser un mutex commun dans attendre_evenement()
   // et dans provoquer_evenement()
   void attendre_evenement() const noexcept
   {
      while(!signal_) Sleep(0);
      signal_ = false;
   }
   void provoquer_evenement() const noexcept
      { signal_ = true; }
   bool est_termine() const noexcept
      { return fin_; }
   void terminer() noexcept
      { fin_ = true; }
};
#include <iostream>
using std::cin;
int main ()
{
   Sync sync;
   HANDLE hTh[] =
   {
      CreateThread (0, 0, ThreadA, &sync, 0, 0),
      CreateThread (0, 0, ThreadB, &sync, 0, 0)
   };
   enum { NB_THREADS = sizeof(hTh)/sizeof(*hTh) };
   // lire une touche avant de terminer
   char c;
   cin >> c;
   sync.terminer();
   WaitForMultipleObjects(hTh, NB_THREADS, INFINITE, TRUE);
}
Thread A Thread B
unsigned long __stdcall ThreadA(void *p)
{
   auto &sync = *static_cast<Sync*>(p);
   while (!pSync->est_termine())
   {
      FaireTraitement(); // peu importe
      if (IlFautReafficher())
         sync.provoquer_evenement();
      Sleep(0);
   }
   // Un dernier réaffichage pour
   // débloquer le Thread B au besoin
   sync.provoquer_evenement();
   return {};
}
unsigned long __stdcall ThreadB(void *p)
{
   auto &sync = *static_cast<Sync*>(p);
   while (!sync.est_termine())
   {
      // Bloquer le thread jusqu'à ce qu'il
      // faille rafraîchir l'affichage
      sync.attendre_evenement();
      RafraichirAffichage();
   }
   return {};
}

Cette stratégie est plus gourmande en temps de traitement que celle utilisant un événement, du fait qu'elle fait une boucle qui suspend le thread appelant pour attendre l'occurrence d'un événement (avec un événement pris en charge par le système d'exploitation, le thread serait véritablement suspendu). Cependant, puisque les événements couverts plus haut impliquent des invocations de services du système d'exploitation, leur utilisation est coûteuse. Si la contention est faible entre les threads, une attente active comme celle ci-dessus donnera parfois de meilleurs résultats qu'une suspension volontaire.

En retour, on pourrait permettre au thread mis en attente d'un événement de spécifier la fréquence à laquelle la vérification serait faite, ce qui permettrait à un thread de consommer aussi peu de temps de traitement que possible. à titre d'exemple, si on estime que 20 rafraîchissements par seconde suffit, alors on peut dormir 1000/20 millisecondes dans la boucle insérée à même la méthode attendre_evenement(), ce qui est beaucoup moins coûteux que dormir 0 millisecondes.

Cette solution, de même que celle exploitant des événements pris en charge par le système d'exploitation, comporte des risques de synchronisation importants, surtout s'il est possible que plusieurs threads attendent le même événement : lequel des threads sera alors responsable de réinitialiser l'événement (ou de remettre le booléen à faux)? Se pourrait-il qu'un événement se produise pendant le sommeil d'un thread et que ce thread le manque parce qu'un autre a reconnu l'événement et l'a déjà réinitialisé?

La racine du problème est double, et tient du caractère binaire (vrai/ faux) d'une valeur booléenne et du fait que la tâche de réinitialiser l'événement ne peut être donné à l'un ou l'autre des threads, du moins pas dans le cas général. La solution au problème repose sur l'analyse de ces deux constats :

Voici donc l'idée :

L'illustration programmée de cette idée suit.

Programme principal (version 1b)
#include <windows.h>
unsigned long __stdcall ThreadA(void *);
unsigned long __stdcall ThreadB(void *);
class Sync
{
   int nb_evenements_;
   bool fin_;
public:
   Sync() noexcept
      : fin_{}, nb_evenements_{}
   {
   }
   // On change de philosophie ici, remarquez bien
   int nb_evenements() const noexcept
      { return nb_evenements_; }
   }
   void provoquer_evenement() noexcept
      { ++nb_evenements_; }
   bool est_termine() const noexcept
      { return fin_; }
   void terminer() noexcept
      { fin_ = true; }
};
#include <iostream>
using std::cin;
int main()
{
   Sync sync;
   HANDLE hTh[] =
   {
      CreateThread(0, 0, ThreadA, &sync, 0, 0),
      CreateThread(0, 0, ThreadB, &sync, 0, 0)
   };
   enum { N = sizeof(hTh)/sizeof(*hTh) };
   // lire une touche avant de terminer
   char c;
   cin >> c;
   sync.terminer();
   WaitForMultipleObjects(hTh, N, INFINITE, TRUE);
}
Thread A Thread B
unsigned long __stdcall ThreadA(void *p)
{
   auto &sync = *static_cast<Sync*>(p);
   while (!sync.est_termine())
   {
      FaireTraitement(); // peu importe
      if (IlFautReafficher())
         sync.provoquer_evenement();
      Sleep(0);
   }
   return {};
}
unsigned long __stdcall ThreadB(void *p)
{
   auto &sync = *static_cast<Sync*>(p);
   int n = sync.nb_evenements();
   while (!sync.est_termine ())
   {
      int temp = sync.nb_evenements()
      if (n != temp)
      {
         RafraichirAffichage();
         n = temp;
      }
      Sleep(1000/20); // ...
   }
   return {};
}

Remarquez que cette solution a l'élégance d'un découplage plus strict de la gestion de l'événement, et s'adapte beaucoup mieux à une stratégie où plusieurs threads consultent l'événement en question. Elle a aussi l'avantage non négligeable de ne pas nécessiter une rustine (une patch) à la fin du thread A pour débloquer le thread B s'il est en attente d'un événement (et peut donc économiser un affichage).


Valid XHTML 1.0 Transitional

CSS Valide !