Objets autonomes

Lisez ce qui suit avec attention et avec un oeil critique, car le code de cet article contient un bogue pernicieux qui ne surviendra que sur certaines architectures matérielles (pas sur les autres).

Un thread, sous Win32 (et sous POSIX, pour les gens qui développent sous Linux), est une fonction globale respectant une signature précise. Celle de Microsoft Windows va comme suit :

unsigned long __stdcall nom_thread(void*);

Ainsi, le programme suivant affiche un '.' sur la sortie standard (à la console) à chaque seconde sous Microsoft Windows :

#include <iostream>
#include <windows.h>
unsigned long __stdcall petits_points(void *);
int main()
{
   using std::cin;
   bool fin = false;
   //
   // le thread est petits_points, qui prend en paramètre l'adresse de fin
   //
   HANDLE h = CreateThread(0, 0, petits_points, &fin, 0, 0);
   char c;
   cin >> c;
   fin = true;
   WaitForSingleObject(h, INFINITE);
   CloseHandle(h);
}
unsigned long __stdcall petits_points(void *p)
{
   using std::cout;
   bool &fin = *static_cast<bool *>(p);
   while (!fin)
   {
      cout << '.';
      Sleep(1000);
   }
   return 0;
}

On voudrait parfois qu'une méthode d'un objet soit le code d'un thread, mais c'est illégal sous Win32 car toute méthode possède un paramètre caché (this) qui indique où se trouvent les données membres de l'objet auquel elle appartient. Si on veut toutefois faire comme si on utilisait une méthode comme thread, on peut contourner cette contrainte à l'aide d'un tour de passe-passe plutôt joli.

Version 0 – classe afficheur_petits_points

Imaginons qu'on veuille utiliser une classe afficheur_petits_points, dont chaque instance sait faire correctement une tâche d'affichage de petits points.

Imaginons aussi qu'on souhaite que la gestion du thread soit pratiquement encapsulée dans chaque afficheur_petits_points, pour libérer le programme principal (main()) de toute préoccupation de multiprogrammation.

Le truc pourrait être, dans un premier temps :

On obtiendrait alors à peu près le code ci-dessous.

#include <iostream>
#include <windows.h>
unsigned long __stdcall petits_points(void *);
class afficheur_petits_points
{
   bool meurs_;
   HANDLE h;
public:
   afficheur_petits_points() noexcept
      : meurs_{}, h{INVALID_HANDLE_VALUE}
   {
   }
   void demarrer()
      { h = CreateThread(0, 0, petits_points, this, 0, 0); }
   void arreter() noexcept
   {
      meurs_ = true;
      WaitForSingleObject(h, INFINITE);
      CloseHandle(h);
   }
   void agir() const
      { std::cout << '.'; }
   bool dois_mourir() const noexcept
      { return meurs_; }
   ~afficheur_petits_points() noexcept
      { arreter(); }
};
int main()
{
   using std::cin;
   afficheur_petits_points aff;
   aff.demarrer();
   char c;
   cin >> c;
} // le destructeur arrête automatiquement le thread
unsigned long __stdcall petits_points(void *p)
{
   auto &aff = *static_cast<afficheur_petits_points *>(p);
   while (!aff.dois_mourir())
   {
      aff.agir();
      Sleep(1000);
   }
   return 0;
}

Version 1 – généraliser le modèle (classe Autonome)

Si on regarde le tout de plus près, on voit rapidement que le modèle ci-dessus a une portée plus large que celle d'afficher des petits points. Après tout :

Ayant ceci en tête, on pourrait imaginer une classe nommée Autonome, dont dériverait la classe afficheur_petits_points, qui nous permettrait un peu plus de souplesse :

Le thread deviendrait un thread générique (nommé ici thread_generique()), qui prendrait en paramètre un pointeur de Autonome et appelle sa méthode agir() tant qu'il n'est pas temps de mourir.

Ainsi, on pourrait avoir plusieurs types d'objets autonomes, ayant chacun leur propre méthode agir(). Le polymorphisme sur agir() à partir d'un pointeur de Autonome fait le reste du boulot, et le thread fait agir n'importe quel dérivé de Autonome comme si c'était l'objet lui-même qui contenait le thread.

Ceci nous donnerait, grosso modo, ceci :

#include <iostream>
#include <windows.h>
unsigned long __stdcall thread_generique(void *);
class Autonome
{
   bool meurs_;
   HANDLE h;
   int sommeil_;
public:
   Autonome(int sommeil = 0) noexcept
      : meurs_{}, sommeil_{sommeil}, h{INVALID_HANDLE_VALUE}
   {
   }
   void demarrer() noexcept
      { h = CreateThread(0, 0, thread_generique, this, 0, 0); }
   void arreter() noexcept
   {
      meurs_ = true;
      WaitForSingleObject(h, INFINITE);
      CloseHandle(h);
   }
   virtual void agir() = 0;
   bool dois_mourir() const noexcept
      { return meurs_; }
   int temps_sommeil() const noexcept
      { return sommeil_; }
   virtual ~Autonome() noexcept
      { arreter(); }
};
class afficheur_petits_points
   : public Autonome
{
public:
   afficheur_petits_points() noexcept
      : Autonome{1000}
   {
   }
   void agir()
      { std::cout << '.'; }
};
int main()
{
   using std::cin;
   afficheur_petits_points aff;
   aff.demarrer();
   char c;
   cin >> c;
} // le destructeur arrête automatiquement le thread
unsigned long __stdcall thread_generique(void *p)
{
   auto &a = *static_cast<Autonome *>(p);
   while(!a.dois_mourir())
   {
      a.agir();
      Sleep(a.temps_sommeil());
   }
   return 0;
}

Détail technique – pourquoi démarrer après la fin du constructeur?

Pourquoi ne démarrerait-on pas le thread directement dans le constructeur de Autonome? Après tout, on voudra nécessairement que le thread soit démarré aussitôt que possible, alors pourquoi ne pas le faire directement dans le constructeur?

Simple : parce qu'en général, ça va planter!

La raison tient de la structure de l'héritage dans un langage OO :

Mais voilà: si le constructeur lance le thread, les méthodes de l'objet afficheur_petits_points seront appelées, mais cet objet n'existera pas encore, n'ayant pas été construit – à ce stade, seul son parent existe!

Sachant ceci, on comprend qu'il est important que demarrer() soit appelé suite à chaque construction, mais seulement une fois la construction complétée.

Version 2 – automatiser le démarrage

Si notre but est de faire en sorte que chaque instance de afficheur_petits_points soit automatiquement démarrée une fois construite, il faut :

Voyons les deux façons de faire.

Créer dans une fonction globale

On pourrait créer une fonction nommée creer_afficheur_petits_points(), qui :

Cette fonction s'écrirait donc comme suit.

afficheur_petits_points *creer_afficheur_petits_points()
{
   auto p = new afficheur_petits_points;
   p->demarrer();
   return p;
}

Il faut toutefois, pour que cette stratégie soit efficace, que personne (outre cette fonction) ne puisse instancier un afficheur_petits_points, donc que personne ne puisse faire d'appel à new afficheur_petits_points;.

Pour y arriver, il suffit de rendre le constructeur de afficheur_petits_points privé, pour qu'on ne puisse l'appeler directement, et d'indiquer que notre fonction est une amie de la classe, ce qui lui permet d'accéder à ses membres privés – donc au constructeur.

class afficheur_petits_points
   : public Autonome
{
   //
   // ... section privée ...
   // constructeur par défaut, privé
   //
   afficheur_petits_points()
      : Autonome{1000}
   {
       // code
   }
public:
   friend afficheur_petits_points *creer_afficheur_petits_points();
   // ... reste du code ...
};

Cette solution fonctionne, mais a le caractère agaçant de donner à une fonction globale un accès complet à tous les membres privés d'un afficheur_petits_points. C'est moins grave qu'il n'y paraît, mais ça reste un irritant.

Créer dans une méthode de classe

Toujours en gardant privé le constructeur de afficheur_petits_points, on pourra aussi forcer la création d'une instance de cette classe à travers des canaux privilégiés en utilisant une méthode de classe nommée creer(), qui :

Cette méthode s'écrira comme proposé ci-dessous...

class afficheur_petits_points
   : public Autonome
{
   // ... privé, incluant le constructeur ...
public:
   // ...
   static afficheur_petits_points* creer()
   {
      auto p = new afficheur_petits_points;
      p->demarrer();
      return p;
   }
};

...ce qui est précisément le même code que celui de la fonction globale! On pourra donc créer et démarrer une instance de afficheur_petits_points en appelant la méthode afficheur_petits_points::creer().

Évidemment, il faudra éventuellement détruire explicitement (delete) l'objet qu'on aura ainsi créé, puisqu'il aura été alloué dynamiquement (avec new).

Le programme principal proposé plus haut sera maintenant comme suit.

int main()
{
   using std::cin;
   auto aff = afficheur_petits_points::creer();
   char c;
   cin >> c;
   delete aff;
}

Version 3 – automatiser la destruction

Et si on veut éviter d'oublier de détruire l'instance de afficheur_petits_points une fois le programme principal terminé?

La manière la plus simple d'y arriver est de modifier légèrement la signature de la méthode creer() de la classe afficheur_petits_points pour que celle-ci retourne un unique_ptr.

#include <memory>
class afficheur_petits_points
   : public Autonome
{
   // ... privé, incluant le constructeur ...
public:
   // ...
   static std::unique_ptr<Autonome> creer()
   {
      using std::unique_ptr;
      unique_ptr<Autonome> p (new afficheur_petits_points);
      p->demarrer();
      return p;
   }
};
// ...
int main()
{
   using std::cin;
   auto tp(afficheur_petits_points::creer());
   char c;
   cin >> c;
} // autodestruction!

Ce faisant, la variable p dans main() devient alors un objet RAII, dont le destructeur sera appelé peu importe la manière dont se terminera le programme, garantissant l'interruption de l'exécution du thread et la libération des ressources qui y sont associées.

Enrichir le modèle

Le modèle proposé ici est un modèle général, pas une solution universelle pour tous les problèmes. Ainsi, il est possible (pour ne pas dire probable!) que vous ressentiez le besoin de l'enrichir et de le compléter pour faire face aux défis d'une plateforme ou d'une technologie, et c'est absolument correct de le faire.

Un cas possible serait celui d'un objet autonome devant manipuler des données allouées thread par thread plutôt que processus par processus. Le modèle COM procède ainsi : la mécanique de COM doit être chargée pour chaque thread qui en aura besoin et ses pointeurs d'interfaces ont une existence locale au thread qui les a obtenus.

Avec le modèle simple proposé plus haut, un objet autonome interagissant avec COM pose problème :

Une solution partielle serait de traiter le premier appel à agir() dans le thread de manière différente, par exemple en utilisant un booléen qui serait initialement true, puis mis à false suite à l'invocation de agir() et qui serait testé pour charger COM. Cette approche est, toutefois, lente et boiteuse. Il est préférable de raffiner légèrement la classe Autonome et le thread thread_generique() de manière à les rendre conscients du besoin, occasionnel, de prendre en charge du code de démarrage et du code d'arrêt.

La solution, en soi, est simple: injecter des méthodes polymorphiques vides dans Autonome, nommées par exemple debut() et fin(), qui seront invoquées dans le thread générique et qui pourront être spécialisées par les divers objets autonomes.

// ...
class Autonome
{
   // ...
public:
   virtual void debut()
      { }
   virtual void fin()
      { }
   // ...
};

Dans notre exemple au sujet de COM, la méthode debut() d'un autonome utilisant des services COM invoquerait probablement CoInitializeEx(), CoCreateInstance() (ou CoCreateInstanceEx()) et QueryInterface() de manière opportune. La méthode fin(), elle, invoquerait probablement Release() et CoUninitialize().

Le thread générique, lui, invoquerait debut() avant de commencer à itérer et fin() juste avant de terminer son exécution.

Ceci assurerait une invocation dans le thread du code d'initialisation et du code de nettoyage.

// ...
unsigned long __stdcall thread_generique(void *p)
{
   auto &a = *static_cast<Autonome *>(p);
   a.debut();
   while (!a.dois_mourir())
   {
      a.agir();
      Sleep(a.temps_sommeil());
   }
   a.fin();
   return {};
}

Bogue sournois

Un bogue sournois se cache dans notre implémentation de la classe Autonome. En effet :

En pratique, cela signifie que si nous souhaitons des objets autonomes, il est essentiel que les objets réalisant la tâche à exécuter de manière asynchrone ne soient pas des enfants d'Autonome mais bien des entités qui seront prises en charge par un Autonome. En ce sens, on comprend mieux que std::thread, de C++ 11, prenne en charge une opération à exécuter qui ne soit pas un de ses dérivés : une telle implémentation ne fonctionnerait tout simplement pas.

Implémentation plus contemporaine

Une implémentation plus contemporaine du concept d'objet autonome tiendra compte de cette faille dans l'implémentation précédente, et n'implémentera pas la relation entre objet autonome et tâche réalisée par voie d'héritage puisque cette approche implique une vilaine condition de course. Nous procéderons donc plutôt par composition.

Une implémentation possible d'une classe Evenement telle que celle utilisée plus haut serait celle proposée à droite. Remarquez qu'elle repose sur l'idiome pImpl, fort utile lorsqu'il s'agit d'isoler l'interface d'une classe de son implémentation.

Ici, notre implémentation reposera sur une condition_variable de C++, mais elle aurait tout aussi pu être conçue sur la base d'outils propres à une plateforme ou l'autre.

#ifndef EVENEMENT_H
#define EVENEMENT_H
#include <memory>
#include <chrono>
class Evenement
{
public:
   class ReinitialisationAutomatique {};
   class ReinitialisationManuelle  {};
private:
   class Impl;
   std::unique_ptr<Impl> evenement_;
   static std::unique_ptr<Impl> creer(ReinitialisationAutomatique);
   static std::unique_ptr<Impl> creer(ReinitialisationManuelle);
   std::unique_ptr<Impl>& ev() volatile;
   const std::unique_ptr<Impl>& ev() const volatile;
public:
   Evenement(ReinitialisationAutomatique);
   Evenement(ReinitialisationManuelle);
   Evenement(Evenement &&autre);
   ~Evenement();
   void swap(Evenement &);
   Evenement& operator=(Evenement &&);
   bool attendre() const volatile;
   bool attendre(const std::chrono::milliseconds&) const volatile;
   void provoquer() const volatile;
   void reinitialiser() const volatile;
};
#endif

L'implémentation va un peu de soi. Notez au passage la mise en application du recours à des types en lien et place de variables.

#include "Evenement.h"
#include <algorithm>
#include <chrono>
#include <memory>
#include <cassert>
#include <condition_variable>
#include <mutex>
#include <chrono>
using namespace std;
using namespace std::chrono;
class Evenement::Impl
{
   static void reveiller_un(Impl &ev)
   {
      ev.evenement_.notify_one();
   }
   static void reveiller_tous(Impl &ev)
   {
      ev.evenement_.notify_all();
   }
   static void auto_reset(Impl &ev)
   {
      ev.provoque_ = false;
   }
   static void manu_reset(Impl &)
   {
   }
   condition_variable evenement_;
   mutex m_;
   mutable bool provoque_;
   unique_lock<mutex> verrouiller() const volatile
   {
      return unique_lock<mutex>{const_cast<Impl&>(*this).m_};
   }
   void(*pReveil)(Impl&);
   void(*pReset)(Impl&);
   Impl& raw() const volatile
      { return const_cast<Impl&>(*this); }
public:
   Impl(ReinitialisationAutomatique)
      : pReveil{reveiller_un}, pReset{auto_reset}, provoque_{}
   {
   }
   Impl(ReinitialisationManuelle)
      : pReveil{reveiller_tous}, pReset{manu_reset}, provoque_{}
   {
   }
   bool attendre() const volatile
   {
      auto verrou = verrouiller();
      raw().evenement_.wait(
         verrou, [&]() { return provoque_; } // ICI: spurious?
      );
      pReset(raw());
      return true;
   }
   bool attendre(const milliseconds &ms) const volatile
   {
      auto verrou = verrouiller();
      auto res = raw().evenement_.wait_for(
         verrou, ms, [&]() { return provoque_; }
      );
      pReset(raw());
      return res;
   }
   void provoquer() const volatile
   {
      auto verrou = verrouiller();
      provoque_ = true;
      pReveil(raw());
   }
   void reinitialiser() const volatile
   {
      auto verrou = verrouiller();
      provoque_ = {};
   }
};

auto Evenement::creer(ReinitialisationAutomatique categ) -> unique_ptr<Impl>
   { return make_unique<Impl>(categ); }
auto Evenement::creer(ReinitialisationManuelle categ) -> unique_ptr<Impl>
   { return make_unique<Impl>(categ); }

bool Evenement::attendre() const volatile
   { return ev()->attendre(); }
bool Evenement::attendre(const milliseconds &ms) const volatile
   { return ev()->attendre(ms); }
void Evenement::provoquer() const volatile
   { return ev()->provoquer(); }
void Evenement::reinitialiser() const volatile
   { return ev()->reinitialiser(); }

auto Evenement::ev() volatile -> unique_ptr<Impl> &
{
   return const_cast<Evenement*>(this)->evenement_;
}
auto Evenement::ev() const volatile -> const unique_ptr<Impl> &
{
   return const_cast<const Evenement*>(this)->evenement_;
}

Evenement::Evenement(ReinitialisationAutomatique mode)
   : evenement_{ creer(mode) }
{
}
Evenement::Evenement(ReinitialisationManuelle mode)
   : evenement_{ creer(mode) }
{
}
Evenement::Evenement(Evenement &&autre)
   : evenement_{ move(autre.evenement_) }
{
}

void Evenement::swap(Evenement &autre)
{
   using std::swap;
   swap(evenement_, autre.evenement_);
}

Evenement& Evenement::operator=(Evenement &&autre)
{
   evenement_ = move(autre.evenement_);
   autre.evenement_ = nullptr;
   return *this;
}
Evenement::~Evenement() = default;

Pour la classe Autonome en soi, nous diviserons le code en deux, soit Autonome pour la mécanique de prise en chaque et Operable à titre d'interface pour ce qui est pris en charge.

J'ai intégré à Operable que qui était (plus haut) séparé en trois classes distinctes (Acteur, Rythmique, Stoppable), mais je l'ai fait simplement par souci de simplicité, par désir de garder l'exemple court.

Un Operable en soit est inerte et abstrait.

#ifndef AUTONOME_H
#define AUTONOME_H
#include "Evenement.h"
#include <memory>
#include <chrono>
#include <vector>
#include <string>
#include <utility>
#include <atomic>
#include <functional>
class already_running {};
class could_not_start {};
struct Operable
{
   virtual void agir() = 0;
   virtual void debut() {}
   virtual void fin() {}
   virtual ~Operable() = default;
   bool doit_mourir() const noexcept
      { return meurs; }
   void attendre_fin() const volatile
      { deces_attendre(); }
   bool tester_fin() const volatile
      { return deces.attendre(0ms); } // C++14
   void demander_arret()
      { meurs = true; }
   void signaler_deces() volatile
      { deces.provoquer(); }
   void reinitialiser() volatile
      { deces.reinitialiser(); }
protected:
   Operable()
      : meurs{}, deces{Evenement::ReinitialisationManuelle{}}
   {
   }
private:
   std::atomic<bool> meurs;
   Evenement deces;
};

La classe Autonome n'aura pas d'enfants (qualifiée final). Elle prend un charge un Operable, lui associe une représentation d'un thread pour la plateforme visée (Autonome::Rep, par l'idiome pImpl), et fait le pont entre la tâche à réaliser et la mécanique sous-jacente.

class Autonome final
{
public:
   using delai_type = std::chrono::milliseconds;
   using description_type = std::pair<std::string, delai_type>;
private:
   struct Rep;
   std::unique_ptr<Rep> rep_;
   std::unique_ptr<Operable> p_;
   delai_type sommeil_;
   std::string name_;
   Autonome(std::unique_ptr<Operable>, const std::string&, delai_type);
public:
   template<class T>
      T& underlying_representation()
      {
         return static_cast<T&>(*(p_.get()));
      }
   template<class T>
      const T& underlying_representation() const
      {
         return static_cast<const T&>(*(p_.get()));
      }
   std::string name() const
      { return name_; }
   static std::shared_ptr<Autonome>
      make(std::unique_ptr<Operable>, const std::string&, delai_type);
   void debut()
      { p_->debut(); }
   void fin()
      { p_->fin(); }
   void demarrer();
   void arreter() noexcept;
   void attendre_fin() const noexcept
      { p_->attendre_fin(); }
   bool tester_fin() const noexcept
      { return p_->tester_fin(); }
   void agir()
      { p_->agir(); }
   bool doit_mourir() const noexcept
      { return p_->doit_mourir(); }
   delai_type temps_sommeil() const noexcept
      { return sommeil_; }
   void demander_arret() noexcept
      { p_->demander_arret(); }
   void signaler_deces() noexcept
      { p_->signaler_deces(); }
   virtual ~Autonome() noexcept;
   friend void attendre(const std::vector<std::shared_ptr<Autonome>>&);
};
void attendre(const Autonome &);
void clore_execution(Autonome &);
#endif

Enfin, j'ai implémenté Autonome en termes de std::thread ici, mais on aurait pu maintenir une implémentation propre à une plateforme particulière. L'important ici est qu'Autonome est un gestionnaire de thread, pas le parent d'une classe qui réaliser un traitement dans le thread.

Une approche plus contemporaine encore replacerait Operable::agir() par la prise en charge d'une fonction au choix du code client, ou qui réaliserait de l'effacement de type en générant des enfants d'Operable sur demande :

class Autonome final
{
   class Operable { /* ... */ }; // devient un détail d'implémentation
   std::unique_ptr<Operable> p_;
   // ...
   template <class F>
      struct OperableImpl
         : Operable
      {
         F f;
         OperableImpl(F f) : f{f}
            { }
         void agir() override
            { f(); }
      };
   // ...
   Autonome(std::unique_ptr<Operable>, const std::string&, delai_type);
   static std::shared_ptr<Autonome>
      make(std::unique_ptr<Operable>, const std::string&, delai_type);
public:
   template <class F>
      static std::shared_ptr<Autonome>
         make(F f, const std::string &nom, delai_type delai)
      {
         return make(std::make_unique<OperableImpl<F>>(f), nom, delai);
      }
};

Je me limiterai à indiquer ici qu'au moins, l'implémentation présentée ici n'a pas les défauts de la précédente.

#include "autonome.h"
#include <algorithm>
#include <memory>
#include <chrono>
#include <thread>
#include <memory>
#include <iostream>
#include <cstdlib>
#include <string>
#include <vector>
using namespace std;
using namespace std::chrono;
Autonome::Autonome(unique_ptr<Operable> p, const string &name, delai_type sommeil)
   : p_{move(p)}, sommeil_{sommeil}, name_{name}
{
}
shared_ptr<Autonome> Autonome::make
   (unique_ptr<Operable> obs, const string &nom, delai_type sommeil)
{
   return make_shared<Autonome>(move(obs), nom, sommeil);
}
Autonome::~Autonome() noexcept
   { arreter(); }
template <class F>
   system_clock::duration minuter(F f)
   {
      auto avant = system_clock::now();
      f();
      return system_clock::now() - avant;
   }
void ThreadGenerique(Autonome &a)
{
   try
   {
      a.debut();
      while (!a.doit_mourir())
      {
         auto ecoule = minuter([&]() { a.agir(); });
         auto dodo = a.temps_sommeil();
         auto dt = duration_cast<milliseconds>(ecoule).count();
         this_thread::sleep_for(dt < dodo ? dodo - dt : 0ms);
      }
      a.fin();
   }
   catch (...)
   {
      cerr << "Fin du thread " << a.name() << " par voie d'exception" << endl;
   }
   a.signaler_deces();
}
struct Autonome::Rep
{
   thread th;
   Rep(thread th)
      : th{move(th)}
   {
   }
   Rep(Rep && autre)
      : th{ move(autre.th) }
   {
   }
   Rep& operator=(Rep &&autre)
   {
      th = move(autre.th);
      return *this;
   }
};
void Autonome::demarrer()
{
   if (rep_) throw already_running{};
   thread th{ThreadGenerique, ref(*this)};
   rep_ = make_unique<Rep>(move(th));
   p_->reinitialiser();
}
void Autonome::arreter() noexcept
{
   if (!rep_) return;
   demander_arret();
   attendre_fin();
   rep_->th.join();
   rep_.reset();
}
void attendre(const Autonome &a)
   { a.attendre_fin(); }
void attendre(const vector<shared_ptr<Autonome>> &v)
{
   for (auto & th : v)
      attendre(*th);
}
void clore_execution(Autonome &a)
{
   a.demander_arret();
   a.attendre_fin();
}

Valid XHTML 1.0 Transitional

CSS Valide !