Objet verrouillable – un type Guarded<T>

Ceci est inspiré d'une présentation de Herb Sutter à CppCon 2018.

Supposons que nous souhaitons non pas utiliser une classe combinant un outil de synchronisation et un objet à synchroniser, comme dans le cas d'une zone de transit, masi bien un objet lui-même verrouillable, offrant une interface classique comme celle d'un mutex (le trio lock(), try_lock(), unlock()) de manière à ce qu'il soit possible de le manipuler à la fois comme la donnée qu'il encapsule et comme le verrou qui le protège.

Le truc est tout simple :

  • Encapsuler le mutex et l'objet dans une même classe, disons Guarded<T> pour un type T donné
  • Offrir l'interface du mutex par délégation, et
  • Offrir l'interface du T par l'opérateur ->. Notez ici qu'il aurait pu être sage d'utiliser un observer_ptr plutôt qu'un pointeur brut, bien que ce ne soit pas dramatique
#include <mutex>
template <class T>
   class Guarded {
      mutable std::mutex m;
      T obj;
   public:
      template <class U>
      Guarded(U && obj) : obj { std::forward<U>(obj) } {
      }
      auto try_lock() { return m.lock(); }
      void lock() { m.lock(); }
      void unlock() { m.unlock(); }
      auto operator->() { return &obj; }
      auto operator->() const { return &obj; }
   };
template <class T> Guarded(T) -> Guarded<T>;

Le code en exemple à droite montre comment il serait possible de verrouiller une Guarded<std::string> puis d'en appeler la méthode size() de manière synchronisée.

#include <string>
#include <iostream>
int main() {
    using namespace std;
    auto s = Guarded("Yo"s);
    lock_guard _{ s };
    cout << s->size() << endl;
}

Et si nous souhaitions nous assurer que les accès à la ressource ne soient faits que par un thread détenteur du verrou, pour dépister des bogues? C'est à peine plus difficile : l'idée est de savoir, avant d'accéder à l'objet verrouillable, si le thread courant est propriétaire du verrou.

La technique pour y arriver est simple :

  • Chaque thread a un identifiant unique, de type thread::id
  • Notre Guarded<T> garde un tel identifiant à l'interne
  • Chaque verrouillage réussi est suivi de la capture de l'identifiant du thread actif
  • Chaque déverrouillage est précédé par la réinitialisation de cet identifiant
  • Chaque tentative d'accès à l'objet encapsulé valide d'abord que le thread actif est propriétaire du verrou, et lève une exception si tel n'est pas le cas

C'est aussi simple que ça

#include <mutex>
#include <thread>
class not_owner{};
template <class T>
   class Guarded {
      mutable std::mutex m;
      std::thread::id id{};
      T obj;
      void ensure_ownership() {
         if (id != std::this_thread::get_id()) throw not_owner{};
      }
   public:
      template <class U>
      Guarded(U && obj) : obj { std::forward<U>(obj) } {
      }
      auto try_lock() {
         if(auto ok = m.lock(); ok) {
            id = std::this_thread::get_id();
         }
         return m.lock();
      }
      void lock() {
         m.lock();
         id = std::this_thread::get_id();
      }
      void unlock() {
         id = {};
         m.unlock();
      }
      auto operator->() {
         ensure_ownership();
         return &obj;
      }
      auto operator->() const {
         ensure_ownership();
         return &obj;
      }
   };
template <class T> Guarded(T) -> Guarded<T>;

Valid XHTML 1.0 Transitional

CSS Valide !