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.
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;
}
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;
}
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.
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.
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.
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;
}
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.
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 {};
}
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.
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. |
|
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. |
|
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. |
|
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. |
|
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 :
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. |
|