Algorithme d'Eisenberg et de McGuire

Cet article porte sur la version C++ 11 des exemples. Pour l'équivalent avec C++ 03, voir cet autre article.

L'article suppose que vous avez lu la présentation générale des algorithmes de synchronisation classiques, de même que Algos-Section-Critique-General.html qui explique la démarche d'ensemble, présente les risques et décrit les classes Producteur et Consommateur qui sont réinvesties ici.

Le code qui suit est une implémentation de l'algorithme d'Eisenberg et de McGuire. Elle permet la synchronisation de unités d'exécution (threads, processus) partageant une zone de mémoire, sans avoir recours à des outils de synchronisation comme ceux que l'on trouve dans les systèmes d'exploitation contemporains. Elle repose sur un système d'états (dormant, tente d'entrer dans la section critique, est dans la section critique).

Historiquement, cette solution serait la première à couvrir correctement le cas de unités d'exécution avec une contrainte de complexité aussi stricte.

Intuition générale : les unités d'exécution sont munies d'identifiants uniques. Elles passent de mode actif à dormant à en attente de l'accès à la section critique. Le droit d'accès à la section critique est obtenu sur la base d'une combinaison de ces états à un système de tours

Le principe

Le principe de cet algorithme va comme suit :

Détails spécifiques à l'algorithme d'Eisenberg et de McGuire

L'algorithme d'Eisenberg et de McGuire utilise deux états partagés, placés dans le singleton etats_synchro présenté à droite :

  • Un tableau d'états, un par unité de traitement, où l'entrée à un indice donné indique l'état actuel de l'unité de traitement correspondante; et
  • Un entier représentant un tour (à qui le tour d'entrer dans la section critique?).

Le singleton à droite encapsule les états derrière une façade de services essentiels, le tout dans une optique de lisibilité et de robustesse.

#include "Producteur.h"
#include "Consommateur.h"
#include <cassert>
#include <algorithm>
#include <iostream>
#include <string>
#include <fstream>
#include <thread>
#include <random>
using namespace std;

class etats_synchro
{
public:
   enum { NTHREADS = 2 };
private:
   enum Etat { VEUT_ENTRER, EST_DEDANS, EST_IDLE };
   Etat fanion_[NTHREADS];
   int tour_;
public:
   etats_synchro()
      : tour_{} // peu importe
   {
      fill(begin(fanion_), end(fanion_), EST_IDLE);
   }
   void veut_entrer(int n) noexcept
   {
      assert(0 <= n && n < NTHREADS);
      fanion_[n] = VEUT_ENTRER;
   }
   void entre(int n) noexcept
   {
      assert(0 <= n && n < NTHREADS);
      fanion_[n] = EST_DEDANS;
   }
   bool est_idle(int n) const noexcept
   {
      assert(0 <= n && n < NTHREADS);
      return fanion_[n] == EST_IDLE;
   }
   void devient_idle(int n) noexcept
   {
      assert(0 <= n && n < NTHREADS);
      fanion_[n] = EST_IDLE;
   }
   bool est_dedans(int n) const noexcept
   {
      assert(0 <= n && n < NTHREADS);
      return fanion_[n] == EST_DEDANS;
   }
   int tour() const noexcept
      { return tour_; }
   void tour(int n) noexcept
   {
      assert(0 <= n && n < NTHREADS);
      tour_ = n;
   }
   static int prochain(int n)
   {
      assert(0 <= n && n < NTHREADS);
      return (n + 1) % NTHREADS;
   }
};

L'algorithme est implémenté par la fonction eisenberg_mcguire(), à droite, exécutée dans un thread distinct.

Le code clé de cet algorithme est celui qui gère l'entrée dans la section jugée critique, de même que celui qui en gère la sortie. Ce code va comme suit :

  • L'unité d'exécution désireuse d'accéder à la zone critique l'indique
  • Par la suite, supposant que les autres unités d'exécution aient priorité sur elle, elle itère de manière cyclique en examinant les états des autres unités de traitement, jusqu'à ce qu'elle ait fait un tour complet sans trouver qui que ce soit d'actif
  • Elle indique ensuite qu'elle est dans la zone critique, ce qui est un mensonge, puis vérifier qu'elle y est bel et bien seule
  • Tout cela se répète tant que l'unité d'exécution active n'est pas en mesure d'affirmer qu'elle seule peut entrer dans la zone critique (donc que c'est son tour ou que tous les autres candidats sont dormants)

Une fois le travail critique terminé, l'unité active cherche à déterminer le prochain candidat à s'exécuter, puis retombe officiellement en état dormant. Il est possible, si tous les autres candidats sont dormants, que la main revienne tout de suite à l'unité de traitement active.

template <class T>
   void eisenberg_mcguire(T && oper, etats_synchro &etats, bool &fin)
   {
      auto id = oper.id;
      while (!fin)
      {
         oper.avant_partie_critique();
         int j;
         do
         {
            etats.veut_entrer(id); // j'annonce mes couleurs
            // j'attends que quelqu'un, en tournant en sens "horaire" par rapport
            // a moi, constate que tous les autres sont "idle". Ils ont priorite
            // sur moi pour le moment
            j = etats.tour();
            while (j != id)
               if (etats.est_idle(j))
                  j = etats.tour();
               else
                  j = etats_synchro::prochain(j);
            // j'annonce que je suis dans la section critique; c'est un mensonge!
            etats.entre(id);
            // je vérifie si je suis bel et bien seul dedans
            for (j = 0 ; j < etats_synchro::NTHREADS && ((j == id) || !etats.est_dedans(j)); ++j)
               ;
         // si la recherche a échoué et c'est mon tour
         } while (!( (j >= etats_synchro::NTHREADS) &&
                     ((etats.tour() == id) || (etats.est_idle(etats.tour())))));
         // youppi, c'est à moi!!!
         etats.tour(id);
         oper.partie_critique();
         // je trouve le prochain voisin qui n'est pas "idle", en sens "horaire" a partir de "tour",
         // et je décide que c'est _son_ tour. Initialement, c'est mon tour, alors si tout le monde
         // est idle, alors ca restera mon tour...
         j = etats_synchro::prochain(etats.tour());
         while (etats.est_idle(j))
            j = etats_synchro::prochain(j);
         etats.tour(j);
         etats.devient_idle(id); // J'annonce que je suis "idle"
         oper.apres_partie_critique();
      }
   }

Sans grande surprise, le programme principal lance les diverses unités de traitement souhaitées, assure leur identification correcte et leur supplée le booléen qu'elles partageront et qui jouera pour elles le rôle d'un signal de fin, puis attend la fin des threads et nettoie les ressources attribuées au préalable.

int main()
{
   etats_synchro etats;
   random_device rd;
   mt19937 prng{ rd() };
   auto gen_work = [&]() -> int {
      uniform_int_distribution<int> de(1,50);
      return de(prng);
   };
   ofstream sortie{"out.txt"};
   clog.rdbuf(sortie.rdbuf());
   bool fin = {};
   int id = {};
   string transit;
   Producteur prod{ id++, transit, gen_work };
   Consommateur cons{ id++, transit, sortie };
   thread th[] = {
      thread{[&] {
         eisenberg_mcguire(prod, etats, fin);
      }},
      thread{[&] {
         eisenberg_mcguire(cons, etats, fin);
      }}
   };
   char c; cin >> c; fin = true;
   for(auto &thr : th)
      thr.join();
   clog.rdbuf(nullptr);
}

Valid XHTML 1.0 Transitional

CSS Valide !