Sections critiques portables

Si vous estimez ce qui suit intéressant, vous apprécierez probablement aussi les autres articles de cette section de h-deb.

Une section critique est un bout de code qui ne peut être interrompu pas d'autres threads d'un même processus pendant son exécution, donc qui se veut atomique dans un processus donné. Il doit donc être court (en temps d'exécution) et fini (ce n'est pas un bon endroit pour se lancer dans une série d'entrées/ sorties).

Les sections critiques constituent un mécanisme de synchronisation plus primitif mais aussi plus léger) que les mutex. Dans les cas où les deux pourraient être appliqués, les sections critiques tendent à permettre la rédaction de programmes plus performants.

Les sections critiques sous Win32

Pour en savoir plus sur le type CRITICAL_SECTION sous Win32, envisagez lire cet article. Lisez aussi les avertissements au début de l'article.

Sous Win32, une section critique est représentée par un enregistrement (un struct) nommé CRITICAL_SECTION. Cet enregistrement permet de gérer l'accès à la section critique et sait, entre autres choses :

Les principales fonctions Win32 ayant trait aux sections critiques sont :

Trois autres fonctions Win32 utiles à connaître pour qui s'intéresse aux sections critiques sur cette plateforme :

Exemple procédural

Imaginons, pour fins de démonstration, un exemple un peu malsain de recours à une section critique. La raison pour laquelle cet exemple est malsain est que le code réalisé dans la section critique est très lent (on parle d'une projection de texte à la console), chose peu recommandable en pratique.

L'exemple procède comme suit :

Le recours à une variable globale ici n'est pas un besoin technique mais découle simplement d'un souci d'économie pour ce qui est de la complexité de l'exemple.

La présence de la section critique dans ce code joue un rôle analogue à celui d'un mutex, soit empêcher plus d'un thread d'entrer en même temps dans une même section critique. Conséquence : le message affiché trois fois de chaque thread ne sera pas mêlé aux messages des autres threads. Ceci donnera au programme un affichage cohérent.

#include <string>
#include <algorithm>
#include <random>
#include <iostream>
#include <windows.h>
CRITICAL_SECTION g_CritSect;
unsigned long __stdcall afficheur(void *);
int main() {
   using namespace std;
   random_device rd;
   mt19937 prng{ rd() };
   InitializeCriticalSection(&g_CritSect);
   const string MESSAGES [] = { "Yo", "Coucou", "Hey!", "Ouf..." };
   enum { N = sizeof (MESSAGES)/ sizeof (MESSAGES[0]) }
   HANDLE h[N] = { 0 };
   for (int i = 0; i < N; ++i)
      h[i] = CreateThread(
         0, 0, afficheur, const_cast<string*>(MESSAGES + i), CREATE_SUSPENDED, 0
      );
   shuffle(begin(h), end(h), prng);
   for_each(begin(h), end(h), ResumeThread);
   WaitForMultipleObjects(N, h, TRUE, INFINITE);
   for_each(begin(h), end(h), CloseHandle);
   DeleteCriticalSection(&g_CritSect);
}
unsigned long __stdcall afficheur(void *p) {
   using namespace std;
   auto &texte = *static_cast<string *>(p);
   EnterCriticalSection(&g_CritSect);
   cout << texte << endl;
   cout << texte << endl;
   cout << texte << endl;
   LeaveCriticalSection(&g_CritSect);
   return {};
}

Les aléas usuels d'une approche procédurale comme celle de cet exemple s'appliquent ici. En particulier, si une exception ou un bris anormal du flot d'un programme se produit, il y a un risque qu'une section critique entrée ne soit jamais effectivement quittée.

Il y a aussi, si de telles circonstances surviennent, un risque que des ressources attribuées au préalable ne soient jamais libérées. Clairement, appliquer une approche OO sera bénéfique.

Exemple OO non portable

Une approche OO au même problème s'implémente assez facilement avec les techniques que nous connaissons déjà, surtout si nous ne visons pas du code pleinement portable. Notre exemple utilisera :

Le code suit. Remarquez que, en excluant les classes (qui sont réutilisables d'une projet à l'autre), le code client (main() et afficheur()) est à la fois plus concis, plus simple et beaucoup moins dangereux.

#include "Incopiable.h"
#include <string>
#include <algorithm>
#include <random>
#include <iostream>
#include <windows.h>
class SectionCritique : Incopiable {
   CRITICAL_SECTION sect;
   friend class Pogneur;
   void entrer() noexcept {
      EnterCriticalSection(&sect);
   }
   void quitter() noexcept {
      LeaveCriticalSection(&sect);
   }
public :
   class Pogneur {
      SectionCritique &sect;
   public :
      Pogneur(SectionCritique &sect) noexcept : sect{sect} {
         sect.entrer();
      }
      ~Pogneur() {
         sect.quitter();
      }
   };
   SectionCritique() noexcept {
      InitializeCriticalSection(&sect);
   }
   ~SectionCritique() {
      DeleteCriticalSection(&sect);
   }
};
SectionCritique g_CritSect;
unsigned long __stdcall afficheur(void *);
int main() {
   using namespace std;
   random_device rd;
   mt19937 prng{ rd() };
   const string MESSAGES [] = { "Yo", "Coucou", "Hey!", "Ouf..." };
   enum { N = sizeof (MESSAGES)/ sizeof (MESSAGES[0]) }
   HANDLE h[N] = { 0 };
   for (int i = 0; i < N; ++i)
      h[i] = CreateThread(
         0, 0, afficheur, const_cast<string *>(MESSAGES + i), CREATE_SUSPENDED, 0
      );
   shuffle(begin(h), end(h), prng);
   for_each(begin(h), end(h), ResumeThread);
   WaitForMultipleObjects(N, h, TRUE, INFINITE);
   for_each(begin(h), end(h), CloseHandle);
}
#include <iostream>
unsigned long __stdcall afficheur(void *p) {
   using namespace std;
   auto& texte = *static_cast<string *>(p);
   SectionCritique::Pogneur pogn(g_CritSect);
   cout << texte << endl;
   cout << texte << endl;
   cout << texte << endl;
   return {};
}

Exemple OO portable

Pour en arriver à une implémentation sécuritaire, portable et efficace, il convient d'appliquer plusieurs schémas de conception et plusieurs idiomes de programmation.

La structure globale minimale dont nous aurons besoin pour arriver à nos fins sera :

Toute invocation d'un service de la strate utilisable (SectionCritique) provoquera une invocation polymorphique d'un service de l'interface isolante ISectionCritique qui résultera en fin de compte en une invocation des services locaux à la plateforme par SectionCritiqueWin32. Seule l'invocation polymorphique ne pourra être optimisée de manière statique par un compilateur – le prix à payer pour devenir pleinement portable est une indirection à travers une table de pointeurs.

Le code des différents fichiers impliqués suit.

Strate isolante et strate d'utilisation – Fichier SectionCritique.h

Le type Impl représente l'implémentation (cachée, pImpl) du concept de section critique. La strate utilisable est exactement telle qu'elle était auparavant, à ceci près qu'elle devient portable de par son recours à Impl, la strate isolante, plutôt qu'à du code local à la plateforme pour solliciter les services de section critique locaux.

#ifndef SECTION_CRITIQUE_H
#define SECTION_CRITIQUE_H
#include <memory>
class SectionCritique {
   struct Impl;
   std::unique_ptr<Impl> sect;
   friend class Pogneur;
   void entrer() noexcept;
   void quitter() noexcept;
public:
   class Pogneur {
      SectionCritique &sect;
   public:
      Pogneur(SectionCritique &sect) noexcept : sect{sect} {
         sect.entrer();
      }
      ~Pogneur() {
         sect.quitter();
      }
   };
   SectionCritique();
   ~SectionCritique();
};
#endif

Strate isolante – Fichier SectionCritique.cpp

Le fichier SectionCritique.cpp propose une implémentation des méthodes de fabrication et de nettoyage qui sera appropriée à la plateforme pour laquelle le code est compilé. Ce fichier est éminemment non portable. Son rôle est d'encapsuler dans un ensemble OO sécurisé tout en demeurant facile à optimiser les fonctions non portables pour faciliter l'expression du reste du code serveur.

Si plusieurs plateformes devaient être supportées par votre code, il faudrait adapter ce fichier.

#include "SectionCritique.h"
#include <windows.h>
class SectionCritique::Impl {
   CRITICAL_SECTION sect;
public:
   Impl() noexcept {
      InitializeCriticalSection(&sect);
   }
   ~Impl() {
      DeleteCriticalSection(&sect);
   }
   void entrer() noexcept {
      EnterCriticalSection(&sect);
   }
   void quitter() noexcept {
      LeaveCriticalSection(&sect);
   }
};
SectionCritique::SectionCritique() : sect{ make_unique<Impl>() } {
}
SectionCritique::~SectionCritique() = default;
void SectionCritique::entrer() {
   sect->entrer();
}
void SectionCritique::quitter() {
   sect->quitter();
}

Code client – Fichier Principal.cpp

Le programme de démonstration suit et n'a, à une directive d'inclusion de fichier près, pas changé d'un poil. Clairement, la démarche proposée n'implique pas de transformations nuisibles pour le code client.

Notez au passage que ce fichier inclut <windows.h> pour le recours aux threads. Si nous avions isolés les threads aussi, alors ce programme aurait été un programme multiprogrammé, synchronisé mais aussi rapide et pleinement portable.

#include "SectionCritique.h"
#include <string>
#include <algorithm>
#include <random>
#include <windows.h>
SectionCritique g_CritSect;
unsigned long __stdcall afficheur(void *);
int main() {
   using namespace std;
   random_device rd;
   mt19937 prng{ rd() };
   const string MESSAGES [] = { "Yo", "Coucou", "Hey!", "Ouf..." };
   enum { N = sizeof (MESSAGES)/ sizeof (MESSAGES[0]) };
   HANDLE h[N] = { 0 };
   for (int i = 0; i < N; ++i)
      h[i] = CreateThread(
         0, 0, afficheur, const_cast<string *>(MESSAGES + i), CREATE_SUSPENDED, 0
      );
   shuffle(begin(h), end(h), prng);
   for_each(begin(h), end(h), ResumeThread);
   WaitForMultipleObjects(N, h, TRUE, INFINITE);
   for_each(begin(h), end(h), CloseHandle);
}
#include <iostream>
unsigned long __stdcall afficheur(void *p) {
   using namespace std;
   auto &texte = *static_cast<string *> (p);
   SectionCritique::Pogneur pogn{g_CritSect};
   cout << texte << endl;
   cout << texte << endl;
   cout << texte << endl;
   return {};
}

Sections critiques et systèmes à plusieurs processeurs

L'implémentation à bas niveau d'une section critique est simple :

La section critique, en pratique, évite parfois la synchronisation par un mutex, ce qui la rend un peu plus efficace que le verrouillage systématique, plus coûteux qu'on le suspecterait a priori.

Il se trouve que, sur un système à plusieurs processeurs (ou à un seul processeur muni de plusieurs coeurs), la suspension d'un thread désireux d'entrer dans une section critique devient si coûteuse qu'une alternative est privilégiée : le thread demandeur d'accès bouclera sur lui-même un certain nombre d'itérations en tentant d'obtenir le droit d'accès et ne sera suspendu que si la section critique n'a pas encore été libérée suite à cette boucle.

Le nombre d'itérations à réaliser avant suspension du thread demandeur d'accès à une section critique est ce qu'on nomme le Spin Count de cette section critique. Sur un système à un seul processeur, un Spin Count de zéro (mise en attente automatique si la section critique n'est pas immédiatement disponible) est convenable.

Sur plusieurs processeurs ou sur un processeur muni de plusieurs coeurs, on voudra habituellement un Spin Count plus élevé. L'exemple proposé par MSDN pour démontrer l'utilisation de la fonction InitializeCriticalSectionAndSpinCount() a recours à un Spin Count de 0x80000400.

La fonction InitializeCriticalSectionAndSpinCount() combine donc les étapes d'initialisation de la section critique et du Spin Count en un seul appel. De son côté, la fonction SetCriticalSectionSpinCount() se limite à fixer un Spin Count pour une section critique déjà initialisée.


Valid XHTML 1.0 Transitional

CSS Valide !