Synchronisation poussée et mot clé volatile

Ce document abordera des considérations poussées quant à la synchronisation dans un cadre multiprogrammé et quant aux impacts de certaines approches OO. Nous y verrons comment produire du code multiprogrammé plus robuste avec C++, puis la section Lectures complémentaires proposera d'autres pistes, y compris pour d'autres langages de programmation.

Ce que vous trouverez dans cet article est un usage critiqué, même entre experts. C'est une technique que j'aime bien, que j'utilise, mais le mot clé volatile est mal défini et mal compris; pour cette raison, je vous invite à la prudence. J'ai développé une partie de l'argumentaire ci-dessous sur la base d'un texte controversé d'Andrei Alexandrescu : http://www.ddj.com/dept/cpp/184403766

Multiprogrammation, concurrence et temporaires

Ce qui suit était vrai avec C++ 03, mais je ne l'ai pas validé avec C++ 11.

Imaginons un programme C++ relativement simple où en plus du thread principal, deux threads accèdent en concurrence à une donnée partagée. Pour y aller d'un exemple concret, la donnée sera une chaîne de caractères standard (std::string) encapsulée dans une instance d'une classe que nous nommerons zone_transit. Schématiquement, cela nous donne quelque chose comme ce qui est proposé à droite.

Remarquez qu'un simple canal comme zone_transit est d'une utilité limitée, mais on pourrait utiliser une instance de zone_transit comme lieu pour transformer du texte (le mettre en majuscules, par exemple, ou lui insérer des balises HTML).

Ce code est propre, et permet à la plupart des programmes d'utiliser une instance de zone_transit pour encapsuler certains accès primitifs en écriture à une chaîne de caractères.

zone_transit.h
#include "Mutex.h"
#include <string>
class zone_transit {
public:
   using value_type = std::string;
private:
   value_type texte;
   Mutex m;
public:
   value_type extraire() {
      Autoverrou av{m};
      value_type temp;
      temp.swap(texte);
      return temp;
   }
   void ajouter(const value_type &val) {
      Autoverrou av{m};
      texte += s;
   }
};

Examinons maintenant deux variantes d'une même situation d'utilisation de zone_transit :

ExempleOk.cpp
#include "zone_transit.h"
#include <string>
#include <atomic>
#include <algorithm>
#include <fstream>
#include <iostream>
#include <windows.h>
using namespace std;
unsigned long __stdcall lecteur(void *);
unsigned long __stdcall afficheur(void *);
class ObjetPartage {
   atomic<bool> fin_lect;
   zone_transit tampon_;
   string fich;
public:
   ObjetPartage(const string &nom) : fin_lect{}, fich{nom} {
   }
   zone_transit& tampon() noexcept {
      return tampon_;
   }
   void fini() {
      fin_lect = true;
   }
   bool est_fini() const noexcept {
      return fin_lect.load();
   }
   const string& nom_fichier() const {
      return fich;
   }
};
int main() {
   ObjetPartage op("in.txt");
   HANDLE h[] = {
      CreateThread(0, 0, lecteur, &op, 0, 0),
      CreateThread(0, 0, afficheur, &op, 0, 0)
   };
   enum { N = sizeof(h)/sizeof(*h) };
   WaitForMultipleObjects(N, h, TRUE, INFINITE);
   for_each(begin(h), end(h), CloseHandle);
}
unsigned long __stdcall lecteur(void *p) {
   auto &op = *static_cast<ObjetPartage *>(p);
   auto &tampon = op.tampon();
   ifstream ifs{op.nom_fichier()};
   for (string s; getline(ifs, s); ) {
      s += '\n';
      tampon.ajouter(s);
      Sleep(0);
   }
   op.fini();
   return {};
}
unsigned long __stdcall afficheur(void *p) {
   auto &op = *static_cast<ObjetPartage *>(p);
   auto &tampon = op.tampon();
   while (!op.est_fini()) {
      auto s = tampon.extraire();
      if (!s.empty()) cout << s;
      Sleep(0);
   }
   return {};
}

Le code de ExempleOk.cpp est sans danger. Cela dit, examinez le programme suivant :

ExempleDanger.cpp
#include "zone_transit.h"
#include <string>
#include <atomic>
#include <algorithm>
#include <fstream>
#include <iostream>
#include <windows.h>
unsigned long __stdcall lecteur(void *);
unsigned long __stdcall afficheur(void *);
using namespace std;
class ObjetPartage {
   atomic<bool> fin_lect;
   zone_transit tampon_;
   string fich;
public:
   ObjetPartage(const string &nom) : fin_lect{}, fich{nom} {
   }
   zone_transit& tampon() noexcept {
      return tampon_;
   }
   void fini() noexcept {
      fin_lect = true;
   }
   bool est_fini() const {
      return fin_lect.load();
   }
   const string& nom_fichier() const {
      return fich;
   }
};
int main() {
   ObjetPartage op("in.txt");
   HANDLE h[] = {
      CreateThread(0, 0, lecteur, &op, 0, 0),
      CreateThread(0, 0, afficheur, &op, 0, 0)
   };
   enum { N = sizeof(h)/sizeof(*h) };
   WaitForMultipleObjects(N, h, TRUE, INFINITE);
   for_each(begin(h), end(h) CloseHandle);
}
unsigned long __stdcall Lecteur(void *p) {
   auto &op = *static_cast<ObjetPartage *>(p);
   auto &tampon = op.tampon();
   ifstream ifs{op.nom_fichier()};
   for (string s; getline(ifs, s); ) {
      tampon.ajouter(s);
      tampon.ajouter("\n");
      Sleep(0);
   }
   op.fini();
   return {};
}
unsigned long __stdcall afficheur(void *p) {
   auto &op = *static_cast<ObjetPartage *>(p);
   auto &tampon = op.tampon();
   while (!op.est_fini()) {
      auto s = tampon.extraire();
      if (!s.empty()) cout << s;
      Sleep(0);
   }
   return {};
}

Le changement est extrêmement mineur, du moins en apparence. Pourtant, ExempleDanger.cpp plantera fréquemment mais irrégulièrement sur de gros fichiers in.txt. Évidemment, la question est pourquoi?

Une subtilité plutôt... subtile

Il faut comprendre ce que provoque l'exécution de l'opération ajouter("\n"); d'une instance de zone_transit pour comprendre la mécanique qui mène à notre problème de plantage à l'exécution.

Le littéral "\n" est de type const char*. La classe zone_transit n'offre pas de méthode nommée ajouter() prenant en paramètre un const char*, mais propose par contre une méthode ajouter() prenant en paramètre un const std::string&. De plus, le type std::string propose un constructeur prenant en paramètre un const char*. Ne reste plus au moteur d'inférence de types de C++ qu'à faire le raccord et à solliciter automatiquement le constructeur en question pour générer une variable temporaire du type requis (const std::string&) à partir du type d'origine (const char*). C'est, en fait, tellement normal en apparence, tellement inscrit dans nos habitudes qu'on tend à oublier à quel point cette mécanique est... magique.

Mais qui dit magie dit danger. En effet, la création un peu spontanée d'une temporaire entraîne une question importante: quand, exactement, la temporaire sera-t-elle créée? Et jusqu'à quand, exactement, existera-t-elle? Pris de loin, ou pris dans une situation monprogrammée, il n'y a pas de réelle différence entre les deux cas. Cela dit, dans une situation multiprogrammée comme la nôtre, l'une des deux versions a pour impact d'entraîner des effets de bord excessivement pervers.

Illustrons le tout en simulant à la main ce que fait le compilateur. Pour y arriver, imaginons que la méthode ajouter() de zone_transit prenne en paramètre un const char*, et que la std::string soit une variable locale à la méthode.

Pour que l'effet soit représentatif, insérons un bloc anonyme, donc une paire { } qui n'est pas rattachée à une structure de contrôle comme les sont les alternatives (if) et les répétitives (while).

Le rôle de ce bloc sera de délimiter la portée de l'instance de Autoverrou nommée av dans la méthode d'une manière semblable à ce qui résulterait du code réellement généré par le compilateur.

Selon toute probabilité, le code proposé devrait vous sembler encore inoffensif. En effet, la variable texte est celle qui, dans une instance de zone_transit, est protégée par le mutex m, et celle-ci n'est plus accédée suite à l'accolade fermante du bloc anonyme inséré dans la méthode.

Évidemment, il y a un piège...

#include "Mutex.h"
#include <string>
class zone_transit {
public:
   using value_type = std::string;
private:
   value_type texte;
   Mutex m;
public:
   value_type extraire() {
      Autoverrou av{m};
      value_type temp;
      texte.swap(temp);
      return temp;
   }
   //
   // Version simulant manuellement ce que fait le compilateur
   //
   void ajouter(const char *txt) {
      //
      // création d'une temporaire
      //
      auto s = txt;
      {
         //
         // Bloc anonyme servant à simuler le début de la méthode
         // (normalement, l'accolade de la méthode sert à cette fin).
         // J'insère un bloc ici pour bien montrer la portée de
         // l'Autoverrou av ci-dessous.
         //
         Autoverrou av{m};
         texte += s;
      } // fin de la portée de l'Autoverrou av
   } // fin de la portée de la chaîne s
};

Quel est le piège?

Pour comprendre le piège, il faut savoir que le type std::string a deux particularités, qu'il partage avec la plupart des types de STL, une partie importante de la bibliothèque standard de C++ :

On dit d'un objet qui est conscient des contraintes propres à la multiprogrammation et qui offre des services sécurisés en conséquence qu'il est Thread-Safe.

On dit d'un objet qui n'est pas conscient des contraintes propres à la multiprogrammation et qui n'offre pas de services sécurisés en conséquence qu'il est Thread Oblivious.

Prenez quelques instants pour examiner la classe chaine dans le document sur les classes imbriquées à l'adresse http://h-deb.clg.qc.ca/Sujets/Divers--cplusplus/CPP--Classes-imbriquees.html

La classe std::string utilise, pour fins d'optimisation, un schème de représentation interne partageable (le Copy of Write) dans la même lignée que la classe chaine::chaineRep présentée sur cette page. Prenez soin d'assimiler cette information. C'est une bonne technique OO d'optimisation dans un programme monoprogrammé, mais pour un cas multiprogrammé comme celui-ci, elle est extrêmement dangereuse.

En résumé, cette technique va comme suit :

Revenons au code de ajouter() et de extraire(), ci-dessus, en nous souvenant que la version de ajouter() ici a été modifiée à la main pour simuler le comportement du code généré par le compilateur et dans le but d'éclaircir le propos :

La conséquence d'une telle situation est que deux threads se retrouveront à modifier en même temps et à l'insu l'un de l'autre une donnée complexe – un objet contenant une séquence de caractères – et l'un, changeant des attributs comme par exemple la taille de la chaîne ou le pointeur menant vers les données – risque de corrompre l'autre. Le destructeur de la temporaire et les opérations entreprises par extraire() seront nos coupables.

Comment s'en sortir?

Comment s'en sortir, alors? Plusieurs stratégies s'offrent à nous.

L'une est de s'en remettre au client – au sous-programme appelant – et d'espérer que celui-ci n'exploite pas du tout le moteur d'inférence de types, donc qu'il crée lui-même une temporaire avant l'appel de la méthode.

Cela fonctionne, mais ne respecte pas la philosophie OO selon laquelle l'objet est responsable de sa propre intégrité: en procédant ainsi, tout client malicieux pourrait briser notre objet zone_transit.

unsigned long __stdcall lecteur(void *p) {
   // ...using...
   auto &op= *static_cast<ObjetPartage *>(p);
   auto &tampon = op.tampon();
   ifstream ifs{op.nom_fichier()};
   for (string s; getline(ifs, s)); ) {
      tampon.ajouter(s);
      static const string NOUVELLE_LIGNE = "\n";
      tampon.ajouter(NOUVELLE_LIGNE);
      Sleep(0);
   }
   op.fini();
   return {};
}

Une autre est de modifier manuellement la variable temporaire pour forcer la déconnexion entre celle-ci et l'attribut texte. Cette stratégie a le défaut de forcer un changement de la signature de notre méthode (si nous devons modifier la donnée passée en paramètre, alors celle-ci ne peut plus être spécifiée const).

class zone_transit {
   // ...
   using value_type = std::string;
   // ...
   void ajouter(value_type s) {
      Autoverrou av{m};
      texte += s;
      s.clear();
   }
};

Une troisième est de procéder au transfert des données de la source (le paramètre) vers la destination (l'attribut) de manière à empêcher la chaîne de procéder intelligement, donc en forçant la copie des données.

C'est plus lent que le comportement attendu d'une std::string optimisée, évidemment, mais dans le cas qui nous intéresse, ce comportement sera plus près des attentes.

class zone_transit {
   // ...
   using value_type = std::string;
   // ...
   void ajouter(const value_type &s) {
      // ...using...
      Autoverrou av{m};
      copy(begin(s), end(s), back_inserter(texte));
   }
};

Laisser le compilateur nous assister – objets qualifiés volatile

La plupart des compilateurs modernes sont capables de réaliser des optimisations très agressives[2].

Par exemple, dans le cas d'un sous-programme comme celui proposé à droite, un compilateur pourrait réaliser que la variable fin n'est jamais modifiée, en saisir la valeur initiale et la considérer comme constante pour la durée de la fonction, ce qui pourrait avoir comme impact de générer une boucle infinie.

int compter_secondes(atomic<bool> &fin) {
   int ntours = 0;
   while (!fin) {
      ++ntours;
      Sleep(1000); // approx. une seconde (pas archi précis)
   }
   return ntours;
}

Mais si cette variable est utilisée dans un programme multiprogrammé, comme celui ci-dessous, cette optimisation (qu'on souhaiterait, dans la plupart des cas) peut transformer le sens du programme et nuire à son bon fonctionnement!

#include <iostream>
#include <atomic>
#include <windows.h>
// ... using ... compter_secondes(), omis pour sauver de l'espace ...
unsigned long __stdcall compter(void *p) {
   auto &fin = *static_cast<atomic<bool>*> (p);
   int nsec = compter_secondes(fin);
   cout << "Temps écoulé: " << nsec << " secondes" << endl;
   return {};
}
int main() {
   atomic<bool> fin { false };
   HANDLE h = CreateThread(0, 0, compter, &fin, 0, 0);
   Sleep(10000); // ou lire une touche, ou...
   // la variable fin est modifiée dans main() mais consultée dans
   // compter_secondes(), qui est appelée par le thread compter()
   fin = true;
   WaitForSingleObject(h, INFINITE);
   CloseHandle(h);
}

La multiprogrammation entraîne son lot de contraintes, mais on ne saurait s'en passer dans le monde de l'informatique contemporaine. En même temps, il ne faudrait pas omettre des techniques d'optimisation avantageuses dans le monde de la monoprogrammation (et utiles dans la plupart des cas même en situation de multiprogrammation!) pour éviter des erreurs qui ne surviendront que sur certaines variables bien précises.

Il serait à peine plus complexe – et combien plus efficace! – de demander aux programmeurs de modules multiprogrammés d'identifier clairement, dans leurs programmes, les variables pour lesquelles il est essentiel que le compilateur ne procède à aucune optimisation porteuse de risque; les variables si volatiles qu'on doit présumer que leur état peut changer sans avertissement à cause de l'action de forces extérieures[3].

Les langages de programmation qui proposent un support à la multiprogrammation, à plus forte partie les langages dérivés syntaxiquement du langage C, comme le sont C++, Java et C#, permettent d'identifier ces variables par un mot clé.

En C, C++, Java et C#, le mot clé en question est volatile. Prudence toutefois, car le sens de ce mot varie selon les langages.

Utiliser des données spécifiées volatile

Nous allons explorer l'utilisation de ce mot, d'abord par un exemple simple (celui proposé plus haut, qui pourrait aisément s'écrire en C, en Java ou en C#), puis avec des techniques OO plus complexes mais aussi plus riches.

C++ sera notre langage pour explorer plus à fond le concept de volatile et ses applications, puisque c'est le seul langage d'entre ceux mentionnés plus haut qui offre un support complet pour l'encapsulation – ce qui, nous le verrons rapidement, sera une nécessité pour bien présenter certaines de nos approches.

Cas banal – empêcher des optimisations trop agressives

Reprenons le cas du petit programme multiprogrammé proposé plus haut, et voyons comment faire pour indiquer au compilateur de ne pas procéder à une optimisation présumant de la valeur du booléen servant à contrôler la fin du comptage des secondes.

La variable booléenne déclarée dans main() et utilisée par la fonction compter_secondes() est celle qui doit être ciblée par la spécification volatile.

En effet, on veut éviter que le compilateur génère du code pour la fonction compter_secondes() qui omettrait de réévaluer la valeur du paramètre fin à chaque itération de la répétitive tant que qui y est logée, sur la présomption (erronée dans ce cas-ci, mais le compilateur n'en sait rien a priori) que cette variable ne changera pas de valeur dans cette fonction puisque aucune opération dans la fonction ne la modifie.

On veut aussi éviter que le compilateur fasse quelque remplacement que ce soit dans main() qui risquerait de faire en sorte que main() et compter_secondes() ne travaillent pas exactement sur la même donnée.

Notez la présence de la conversion explicite const_cast<bool*> dans l'appel à CreateThread(). Elle est essentielle, et moins étrange qu'il n'y paraît à première vue. Nous y reviendrons sous peu.

Notez que volatile pour une variable partagée entre deux threads n'est pas à propos, le mot volatile appliqué à un objet ne représentant pas un mécanisme de synchronisation en soi. Préférez atomic<bool> à volatile bool ici.

#include <windows.h>
#include <iostream>
int compter_secondes(volatile bool &fin) {
   int nb_tours = 0;
   while (!fin) {
      ++nb_tours;
      Sleep(1000); // une seconde
   }
   return nb_tours;
}
unsigned long __stdcall Compter(void *p) {
   using namespace std;
   bool &fin = *static_cast<bool*>(p);
   int nb_sec = compter_secondes(fin);
   cout << "Temps écoulé: " << nb_sec << " secondes" << endl;
   return {};
}
int main() {
   volatile bool fin = false;
   HANDLE hTh = CreateThread(0, 0, Compter, const_cast<bool*>(&fin), 0, 0);
   Sleep(10000); // ...
   fin = true;
   WaitForSingleObject(hTh, INFINITE);
   CloseHandle(hTh);
}

D'une manière plus OO, on pourrait s'imaginer une classe Synchro, encapsulant un booléen et offrant deux méthodes :

  • attendre() qui ferait une attente active d'un événement représenté par l'incrémentation d'un entier, et
  • evenement() qui incrémenterait l'entier en question.

Ce n'est pas tant l'instance de Synchro partagée entre les deux threads proposés à droite qui y est volatile, mais bien son attribut cpt. En effet, c'est lui qui, dans la méthode attendre(), risque de disparaître pour cause d'optimisation massive et dont la possible disparition nuirait au bon fonctionnement du programme.

En déclarant l'attribut cpt de type volatile int plutôt que int, on indique au compilateur qu'il ne faut jamais procéder à une optimisation agressive sur cette variable. À l'intérieur d'une instance de Synchro, des optimisations apportées au code utilisant cet attribut pourraient avoir des effets secondaires indésirables.

class Synchro {
  volatile int cpt;
public:
   Synchro() noexcept : cpt{} {
   }
   void attendre() const noexcept {
      for (int avant = cpt; avant == cpt; Sleep(10))
         ;
   }
   void evenement() noexcept
      { ++cpt; }
};
unsigned long __stdcall mise_veille(void *p) {
   auto &sync = *static_cast<Synchro *>(p);
   sync.attendre();
   return {};
}
#include <iostream>
unsigned long __stdcall lire_touche(void *p) {
   using std::cin;
   auto &sync = *static_cast<Synchro *>(p);
   char c;
   cin >> c;
   sync.evenement();
   return {};
}
#include <windows.h>
#include <algorithm>
int main() {
   using namespace std;
   Synchro sync;
   HANDLE h[] = {
      CreateThread(0,0,mise_veille, &sync,0,0),
      CreateThread(0,0,lire_touche, &sync,0,0)
   };
   enum { N = sizeof(h) / sizeof(*h) };
   WaitForMultipleObjects(N, h, TRUE, INFINITE);
   for_each(begin(h), end(h), CloseHandle);
}

Parenthèse – retour sur const et const_cast

Avant de poursuivre, faisons un bref retour sur les usages de const et de const_cast. Cette brève révision nous permettra de mieux comprendre les enjeux de volatile et de comprendre pourquoi on aura aussi besoin de const_cast dans certaines applications pratiques impliquant de la multiprogrammation.

Lorsqu'un objet est qualifié const, le compilateur s'assure que le programme ne puisse réaliser aucune opération sujette à le modifier. Les opérations considérés comme pouvant modifier un objet incluent :

  • Lui affecter une valeur (s'il s'agit d'un type primitif ou si l'opérateur = défini sur lui est celui proposé par défaut[4])
  • Prendre son adresse dans un pointeur qui n'est pas lui même constant, donc par lequel on pourrait indirectement le modifier
  • Prendre une référence non constante sur lui, et
  • Appeler toute méthode de cet objet qui n'est pas elle-même qualifiée const[5], donc qui ne garantit pas que l'objet demeurera inchangé de par l'exécution de ladite méthode

On se souviendra aussi qu'il arrive (trop souvent, hélas) qu'un objet ou un sous-programme ait été mal construit du point de vue de l'encapsulation, négligeant de qualifier const un paramètre à un sous-programme qui aurait dû l'être – n'étant en rien modifié dans ledit sous-programme – ou négligeant de qualifier const une méthode qui aurait dû l'être.

struct X {
   void f();
   void g() const;
};
void f0(int *);
void f1(int &);
void f2(int);
void g0(const int*);
void g1(const int&);
int main() {
   const int VAL = 3;
   int *p = &VAL; // ILLÉGAL
   int &r = VAL;  // ILLÉGAL
   int i = VAL;   // LÉGAL
   f0(&VAL);      // ILLÉGAL
   f1(VAL);       // ILLÉGAL
   f2(VAL);       // LÉGAL
   VAL = 4;       // ILLÉGAL
   g0(&VAL);      // LÉGAL
   g1(VAL);       // LÉGAL
   const X x;
   x.f();         // ILLÉGAL
   x.g();         // LÉGAL

Ainsi, dans la mesure où le niveau de confiance est élevé (ou mieux: s'il y a certitude) qu'une opération ne modifiera pas un objet spécifié const mais a néanmoins négligé de le spécifier correctement, ce qui nous empêche d'y avoir recours, il est possible de réduire momentanément le niveau de protection qu'accorde le compilateur à l'objet en le faisant passer pour non const.

Il y a lieu de remarquer que la conversion suggérée ici est const_cast<int&>(VAL), pas const_cast<int>(VAL).

Je me permets aussi de vous indiquer que les constantes entières peuvent être « brûlées » dans le code généré par le compilateur en tant que littéraux, donc que prendre l'adresse d'une telle constante n'est pas un geste recommandable (un bon cas de faites ce que je dis, pas ce que je fais).

// ...
   // présomption: f1() est
   // inoffensive pour son
   // paramètre, malgré son
   // prototype peu rigoureux
   f1(const_cast<int&>(VAL));
}

La maxime selon laquelle si on peut qualifier quelque chose de const, alors on devrait sans doute le faire est souvent, très souvent, des plus valables. Reste qu'il faut être pragmatique et faire avec les cas où il y a eu négligence.

En effet, l'objectif de la conversion est de retirer la spécification const de VAL pour être capable de réaliser l'appel à f1() tout en passant VAL en paramètre. Nous ne souhaitons pas faire une copie de VAL – pour ce faire, utiliser une variable temporaire de type int aurait suffi, après tout!

Il n'y aurait, manifestement, pas de différence entre ceci...

int temp = VAL;
f(temp);

...et cela...

f(const_cast<int>(VAL));

...puisque, dans les deux cas, il faudrait générer un int temporaire. Ainsi, une conversion const_cast n'a de sens que sur des pointeurs ou des références.

#include <string>
#include <iostream>
//
// f() est bénigne pour s, mais on a
// négligé de spécifier s « const »
//
void f(std::string &s) {
   std::cout << s;
}
int main() {
   using std::string;
   const string BIENVENUE = "Coucou!";
   f(BIENVENUE); // ILLÉGAL
   //
   // On ne veut pas créer une copie de
   // BIEVENUE, car on veut profiter du gain
   // de vitesse dû au fait qu'on évite une
   // copie de par le passage par référence
   //
   f(const_cast<string&>(BIENVENUE));
}

Relation entre const, mutable et volatile

Il existe une relation particulière entre ces mots clés que sont const, mutable et volatile. En effet :

L'exemple ci-dessous montre des exemples de méthodes déclinées en versions const et non const, de même qu'un exemple d'attribut mutable (donc sujet à changer de valeur même dans une situation où l'instance qui en est propriétaire devrait demeurer inchangée) :

class Chaine {
public:
   using size_type = int;
   using value_type = char;
private:
   const size_type taille_;
   value_type *texte;
   // compte les appels à taille(), pour fins statistiques
   mutable int cpt_appels;
public:
   // Exceptions possibles
   class TailleIllegale {};
   class HorsBornes {};
   Chaine(size_type lg) : texte{}, taille_(lg), cpt_appels{} {
      if (taille() <= 0) throw TailleIllegale();
      texte = new char[taille()];
   }
   // combinaison de méthode const et d'attribut mutable
   size_type taille() const noexcept {
      ++ cpt_appels;
      return taille_;
   }
   // versions const et non const d'une même opération
   value_type operator[](size_type i) const {
      if (i < 0 || i >= taille()) throw HorsBornes{};
      return texte[i];
   }
   value_type& operator[](size_type i) {
      if (i < 0 || i >= taille()) throw HorsBornes{};
      return texte[i];
   }
   //
   // ajouter la Sainte-Trinité
   //
};

Sachant cela, examinons de plus près comment exploiter la spécification volatile :

Il faut donc comprendre méthode volatile au sens de méthode pouvant être appelée d'une instance elle-même qualifiée volatile. Une méthode volatile est une méthode que le programmeur estime sécuritaire à solliciter chez une instance qualifiée volatile.

Relation entre volatile et const – un comparatif

Pour aider à situer la relation entre les mots volatile et const, voici quelques petits tableaux comparatifs[6]. En espérant que le tout vous soit utile.

Mot clé const Mot clé volatile Remarques

Un objet qualifié const a des états (des attributs) qui sont mutables ou qui ne changeront pas.

Un objet qualifié volatile a des états (des attributs) qui peuvent changer de manière imprévue.

En ce sens, l'un est un peu l'inverse de l'autre : le compilateur optimisera agressivement le code associé à un objet const et sera beaucoup plus prudent avec un objet volatile.

Les attributs d'un objet qualifié const sont eux-mêmes considérés const (sauf bien sûr s'ils sont mutables).

Les attributs d'un objet qualifié volatile sont eux-mêmes considérés volatile.

En ce sens, les deux se ressemblent car la qualification se propage de l'objet vers ses attributs.

Sur un objet qualifié const, on ne peut invoquer que des méthodes elles-mêmes qualifiées const.

Sur un objet qualifié volatile, on ne peut invoquer que des méthodes elles-mêmes qualifiées volatile.

En ce sens, l'impact des deux qualifications est identique, mais pour des raisons différentes : dans un cas, on cherche à garantir la constance de l'objet alors que dans l'autre, on cherche à tenir compte de sa volatilité.

Une méthode d'instance const d'un objet donné ne peut invoquer que des méthodes d'instances const du même objet (pour garantir le respect de la qualification const de manière transitive).

Une méthode d'instance volatile d'un objet donné ne peut invoquer que des méthodes d'instances volatile du même objet (pour garantir le respect de la qualification volatile de manière transitive).

En ce sens, la règle est la même dans les deux cas.

Une méthode d'instance const est une garantie que l'état de l'objet auquel elle appartient ne changera pas en conséquence de son action.

Une méthode d'instance volatile est une garantie que l'objet tient compte dans cette méthode du fait que l'objet est sujet à changer à tout moment.

En ce sens, il y a une différence sémantique entre les deux qualifications.

Mot clé volatile et limites du compilateur

Un compilateur de qualité peut (doit!) offrir un support très strict du mot clé const et du concept qui y est associé. La possibilité de qualifier const des méthodes permet d'ailleurs à un compilateur de valider le respect des règles associées à ce concept et de s'assurer à la compilation qu'aucune de ces règles n'est violée dans un programme donné.

Le compilateur peut offrir un certain secours aux développeurs de systèmes multiprogrammés en reconnaissant la spécification volatile sur des objets et en bloquant à la compilation l'invocation de méthodes de cet objet qui ne seraient pas elles-mêmes garanties volatile. Toutefois, ce qui permet à un programmeur de qualifier volatile ou non une méthode est plus flou que ce qui lui permet de qualifier une méthode const.

Certaines règles sont faciles à reconnaître et à mettre en place :

C'est quand même peu (bien que semblable) en comparaison avec le vaste support accordé aux objets et aux méthodes const. C'est pourtant la limite du support raisonnable: la multiprogrammation est une chose complexe et qui dépend de plusieurs facteurs, ce qui la rend plus difficile à analyser et à valider de manière unitaire – la constance d'un objet sur la base de certaines opérations est chose plus simple à valider localement dans la mesure où les opérations sont récursivement porteuses de leurs propres garanties de constance.

Comment un compilateur pourrait-il savoir qu'une méthode qualifiée volatile (donc sécuritaire pour invocation sur un objet volatile) l'est vraiment? Serait-il suffisant de vérifier qu'elle exploite un outil de synchronisation comme un mutex ou un sémaphore (ou une qualification synchronized telle qu'on les rencontre en Java)? Faudrait-il vérifier que ces outils sont-ils bien utilisés? Comment pourrait-on réaliser une telle analyse sans connaissance du contexte dans lequel la méthode sera invoquée?

Dans l'état courant du savoir à ce sujet, ce problème est insoluble.

Constructeurs et destructeurs volatile?

Les constructeurs et les destructeurs ne peuvent être qualifiés volatile. Ces moments charnières ne peuvent se produire qu'une seule fois dans la vie de chaque objet, et ne sont donc pas sujets à être soumis à des accès concurrents.

Le mot clé volatile en action

Il reste que le support offert par le mot clé volatile appliqué à des méthodes est très appréciable pour les programmeurs expérimentés. Voyons un peu en quoi, et comment.

Allons-y d'un exemple concret, en nous basant sur la classe zone_transit décrite précédemment. Imaginons tout d'abord un scénario optimal, où aucune synchronisation n'est requise.

Dans une telle situation, qu'elle soit monoprogrammée ou que le thread utilisant un zone_transit n'en fasse qu'une simple file d'attente de caractères, on n'aurait même pas besoin d'utiliser un mécanisme de synchronisation comme un mutex.

Après tout, exiger l'obtention d'un mutex pour procéder ralentit l'exécution du programme, alors vaut mieux l'éviter si ce n'est pas nécessaire.

Cela dit, ayant le souci d'offrir des objets complets, robustes et efficaces tout en visant à écrire des programmes qui soient les plus stables possibles, on aimerait que zone_transit soit une classe aussi efficace que possible, qu'elle soit utilisée ou non dans un contexte de multiprogrammation.

Conséquemment, on aimerait qu'un programme monoprogrammé puisse utiliser notre classe sans pénalité et sans se préoccuper outre mesure de problématiques liées à la multiprogrammation. On voudrait, en fait, qu'un zone_transit encapsule à la fois les opérations normales, donc rapides et sans protection contre les problèmes propres à la multiprogrammation, et les opérations spécialisées, un peu moins rapides mais protégées contre les problèmes propres à la multiprogrammation.

#include "Mutex.h"
#include <string>
class zone_transit { // SANS PROTECTION
public:
   using value_type = std::string;
private:
   Mutex m;
   value_type texte;
public:
   void ajouter(const value_type &s) {
     texte += s;
  }
   value_type extraire() {
      auto s = texte;
      texte.clear();
      return s;
   }
   bool empty() const noexcept {
      return texte.empty();
   }
};

On voudrait aussi que ce soit le compilateur, pas le code client, qui soit responsable de s'assurer que les opérations pertinentes soient choisies dans un cas comme dans l'autre. Idéalement, le code client devra obtenir du compilateur un support maximal dès qu'un objet y sera spécifié volatile, du fait que le compilateur sait alors de cet objet qu'il a des besoins particuliers.

On veut, donc, que dans le programme proposé à droite, il y ait une différence fondamentale entre appeler la méthode ajouter() de ts et appeler la méthode ajouter() de vts :

  • Dans le premier cas, le compilateur devrait avoir recours à une version rapide et non protégée de ajouter(), alors que
  • Dans le second cas, le compilateur devrait exiger une version volatile (donc déclarée sécurisée par le programmeur) de ajouter() et utiliser celle-ci

Ce qu'on demande au compilateur est :

  • De reconnaître le signal de volatilité d'un objet, signal donné par le programmeur en spécifiant ladite variable volatile, et
  • De s'y conformer en évitant toute optimisation agressive sur cet objet et en n'utilisant que les méthodes de cet objet qui sont elles-mêmes qualifiées volatile
// ...
int main() {
   zone_transit ts;
   volatile zone_transit vts;
   ts.ajouter("ts");
   vts.ajouter("vts");
}

La version de la classe zone_transit proposée à ci-dessous est préférable aux versions précédentes en ce sens qu'elle offre des versions sécurisées (volatile) et non sécurisées de chaque méthode pertinente. Le compilateur choisira la version de chaque méthode en fonction de la qualification volatile ou non de l'instance propriétaire de la méthode.

#include "Mutex.h"
#include <string>
class zone_transit {
public:
   using value_type = std::string;
private:
   Mutex m;
   value_type texte;
public:
   void ajouter(const value_type &s) {
      texte += s;
   }
   void ajouter(const value_type &s) volatile {
      Autoverrou av(const_cast<Mutex&>(m));
      const_cast<value_type &>(texte) += s;
   }
   value_type extraire() {
      auto s = texte;
      texte.clear();
      return s;
   }
   value_type extraire() volatile {
      Autoverrou av(const_cast<Mutex&>(m));
      auto s = const_cast<value_type &>(texte);
      const_cast<value_type &>(texte).clear();
      return s;
   }
   bool empty() const noexcept {
      return texte.empty();
   }
   bool empty() const volatile noexcept {
      Autoverrou av(const_cast<Mutex&>(m));
      return const_cast<value_type &>(texte).empty();
   }
};

Ou encore, pour une écriture plus élégante, la version suivante, qui fait des méthodes qualifiées volatile un simple enrobage injectant des primitives de synchronisation autour des méthodes brutes (ce qui réduit la complexité de l'entretien du code).

#include "Mutex.h"
#include <string>
class zone_transit {
public:
   using value_type = std::string;
private:
   Mutex m;
   value_type texte;
public:
   void ajouter(const value_type &s) {
      texte += s;
   }
   void ajouter(const value_type &s) volatile {
      Autoverrou av(const_cast<Mutex&>(m));
      const_cast<zone_transit *>(this)->ajouter(s);
   }
   value_type extraire() {
      auto s = texte;
      texte.clear();
      return s;
   }
   value_type extraire() volatile {
      Autoverrou av(const_cast<Mutex&>(m));
      return const_cast<zone_transit *>(this)->extraire();
   }
   bool empty() const noexcept {
      return texte.empty();
   }
   bool empty() const volatile noexcept {
      Autoverrou av(const_cast<Mutex&>(m));
      return const_cast<zone_transit *>(this)->empty();
   }
};

Vous remarquerez sans doute la présence de quelques conversions explicites de types ISO (des const_cast) dans le code en exemple. Examinons-les de plus près.

Méthodes spécifiées volatile et objets Thread Oblivious

Les types STL (comme std::string, std::vector, std::stack et ainsi de suite) qui peuplent les bibliothèques standard de C++. ne sont généralement pas des types Thread-Safe, en ne sont donc pas des types protégés contre les accès concurrents.

Les types STL sont des types souples, dont les opérations sont extrêmement efficaces, mais qui ne proposent pas de méthodes spécifiées volatile. Ce ne sont pas les seuls types qui ne soient pas Thread-Safe, évidemment: la plupart des types (la plupart des classes écrites par la plupart des programmeurs et par la plupart des compagnies) ne sont pas conçus avec tout le soin requis pour être à la fois efficaces en situation monoprogrammée et sécuritaires en situation multiprogrammée.

Ne prenons donc pas ce constat fait sur les types STL comme un reproche, mais bien comme un constat: le cas multiprogrammé typique est une classe Thread-Safe qui encapsule des attributs Thread Oblivious et assure sa propre sécurité par des mécanismes qui font partie de sa barrière d'encapsulation.

Une méthode spécifiée volatile ne peut pas utiliser directement des attributs qui ne sont pas volatile. Cela tombe sous le sens, un peu comme le fait qu'une méthode spécifiée const d'un objet constitué en partie d'autre objets, par composition ou par agrégation, ne peut utiliser de ces objets que des méthodes elles-mêmes qualifiées const.

En effet, si une méthode garantit la constance de l'instance qui en est propriétaire pour la durée de son exécution, elle ne peut faire appel aux méthodes de ses attributs qui offrent elles aussi de telles garanties. De manière symétrique, un objet offrant une méthode volatile (donc sécurisée) ne peut avoir recours, à l'intérieur de cette méthode, qu'aux méthodes de ses attributs qui sont elles aussi volatile, donc Thread-Safe.

Tout comme on peut enlever temporairement la spécification const sur un objet, on peut aussi enlever temporairement la spécification volatile. Dans chaque cas, la conversion à appliquer est un const_cast.

Dans notre exemple plus haut, l'instance de std::string nommée texte et servant d'attribut à une instance de zone_transit pose problème si le zone_transit en question est volatile, du fait que les attributs d'un objet volatile sont eux aussi qualifiés volatile. Sachant que std::string n'expose aucune méthode volatile, cet attribut serait inutilisable dans une méthode volatile de l'instance à laquelle il appartient.

Il importe donc de pouvoir supprimer temporairement la qualification volatile de texte quand on désire s'en servir dans une méthode volatile – évidemment, il faut alors que la méthode ait pris les dispositions pour protéger texte puisque, par définition, texte ne se protège pas lui-même.

Les const_cast<std::string&> y sont donc nécessaires pour utiliser une std::string dans une méthode volatile de zone_transit; on présume évidemment que chaque méthode exploitant ce mécanisme a pris au préalable des dispositions pour assurer la sécurité de la std::string en situation multiprogrammée.

Une méthode extraire() en temps constant (complexité )

Les exemples ci-dessus montrent une méthode extraire() implémentée comme suit :

#include "Mutex.h"
#include <string>
class zone_transit {
public:
   using value_type = std::string;
private:
   Mutex m;
   value_type texte;
public:
   // ...
   value_type extraire() {
      auto s = texte;
      texte.clear();
      return s;
   }
   // ...
};

Cette méthode a un gros défaut, soit celui de générer une (pour ne pas dire deux) copie(s) du contenu complet de texte. Par conséquent, cette méthode est de complexité linéaire (, où est texte.size()). Une alternative en temps constant () irait comme suit :

#include "Mutex.h"
#include <string>
class zone_transit {
public:
   using value_type = std::string;
private:
   Mutex m;
   value_type texte;
public:
   // ...
   value_type extraire() {
      value_type s;
      s.swap(texte);
      return s;
   }
   // ...
};

Voyez la nuance (qui se base sur des techniques analogues à celles sous-jacentes à l'implémentation usuelle de l'idiome d'affectation sécuritaire) :

Notez qu'il est tentant d'écrire ce qui suit :

#include "Mutex.h"
#include <string>
class zone_transit {
public:
   using value_type = std::string;
private:
   Mutex m;
   value_type texte;
public:
   // ...
   value_type extraire() {
      return std::move(texte);
   }
   // ...
};

... mais ce serait pour le moins risqué : la norme nous indique que les constructeurs de mouvement doivent faire en sorte que l'objet duquel sont déplacés les états (ici : texte) soit destructible après le mouvement, sans plus. Nous ne pouvons donc présumer par exemple que texte représente une chaîne vide suite à std::move(texte), même s'il serait plausible que l'implémentation mène à cette conclusion.

Lectures complémentaires

Quelques liens pour enrichir le propos.

Cependant, volatile n'est pas une panacée, et quand on le prend pour autre chose que ce que c'est, on peut prendre ce mot en grippe. Pour quelques critiques de volatile en C++, voir :

Le mot clé volatile existe aussi dans d'autres langages, et son sens varie d'un langage à l'autre :

Comparaison du code généré pour des calculs sur un entier, un entier volatile et un entier atomique, par Marc Brooker en 2013 : http://brooker.co.za/blog/2013/01/06/volatile.html


[1]http://h-deb.clg.qc.ca/Sujets/Divers--cplusplus/CPP--Exploiter-Symetrie.html

[2] On n'obtient habituellement pas ces optimisations dans des programmes compilés dans leur version Debug, donc avec inclusion d'information pour assister le déverminage (avec références au code source brûlées dans le code objet, en particulier), mais il est très possible que ces optimisations soient appliquées dans du code compilé pour fins commerciales (en mode Release, selon l'usage commun).

[3] On parle bien entendu de l'action d'un autre thread que celui en cours de compilation, évidemment, mais aussi de choses plus conventionnelles comme le sont par exemple les interruptions matérielles.

[4] Si l'opérateur d'affectation a été défini à l'intérieur de la classe, en tant que méthode, alors il faut voir si cette méthode est elle-même const ou non... Cela dit, notons qu'un opérateur = qui serait const serait aussi très contre intuitif.

[5] Ceci devrait inclure plusieurs opérateurs comme ++, --, +=, <<= et ainsi de suite.

[6] Ces tableaux ont été suggérés par des questions d'Émily Pigeon, étudiante au DEC en informatique de gestion du Collège Lionel-Groulx à la session Hiver 2007.


Valid XHTML 1.0 Transitional

CSS Valide !