Introduction aux condition_variable

Certains des exemples ci-dessous utilisent des λ et des std::future, sur lesquelles vous souhaiterez peut-être lire au préalable.

Le mécanisme standard que propose C++, depuis C++ 11, pour qu'un thread puisse se suspendre en attente d'un événement ou encore pour qu'un thread puisse signaler l'occurrence d'un événement est la condition_variable.

Il est plus facile de comprendre l'action du type condition_variable avec un exemple. Ainsi, examinons le code ci-dessous :

#include <condition_variable>
#include <iostream>
#include <future>
#include <vector>
#include <mutex>
using namespace std;
int main() {
   vector<future<void>> v;
   mutex m;
   condition_variable cond;
   for (int i = 0; i < 10; ++i)
      v.push_back(async([&](int n) {
         unique_lock<mutex> verrou{m};
         cond.wait(verrou);
         cout << "Je suis le thread " << n << endl;
      }, i + 1));
   char c;
   cin >> c;
   cond.notify_all();
   for(auto &f : v) f.wait();
}

Ce que fait ce petit programme est lancer de manière asynchrone plusieurs tâches, dont les futures sont entreposées dans le vecteur v, de telle sorte que ces tâches se mettent toutes en attente d'un événement. Par la suite, le programme principal bloque jusqu'à ce que l'usager entre une touche au clavier puis appuie sur <enter>. Suite à cette action, l'événement et provoqué, ce qui débloque les threads en attente qui produisent tous une séquence de messages.

Cet exemple est un peu simpliste, et légèrement incorrect (nous y reviendrons), mais permet de montrer en surface l'utilisation d'une condition_variable :

La mise en suspension d'un thread en attente sur une condition_variable fait en sorte que ce thread ne consomme pas de temps processeur pour la période où il est suspendu.

Voilà pour l'introduction.

Réveils intempestifs (Spurious Wakeups)

Il est toujours possible qu'un wait() débloque de manière intempestive, donc pas pour les bonnes raisons. Ceci est dû à des causes physiques sur lesquelles nous n'avons en général pas le contrôle (en fait, sur des systèmes multiprocesseurs, il serait possible d'éviter les réveils intempestifs, mais cela rendrait beaucoup moins rapide le recours à une condition_variable).

Pour valider qu'un wait() se soit débloqué pour les bonnes raisons, il est d'usage d'associer à un wait() un prédicat qui validera les raisons du réveil. En C++, les λ sont l'outil privilégié pour faire cette validation, mais tout prédicat sans paramètre conviendrait.

Reprenons l'exemple plus haut en ajoutant ce filet de sécurité. Pour cette nouvelle mouture, pour diversifier le portrait, je me limiterai à des threads bruts, mais la fonction de la condition_variable sera la même.

Nous utiliserons un booléen nommé go, initialement faux mais qui ne deviendra true que lorsqu'une touche aura été pressée, pour formaliser le signal de déblocage des threads mis en attente.

Vous serez peut-être étonné(e) que ce booléen ne soit pas atomique, mais ce serait superflu : les opérations sur des condition_variable sont synchronisées sur un verrou (un unique_lock), donc nous n'avons pas de Data Race ici.

#include <thread>
#include <mutex>
#include <iostream>
#include <vector>
#include <condition_variable>
using namespace std;
int main() {
   bool go = {};
   mutex m;
   condition_variable cv;
   vector<thread> v;

Les divers threads prennent en charge des λ et se mettent en attente sur une même condition_variable, de manière synchronisée comme il se doit sur un même verrou et un même mutex.

L'ajout que nous faisons ici est d'associer un prédicat (une autre λ) à chaque mise en suspens. Ce prédicat testera la valeur de la variable go, capturée par référence bien entendu, et retournera true seulement si go est aussi true. Ainsi, si un réveil intempestif survient, le prédicat ne s'avèrera pas et le réveil se conclura par une remise en suspens du thread.

// ...
   for (int i = 0; i < 10; ++i)
      v.emplace_back(
         thread{[&cv, &go, &m, i]() {
            unique_lock<mutex> verrou{m};
            cv.wait(verrou, [&]() { return go; });
            cout << "Youppi, thread " << i << '!' << endl;
         }
      });

Dans le thread principal, la variable go devient true juste avant la notification sur la condition_variable, qui est une opération synchronisée. Conséquemment, lorsque les threads se réveilleront, leur prédicat s'avèrera à partir de ce moment.

// ...
   char c;
   cout << "Appuyez sur une touche pour démarrer... " << endl;
   cin >> c;
   go = true;
   cv.notify_all();
   for (auto & th : v) th.join();
}

Lectures complémentaires

Extrait d'un échange en-ligne dans lequel Anthony Williams explique à Billy O'Neal les Condition Variables telles qu'elles sont implémentées dans Boost :

Anthony Williams : « When a thread waits on the CV it is add to a "generation". All threads in a generation are waiting on the same semaphore. When the CV is notified, the "wake" semaphore is notified, as is the semaphore for each generation currently waiting. So, if there are 5 generations waiting, one thread from each gen will wake, but all but one will then go back to sleep. The reason for this is that notify_all then wakes everyone, and generates a new semaphore and generation set. This prevents "stolen wakes", where a later waiter is woken by an earlier signal, as early signals are no longer visible. »

Billy O'Neal : « The tricky bit with CV is always making sure unlocking the external mutex and going to sleep are done atomically. »

Anthony Williams : « Yes. do_wait locks the internal mutex, adds to a generation, and then unlocks the external mutex before waiting. »

Billy O'Neal : « That just moves the problem to the internal mutex. Worried about lost wake between unlocking that and sleeping. »

Anthony Williams : « That's the thing about the generations. The semaphores have a high max, so count wakeups, so you can sleep after the notify. »

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !