Synchronisation avec verrous

« "Mutexes" should have been called "bottlenecks" to make it more obvious what they actually do » (Kevlin Henney)

Pour des ressources plus générales sur la synchronisation, voir Synchronisation.html

Cette page sera enrichie pour discuter de mutex récursifs, de sémaphores, de multiple-readers single-writer locks, etc.

La technique la plus simple pour synchroniser l'accès à des états d'un programme est de recourir à des verrous.

Mutex

Le verrou le plus simple sur le plan conceptuel est le mutex, pour Mutual Exclusion, qui représente un droit d'accès exclusif à une ressource :

Avec C++ Avec Java Avec C#
#include <thread>
#include <mutex>
using namespace std;
int main() {
   mutex m;
   auto th0 = thread {
      [&]() {
         lock_guard<mutex> _ { m };
         cout << "Je suis "
              << "le thread "
              << "zero" << endl;
      }
   };
   auto th1 = thread {
      [&]() {
         lock_guard<mutex> _ { m };
         cout << "Je suis "
              << "le thread "
              << "un" << endl;
      }
   };
   th1.join();
   th0.join();
}
public class Test {
   static class Thr extends Thread {
      Object m;
      String id;
      public Thr(Object m, String id) {
         this.m = m;
         this.id = id;
      }
      public void run() {
         synchronized(m) {
            System.out.print("Je suis ");
            System.out.print("le thread ");
            System.out.println(id);
         }
      }
   }
   public static void main(String [] args) {
      Object mutex = new Object();
      Thr th0 = new Thr(mutex, "zero");
      Thr th1 = new Thr(mutex, "un");
      th0.start();
      th1.start();
   }
}
using System;
using System.Threading;
namespace Test
{
   class Program
   {
      static void Main(string[] args)
      {
         Mutex m = new Mutex();
         var th0 = new Thread(() =>
         {
            lock (m)
            {
               Console.Write("Je suis ");
               Console.Write("le thread ");
               Console.WriteLine("zero");
            }
         });
         var th1 = new Thread(() =>
         {
            lock (m)
            {
               Console.Write("Je suis ");
               Console.Write("le thread ");
               Console.WriteLine("un");
            }
         });
         th0.Start();
         th1.Start();
         th1.Join();
         th0.Join();
      }
   }
}

Ces trois exemples font la même chose, soit synchroniser une séquence d'écriture à la sortie standard pour éviter un entremêlement des messages.

Le zoo des mutex

Les mutex de C++ forment un petit zoo d'options pour qui souhaite synchroniser son code concurrent à l'aide de verrous :

Outil mutex timed_mutex shared_mutex shared_timed_mutex
En-tête <mutex> <mutex> <shared_mutex> <shared_mutex>
Depuis C++ 11 C++ 11 C++ 17 C++ 14
lock()

Oui

Oui

Oui

Oui

unlock()

Oui

Oui

Oui

Oui

try_lock()

Oui

Oui

Oui

Oui

try_lock_for()

Non

Oui

Non

Oui

try_lock_until()

Non

Oui

Non

Oui

lock_shared()

Non

Non

Oui

Oui

unlock_shared()

Non

Non

Oui

Oui

try_lock_shared()

Non

Non

Oui

Oui

try_lock_shared_for()

Non

Non

Non

Oui

try_lock_shared_until()

Non

Non

Non

Oui

Les versions _shared permettent à plusieurs fils d'exécution de verrouiller un même mutex, typiquement pour permettre la lecture concurrente.

Les versions _timed offrent des mécanismes pour tenter de verrouiller un mutex un certain temps, puis d'abandonner s'ils n'y parviennent pas.

Habituellement, pour une plateforme donnée, les mécanismes les moins « puissants » peuvent être implémentés plus efficacement que les mécanismes les plus « puissants », et sont aussi un peu plus légers. Pour cette raison, les programmeuses et les programmeurs auront tendance à utiliser le mécanisme le moins « puissant » qui suffise à leurs besoins.

Pour faciliter le verrouillage et automatiser le déverrouilage, on aura typiquement recours aux mécanismes suivants (notez que j'utilise CTAD de C++ 17 pour alléger l'écriture), tous incopiables et tous RAII :

lock_guard<T>

Type dont le constructeur verrouille un mutex et un destructeur le déverrouille. Une implémentation maison simple et naïve (en pratique, std::lock_guard offre d'autres modalités de prise en charge d'un mutex que celle-ci) serait :

template <class M>
class lock_guard {
   M &m;
public:
   lock_guard(const lock_guard&) = delete;
   lock_guard& operator=(const lock_guard&) = delete;
   lock_guard(M &m) : m{ m } { m.lock(); }
   ~lock_guard() { m.unlock(); }
};
// ...
mutex m;
// ...
{
   lock_guard _{ m }; // m.lock()
   // ...
} // m.unlock()
unique_lock<T>

Un unique_lock est seul responsable du mutex, mais offre une gamme de services bien plus complexe que ne le fait un lock_guard : il peut être testé pour savoir s'il possède ou non un mutex, il peut le déverrouiller, le verrouiller à nouveau ultérieurement, etc.

// ...
mutex m;
// ...
{
   unique_lock _{ m }; // m.lock()
   // ...
} // m.unlock()
scoped_lock<Ts...>

Un scoped_lock est un peu comme un lock_guard mais est capable de prendre en charge un nombre variadique de mutex.

// ...
mutex m0, m1, m2;
// ...
{
   scoped_lock _{ m0, m1, m2 }; // lock() dans un ordre
                                // fixé et indépendant de
                                // l'ordre des paramètre 
   // ...
} // unlock() en ordre inverse des lock()
shared_lock<T>

Un shared_lock permet de réaliser des cas plus subtils comme le verrouillage pour plusieurs lecteurs et un seul scripteur

Voir Verrous-SWMR.html Pour des exemples

Sémaphores

Depuis C++ 20, le langage C++ offre un en-tête <semaphore> exposant deux types de sémaphores. J'ajouterai des exemples ici dès que j'En aurai le temps.

Un autre type de verrou est le sémaphore, qui généralise le concept de verrou en donnant un accès à demandeurs à une même ressource. Un mutex est au fond un sémaphore pour lequel  .

Le sémaphorem tout comme le mutex, peut être implémenté à même le système d'exploitation, ce qui en fait alors un outil puissant mais lourd (chaque accès est alors un appel système). Pour synchroniser des accès entre threads à l'intérieur d'un même processus, il existe des variantes plus légères, comme les sections critiques sous Microsoft Windows et les futex, pour Fast Userspace Mutex, sous Linux. Notes toutefois que les futex servent principalement à attendre efficacement le passage d'une variable atomique à un état particulier, et sont donc de portée plus limitée que les mutex.

Les implémentations commerciales et standards de mutex offrent typiquement des fonctions de base telles lock() (bloquer jusqu'à obtention de la ressource), try_lock() (tenter d'obtenir la ressource et retourner un code de succès ou d'erreur) avec variantes telles que try_lock_for() et try_lock_until() pour prendre en charge les timeouts, et unlock() (libérer un mutex préalablement acquis).

Notez que typiquement, un try_lock... peut échouer de manière intempestive, mais ne retournera que des faux négatifs (retourner un code d'échec même si le mutex était disponible), jamais de faux positifs (heureusement!); le code client doit être écrit en conséquence.

Le Global Interpreter Lock, ou GIL

Une solution simple, mais terriblement inefficace, pour la synchronisation à travers des verrous est d'utiliser un seul verrou global et de transformer un programme multiprogrammé en programme monoprogrammé lorsque des risques de concurrence surviennent. Quelques langages procèdent ainsi, en particulier Python et Ruby.

Le recours à une mémoire transactionnelle donne l'illusion d'un GIL, sans toutefois reposer sur un tel mécanisme.

Herb Sutter a écrit quelques GOTW (Guru of the Week) en 2014 sur le sujet des conditions de course et de la synchronisation. Voir :

Lectures complémentaires

Quelques liens suivent pour enrichir le propos.

À propos des mutex et de leurs équivalents conceptuels :


Valid XHTML 1.0 Transitional

CSS Valide !