Multiprogrammation, concurrence et temporaires

Multiprogrammation, concurrence et temporaires

Cette page repose sur des concepts orientés objet (OO) qu'on ne rencontre habituellement pas en début de formation, de même que sur des techniques habituellement vues dans un cours de multiprogrammation ou de systèmes client/ serveur (SC/S). Si vous n'avez pas ce profil, il est possible que vous la trouviez un peu aride.

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 TamponSecurise.

TamponSecurise.h
#include "Mutex.h"
#include <string>
class TamponSecurise
{
   std::string texte_;
   Mutex m_;
public:
   std::string extraire()
   {
      Autoverrou av{m_};
      auto temp = texte_;
      texte_.clear();
      return temp;
   }
   void ajouter(const std::string &s)
   {
      Autoverrou av{m_};
      texte_ += s;
   }
};

Schématiquement, ça nous donne quelque chose comme ce qui est proposé à droite. Deux threads opèrent en parallèle, l'un pour remplir la zone tampon et l'autre pour la vider.

Remarquez qu'un simple canal comme TamponSecurise est d'une utilité limitée, mais on pourrait utiliser une instance de TamponSecurise 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 TamponSecurise pour encapsuler certains accès primitifs en écriture à une chaîne de caractères.

Exemple.cpp
#include "TamponSecurise.h"
#include <string>
#include <algorithm>
#include <fstream>
#include <iostream>
#include <thread>

unsigned long __stdcall Lecteur (void *);
unsigned long __stdcall Afficheur (void *);

class ObjetPartage
{
   bool m_FinLecture;
   TamponSecurise m_Tampon;
   std::string m_NomFich;
public:
   ObjetPartage (const std::string &NomFich)
      : m_FinLecture (false), m_Tampon ()
   {
   }
   TamponSecurise & GetTampon () { return m_Tampon; }
   void Fini () { m_FinLecture = true; }
   bool EstFini () const { return m_FinLecture; }
   std::string GetNomFichier () const { return m_NomFich; }
};

int main()
{
   const int NB_THREADS = 2;
   HANDLE hTh[NB_THREADS] = { 0 }
   DWORD idTh[NB_THREADS] = { 0 }
   ObjetPartage op ("in.txt");
   hTh[0] = ::CreateThread (0, 0, Lecteur, &op, 0, &idTh[0]);
   hTh[1] = ::CreateThread (0, 0, Afficheur, &op, 0, &idTh[1]);
   ::WaitForMultipleObjects (NB_THREADS, hTh, TRUE, INFINITE);
   std::for_each (&hTh[0], &hTh[NB_THREADS], ::CloseHandle);
}

unsigned long __stdcall Lecteur (void *p)
{
   ObjetPartage *pOP = static_cast<ObjetPartage *> (p);
   std::ifstream ifs (pOP->GetNomFichier ().c_str ());
   std::string s;
   while (std::getline (ifs, s))
   {
      s += '\n';
      pOP->GetTampon ().ajouter(s);
      ::Sleep (0);
   }
   pOP->Fini ();
   return 0L;
}
unsigned long __stdcall Afficheur (void *p)
{
   ObjetPartage *pOP = static_cast<ObjetPartage *> (p);
   while (!pOP->EstFini ())
   {
      std::string s = pOP->GetTampon ().extraire();
      if (!s.empty ()) std:cout << s;
      ::Sleep (0);
   }
   return 0L;
}

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

ExempleDangereux.cpp
#include "TamponSecurise.h"
#include <string>
#include <algorithm>
#include <fstream>
#include <iostream>
#include <windows.h>

unsigned long __stdcall Lecteur (void *);
unsigned long __stdcall Afficheur (void *);

class ObjetPartage
{
   bool m_FinLecture;
   TamponSecurise m_Tampon;
   std::string m_NomFich;
public:
   ObjetPartage (const std::string &NomFich)
      : m_FinLecture (false), m_Tampon ()
   {
   }
   TamponSecurise & GetTampon () { return m_Tampon; }
   void Fini () { m_FinLecture = true; }
   bool EstFini () const { return m_FinLecture; }
   std::string GetNomFichier () const { return m_NomFich; }
};

int main()
{
   const int NB_THREADS = 2;
   HANDLE hTh[NB_THREADS] = { 0 }
   DWORD idTh[NB_THREADS] = { 0 }
   ObjetPartage op ("in.txt");
   hTh[0] = ::CreateThread (0, 0, Lecteur, &op, 0, &idTh[0]);
   hTh[1] = ::CreateThread (0, 0, Afficheur, &op, 0, &idTh[1]);
   ::WaitForMultipleObjects (NB_THREADS, hTh, TRUE, INFINITE);
   std::for_each (&hTh[0], &hTh[NB_THREADS], ::CloseHandle);
}

unsigned long __stdcall Lecteur (void *p)
{
   ObjetPartage *pOP = static_cast<ObjetPartage *> (p);
   std::ifstream ifs (pOP->GetNomFichier ().c_str ());
   std::string s;
   while (std::getline (ifs, s))
   {
      pOP->GetTampon ().ajouter(s);
      pOP->GetTampon ().ajouter("\n");
      ::Sleep (0);
   }
   pOP->Fini ();
   return 0L;
}
unsigned long __stdcall Afficheur (void *p)
{
   ObjetPartage *pOP = static_cast<ObjetPartage *> (p);
   while (!pOP->EstFini ())
   {
      std::string s = pOP->GetTampon ().extraire();
      if (!s.empty ()) std:cout << s;
      ::Sleep (0);
   }
   return 0L;
}

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 TamponSecurise 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 TamponSecurise 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? Ce problème n'est pas trivial. On peut, pour nous fins un peu précises, résumer les options aux deux (2) ci-dessous:

Cas a Cas b
#include <string>
#include "Mutex.h"
class TamponSecurise
{
   std::string texte_;
   Mutex m_;
public:
   std::string extraire()
   {
      Autoverrou av{m_};
      std::string temp = texte_;
      texte_.clear ();
      return temp;
   }
   // Version simulant manuellement ce que fait le
   // compilateur
   void ajouter(const char *txt)
   {
      const std::string s = txt; // création d'une temporaire
      {
         // 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
};

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.

Avec le compilateur C++ version 7 de Microsoft (celui livré avec Visual Studio .NET 2003), le code est généré avec la stratégie b. C'est la version qui, pour nous, fait mal.

Illustrons le tout en simulant à la main ce que fait le compilateur. Pour y arriver, imaginons que la méthode ajouter() de TamponSecurise 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 TamponSecurise, 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...

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 des bibliothèques standards de C++ :

Prenez quelques instants pour examiner la classe chaine dans le document sur les classes imbriquées: la classe std::string utilise, pour fins d'optimisation, un schème de représentation interne partageable 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 – une excellente – technique OO d'optimisation, mais pour notre problème très précis, 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?

unsigned long __stdcall Lecteur (void *p)
{
   ObjetPartage *pOP = static_cast<ObjetPartage *> (p);
   std::ifstream ifs (pOP->GetNomFichier ().c_str ());
   std::string s;
   while (std::getline (ifs, s))
   {
      pOP->GetTampon ().ajouter(s);
      const std::string NOUVELLE_LIGNE = "\n";
      pOP->GetTampon ().ajouter(NOUVELLE_LIGNE);
      ::Sleep (0);
   } // «destruction» de NOUVELLE_LIGNE
   pOP->Fini ();
   return 0L;
}

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 TamponSecurise.

Une autre est de modifier manuellement la temporaire pour forcer la déconnexion entre celle-ci et l'attribut texte_.

class TamponSecurise
{
   // ...
   void ajouter(std::string s)
   {
      Autoverrou av{m_};
      texte_ += s;
      s.clear();
   }
};

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).

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 TamponSecurise
{
   // ...
   void ajouter(const std::string &s)
   {
      Autoverrou av{m_};
      // for (std::string::size_type i = 0; i < s.length(); i++)
      //    texte_ += s[i];
      std::copy(s.begin(), s.end(),std::back_inserter<std::string> (texte_));
   }
};

Valid XHTML 1.0 Transitional

CSS Valide !