Un type, deux implémentations

Étant donné les consignes du travail qui vous a été proposé, plusieurs options sont possibles. Ce qui suit présente deux d'entre elles. Nous en discuterons en classe.

Les exemples qui suivent utilisent un verrou RAII maison nommé Autoverrou, mais le fait que j'aie nommé lock() et unlock() les méthodes de gestion du verrou sur mon Mutex a pour conséquence que j'aurais pu utiliser un std::lock_guard<Mutex> à la place.

Code de test

Dans les deux approches ci-dessous, j'utiliserai le même code de test, dans l'optique de comparer entre elles des choses comparables.

Le code utilisera une zone de transit qui prendra la forme suivante :

#ifndef ZONE_TRANSIT_H
#define ZONE_TRANSIT_H
#include "Mutex.h" // le vôtre, évidemment
#include <algorithm>
#include <utility>
template <class T>
   class zone_transit {
      T data;
      mutable Mutex m;
   public:
      template <class MT>
         zone_transit(MT mt) : m{ mt } {
         }
      template <class It>
         void ajouter(It debut, It fin) {
            Autoverrou _{ m };
            data.insert(std::end(data), debut, fin);
         }
      void ajouter(const T &vals) {
         ajouter(std::begin(vals), std::end(vals));
      }
      T extraire() {
         using std::swap;
         T temp;
         {
            Autoverrou _{ m };
            swap(temp, data);
         }
         return temp;
      }
   };
#endif

Cette zone de transit est simple (je dirais même classique), à ceci près qu'elle utilise notre Mutex maison plutôt qu'un std::mutex. Notez que j'ai utilisé un template pour le constructeur de la zone de transit, dans le but de permettre au code client de décider quel type d'outil de synchronisation elle devrait utiliser pour protéger son intégrité – j'ai pris la décision pour cet exemple d'utiliser un type en tant que constante.

Le code de test en soi ira comme suit.

Je ferai des tests sur la base d'insertions et d'extractions concurrentes de texte dans une zone de transit. Les tests généreront des statistiques (simplistes) sur la base du nombre d'opérations et du nombre de bytes impliqués dans chaque cas.

#include "zone_transit.h"
#include <string>
#include <thread>
#include <atomic>
#include <chrono>
#include <iostream>
using namespace std;
using namespace std::chrono;
struct stats {
   size_t ajouts{}, bytes_ajoutes{},
          extractions{}, bytes_extraits{};
   friend ostream& operator<<(ostream &os, const stats &st) {
      return os << "\n\t" << st.ajouts << " ajouts\n\t\t" << st.bytes_ajoutes << " bytes\n\t"
                << st.extractions << " extractions\n\t\t" << st.bytes_extraits << " bytes";
   }
};

Chaque test se fera sur la base d'une zone de transit et d'une durée. Pendant le test, deux threads partageront la zone de transit : l'un qui y insèrera un message significatif de manière répétée et l'autre qui en extraira des données.

J'ai utilisé des variables locales dans chaque cas pour comptabiliser les statistiques, pour ne les rendre disponibles que d'un seul coup à la fin du traitement. Ceci permet d'éviter les effets adverses du faux-partage sur une donnée partagée.

template <class Z, class D>
   stats test(Z && zt, D duree) {
      stats resultat;
      atomic<bool> fini{ false };
      auto avant = high_resolution_clock::now();
      thread th0{
         [&] {
            size_t ajouts{}, bytes_ajoutes{};
            const string msg = "J'aime mon prof";
            while (!fini) {
               zt.ajouter(msg);
               ++ajouts;
               bytes_ajoutes += msg.size();
            }
            resultat.ajouts = ajouts;
            resultat.bytes_ajoutes = bytes_ajoutes;
         }
      };
      thread th1{
         [&] {
            size_t extractions{}, bytes_extraits{};
            while (!fini) {
               auto s = zt.extraire();
               ++extractions;
               bytes_extraits += s.size();
            }
            resultat.extractions = extractions;
            resultat.bytes_extraits = bytes_extraits;
         }
      };
      this_thread::sleep_for(duree);
      fini = true;
      th1.join();
      th0.join();
      return resultat;
   }

Le programme principal détermine la durée des tests (cinq secondes dans l'exemple à droite) et le type de zone de transit à utiliser, puis affiche les résultats des tests dans chaque cas. Seul le message indiquant la nature du test diffèrera d'un programme de test à l'autre.

int main() {
   cout << "Test avec mutex polymorphiques\n" << string(60, '-') << endl;
   const auto duree_test = 5s;
   auto stats_local = test(zone_transit<string> { Mutex::Local{} }, duree_test);
   cout << "Mutex local, en " << duree_test.count() << " secondes:" << stats_local << endl;
   auto stats_systeme = test(zone_transit<string> { Mutex::Systeme{} }, duree_test);
   cout << "Mutex systeme, en " << duree_test.count() << " secondes:" << stats_systeme << endl;
}

Passons maintenant aux deux approches dont nous discuterons aujourd'hui.

Approche A – polymorphisme encapsulé dans une coquille pImpl, avec fragmentation, mouvement léger

Cette approche est un pImpl classique : l'implémentation est poussée dans le fichier source, l'interface se limite à un type incomplet nommé Mutex::Impl, mais en pratique, Mutex::Impl est une interface (une classe abstraite sans états) dont dérivent deux implémentations distinctes, une modélisant un mutex local et l'autre modélisant un mutex système.

Le fichier Mutex.h dans cette approche serait :

#ifndef MUTEX_H
#define MUTEX_H
#include <memory>
class Mutex {
public:
   struct Impl;
private:
   std::unique_ptr<Impl> p;
public:
   class Local {};
   class Systeme {};
   static constexpr Local local{};
   static constexpr Systeme systeme{};
   Mutex(Local);
   Mutex(Systeme);
   Mutex(Mutex&&);
   Mutex& operator=(Mutex&&);
   ~Mutex();
   void lock() const noexcept;
   void unlock() const noexcept;
};
class Autoverrou {
   const Mutex &m;
public:
   Autoverrou(const Mutex &m) noexcept : m{ m } {
      m.lock();
   }
   ~Autoverrou() {
      m.unlock();
   }
};
#endif

Le fichier Mutex.cpp dans cette approche serait :

#include "Mutex.h"
#include <memory>
#include <windows.h>
using namespace std;
struct Mutex::Impl {
   virtual void lock() const = 0;
   virtual void unlock() const = 0;
   virtual ~Impl() = default;
};
class MutexSysteme : public Mutex::Impl {
   HANDLE h;
public:
   MutexSysteme() noexcept : h{ CreateMutex(0, FALSE, 0) } {
   }
   void lock() const noexcept override {
      WaitForSingleObject(h, INFINITE);
   }
   void unlock() const noexcept override {
      ReleaseMutex(h);
   }
   ~MutexSysteme() {
      CloseHandle(h);
   }
};
class MutexLocal : public Mutex::Impl {
   mutable CRITICAL_SECTION cs;
public:
   MutexLocal() noexcept {
      InitializeCriticalSection(&cs);
   }
   ~MutexLocal() {
      DeleteCriticalSection(&cs);
   }
   void lock() const noexcept override {
      EnterCriticalSection(&cs);
   }
   void unlock() const noexcept override {
      LeaveCriticalSection(&cs);
   }
};
Mutex::Mutex(Local) : p{ make_unique<MutexLocal>() } {
}
Mutex::Mutex(Systeme) : p{ make_unique<MutexSysteme>() } {
}
Mutex::Mutex(Mutex &&autre) = default;
Mutex& Mutex::operator=(Mutex &&autre) = default;
Mutex::~Mutex() = default;
void Mutex::lock() const noexcept {
   p->lock();
}
void Mutex::unlock() const noexcept {
   p->unlock();
}

Quelques constats :

Approche B – polymorphisme encapsulé dans une coquille pImpl, sans fragmentation, mouvement dispendieux

Cette approche est un pImpl avec gestion manuelle de la mémoire. Ici encore, l'implémentation est poussée dans le fichier source, l'interface se limite à un type incomplet nommé Mutex::Impl, mais en pratique, Mutex::Impl est une interface (une classe abstraite sans états) dont dérivent deux implémentations distinctes, une modélisant un mutex local et l'autre modélisant un mutex système. La subtilité tient ici au fait que plutôt que d'allouer l'implémentation dynamiquement, le Mutex contient un tampon interne suffisamment grand pour contenir la plus grosse des implémentations possibles, et fait donc en sorte de placer son implémentation dans la même zone mémoire que le Mutex lui-même.

La valeur de Mutex::BUFSZ a été choisie avec soin pour que sizeof(Mutex)==32 s'avère. Si révéler les détails d'implémentation avait été permis pour ce travail, nous aurions pu calculer directement la valeur de Mutex::BUFSZ par métaprogrammation dans Mutex.h. Ici, puisque les détails d'implémentation devaient être occultés, la valeur a été choisie manuellement : j'ai bêtement examiné sizeof(MutexLocal) et sizeof(MutexSysteme) pour voir la taille de ces deux types et m'assurer que Mutex::BUFSZ soit suffisant; je fais par contre une assertion statique pour valider cette supposition dans le fichier Mutex.cpp (ce qui explique que Mutex::BUFSZ soit public).

Le fichier Mutex.h dans cette approche serait :

#ifndef MUTEX_H
#define MUTEX_H
#include <memory>
class Mutex {
public:
   struct Impl;
   enum { BUFSZ = 28 }; // notez ceci
private:
   alignas(std::max_align_t) char buf[BUFSZ];
   Impl *p;
public:
   class Local {};
   class Systeme {};
   static constexpr Local local{};
   static constexpr Systeme systeme{};
   Mutex(Local);
   Mutex(Systeme);
   Mutex(const Mutex&) = delete;
   Mutex& operator=(const Mutex&) = delete;
   Mutex(Mutex&&) noexcept;
   Mutex& operator=(Mutex&&) noexcept;
   ~Mutex();
   void lock() const noexcept;
   void unlock() const noexcept;
};
class Autoverrou {
   const Mutex &m;
public:
   Autoverrou(const Mutex &m) noexcept : m{ m } {
      m.lock();
   }
   ~Autoverrou() {
      m.unlock();
   }
};
#endif

Le fichier Mutex.cpp dans cette approche serait :

#include "Mutex.h"
#include <memory>
#include <cstdlib>
#include <windows.h>
using namespace std;
struct Mutex::Impl {
   virtual void lock() const = 0;
   virtual void unlock() const = 0;
   virtual ~Impl() = default;
};
class MutexSysteme : public Mutex::Impl {
   HANDLE h;
public:
   MutexSysteme() noexcept : h{ CreateMutex(0, FALSE, 0) } {
   }
   void lock() const noexcept override {
      WaitForSingleObject(h, INFINITE);
   }
   void unlock() const noexcept override {
      ReleaseMutex(h);
   }
   ~MutexSysteme() {
      CloseHandle(h);
   }
};
class MutexLocal : public Mutex::Impl {
   mutable CRITICAL_SECTION cs;
public:
   MutexLocal() noexcept {
      InitializeCriticalSection(&cs);
   }
   ~MutexLocal() {
      DeleteCriticalSection(&cs);
   }
   void lock() const noexcept override {
      EnterCriticalSection(&cs);
   }
   void unlock() const noexcept override {
      LeaveCriticalSection(&cs);
   }
};
static_assert(sizeof(MutexLocal)   <= Mutex::BUFSZ, "Espace insuffisant pour cette manoeuvre");
static_assert(sizeof(MutexSysteme) <= Mutex::BUFSZ, "Espace insuffisant pour cette manoeuvre");
Mutex::Mutex(Local) : p{ new (static_cast<void*>(buf + 0)) MutexLocal } {
}
Mutex::Mutex(Systeme) : p{ new (static_cast<void*>(buf + 0)) MutexSysteme } {
}
Mutex::Mutex(Mutex &&autre) noexcept {
   memcpy(begin(buf), begin(autre.buf), sizeof(buf)); // copy(begin(autre.buf), end(autre.buf), begin(buf));
   fill(begin(autre.buf), end(autre.buf), '\0');
   p = reinterpret_cast<Impl*>(buf + 0);
   autre.p = {};
}
Mutex& Mutex::operator=(Mutex &&autre) noexcept {
   if (this != &autre) {
      memcpy(begin(buf), begin(autre.buf), sizeof(buf)); // copy(begin(autre.buf), end(autre.buf), begin(buf));
      fill(begin(autre.buf), end(autre.buf), '\0');
      p = reinterpret_cast<Impl*>(buf + 0);
      autre.p = {};
   }
   return *this;
}
Mutex::~Mutex() {
   if (p) p->~Impl();
}
void Mutex::lock() const noexcept {
   p->lock();
}
void Mutex::unlock() const noexcept {
   p->unlock();
}

Quelques constats :

Cette approche ressemble à ce que fait std::string avec le Small String Optimization, ou à ce que fait std::function avec le Small Object Optimization. Pour fins de simplicité, j'ai appliqué à l'attribut buf le pire alignement possible.

Voilà!


Valid XHTML 1.0 Transitional

CSS Valide !