--- author: - Orestis Malaspinas, Steven Liatti title: Introduction aux variables de condition autoSectionLabels: false autoEqnLabels: true eqnPrefix: - "éq." - "éqs." chapters: true numberSections: false chaptersDepth: 1 sectionsDepth: 3 lang: fr documentclass: article papersize: A4 cref: false urlcolor: blue toc: false date: 2020-01-01 mathjax: on include-before: <script src="css/prism.js"></script> --- # Les variables de condition Le contenu de ce chapitre est basé sur l'excellent livre de R. H. Arpaci-Dusseau et A. C. Arpaci-Dusseau[^4]. Après avoir discuté les verrous, et leur construction, nous allons discuter d'une autre structure qui est nécessaire pour construire des applications concurrentes: les **variables de conditions**. Les variables de condition sont utilisées lorsque qu'un fil d'exécution doit d'abord vérifier une condition avant de continuer. Le problème peut s'illustrer de la façon suivante ```language-c void *foo() { printf("Nous y sommes.\n"); // On veut signaler qu'on a terminé: // mais comment faire? return NULL; } int main() { printf("Début du programme.\n"); pthread_t tid; pthread_create(&tid, NULL, foo, NULL); // On aimerait attendre sur la fonction foo // mais comment faire? printf("Fin du programme.\n"); return EXIT_SUCCESS; } ``` Une solution serait d'utiliser une variable partagée entre le thread principal et le thread enfant comme dans l'exemple ci-dessous ```language-c bool done = false; void *foo() { printf("Nous y sommes.\n"); done = true; return NULL; } int main() { printf("Début du programme.\n"); pthread_t tid; pthread_create(&tid, NULL, foo, NULL); while (!done) { // do nothing, just wait } printf("Fin du programme.\n"); return EXIT_SUCCESS; } ``` Cette solution, bien que très simple, est très inefficace. En effet, le thread principal va tourner dans la boucle `while`{.language-c} et gaspiller des ressources pour rien. Par ailleurs, il est assez simple d'imaginer des situations où on peut produire du code qui sera simplement faux. ## Définition Pour pallier aux problèmes d'inefficacité et de justesse que nous venons de discuter, on utilise les **variables de condition**. Une variable de condition est une file d'attente, dans laquelle les fils d'exécution peuvent se mettre lorsqu'une condition n'est pas satisfaite. Ils y attendront que la dite condition soit remplie. Un autre thread peut alors, lorsque l'état de la condition change, réveiller un ou plusieurs fils qui sont en attente pour leur permettre de continuer. Il va **signaler** aux threads en attente de se réveiller. Les variables de conditions dans la librairie POSIX se déclare via le type `pthread_cond_t`{.labguage-c}. Une telle variable se manipule via les fonctions[^1] ```language-c pthread_cond_wait(pthread_cond_t *c, pthread_mutex_t *m); pthread_cond_signal(pthread_cond_t *c); ``` Ces deux fonctions, sont les appels fondamentaux des variables de condition. La fonction `cond_signal()`{.language-c} (on va utiliser ici les raccourcis `cond_wait()`{.language-c} et `cond_signal()`{.language-c} car je suis flemmard) est utilisée pour signaler qu'une variable a changé d'état et réveille un thread. La fonction `cond_wait()`{.language-c} a la responsabilité de mettre le thread qui l'appelle en attente. Il faut noter que cette fonction prend un `mutex`{.language-c} en paramètre. Par ailleurs, `cond_wait()`{.language-c} fait l'hypothèse que le `mutex`{.language-c} est verrouillé et a la responsabilité de libérer le verrou et de mettre le thread en attente. Puis, lorsque le dit thread doit se réveiller (parce qu'un autre fil d'exécution le lui a signalé) `cond_wait()`{.language-c} doit acquérir le verrou à nouveau et continuer l'exécution du thread. Cette partie est quelque peu complexe. Pour la comprendre considérons le code ci-dessous ```language-c bool done = false; pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond = PTHREAD_COND_INITIALIZER; void cond_wait() { pthread_mutex_lock(&mutex); while (!done) { pthread_cond_wait(&cond, &mutex); } pthread_mutex_unlock(&mutex); } void cond_signal() { pthread_mutex_lock(&mutex); done = true; pthread_cond_signal(&cond); pthread_mutex_unlock(&mutex); } void *foo() { printf("Nous y sommes.\n"); cond_signal(); return NULL; } int main() { printf("Début du programme.\n"); pthread_t tid; pthread_create(&tid, NULL, foo, NULL); cond_wait(); printf("Fin du programme.\n"); return EXIT_SUCCESS; } ``` Il y a deux cas distincts: 1. Le thread principal crée un thread enfant et continue son exécution. Il acquière le `mutex`{.language-c} et est mis en attente lorsqu'il appelle `cond_wait()`{.language-c} et libère le verrou. A un moment ou un autre, le fil enfant s'exécutera. En appelant la fonction `cond_signal()`{.language-c}, il verrouillera le `mutex`{.language-c}, assignera la valeur `true`{.language-c} à la variable `done`{.language-c} et appellera `pthread_cond_signal()`{.language-c}, ce qui aura pour effet de réveiller le thread principal. Le thread principal sera réveillé et aura le `mutex`{.language-c} dans un état verrouillé, sortira de la boucle `while`{.language-c} déverrouillera le `mutex`{.language-c} et terminera son exécution. 2. Le thread enfant est exécuté immédiatement, il appelle `cond_signal()`{.language-c} et il modifie `done`{.language-c}. Comme il n'y a pas de thread en attente, rien ne se passe. Le thread principal peut donc finir son exécution tranquillement. --- Remarque # Comme nous allons le voir plus bas, il est très important d'utiliser la boucle `while`{.language-c} bien qu'ici cela ne soit pas strictement nécessaire. --- Il se pourrait que vous soyez tenté·e·s de modifier quelque peu l'implémentation ci-dessus. N'en faites rien, cela pourrait causer des dommages irréversibles à vos codes. Changeons un peu les différentes parties du programme pour voir ce qui peut se passer. Si nous remplaçons les fonctions `cond_wait()`{.language-c} et `cond_signal()`{.language-c} par les codes ci-dessous (il va y avoir plusieurs exemples faux) ```language-c void cond_wait() { pthread_mutex_lock(&mutex); pthread_cond_wait(&cond, &mutex); pthread_mutex_unlock(&mutex); } void cond_signal() { pthread_mutex_lock(&mutex); pthread_cond_signal(&cond); pthread_mutex_unlock(&mutex); } ``` --- Question # Pourquoi cette façon de faire est-elle problématique? --- Dans ce cas, on utilise pas la variable `done`{.language-c}. Hors, si on imagine qu'on est dans le cas où le thread enfant est exécuté immédiatement, il va effectuer la signalisation et retourner immédiatement. Le thread principal lui va ensuite attendre indéfiniment, car plus aucun autre thread ne lui signalera jamais de se réveiller. L'exemple suivant est également faux. Nous nous débarrassons cette fois du verrou ```language-c void cond_wait() { if (!done) { pthread_cond_wait(&cond, &mutex); } } void cond_signal() { done = true; pthread_cond_signal(&cond); } ``` --- Question # Pourquoi cette façon de faire est-elle également problématique? --- Il y a ici un problème d'accès concurrent. Si le thread enfant appelle `cond_wait()`{.language-c} et voit que `done == false`{.language-c} il voudra se mettre en attente. Hors, si à ce moment précis il y a un changement de contexte et `cond_signal()`{.language-c} est appelé, il signalera la condition, mais comme aucun fil n'est en attente, ce signal sera perdu. Le thread enfant sera à un moment ou un autre ré-ordonnancé et sera mis en attente. Comme aucun autre signal ne sera jamais émis il restera endormi à jamais. De ces deux mauvais exemples, on peut tirer un enseignement. Même si cela n'est pas toujours nécessaire, il faut **toujours** détenir le verrou avant de signaler une condition. A l'inverse, il est toujours nécessaire d'avoir un verrou lors de l'appel de `pthread_cond_wait()`{.language-c}. La fonction `pthread_cond_wait()`{.language-c} présuppose que le verrou est détenu lors de son appel. La règle générale est finalement: quoi qu'il arrive avant d'attendre ou de signaler acquérir le verrou (et le libérer ensuite). ## Le problème producteurs/consommateurs Le problème des **producteurs/consommateurs** a été à l'origine du développement des variables de condition par Dijkstra. On peut imaginer un ou plusieurs fils d'exécution produisant des données et les plaçant dans une mémoire tampon (buffer). Le ou les consommateurs vont chercher les données dans la mémoire tampon et les utilisent. Un exemple typique est la fonction `pipe` d'un programme dans un autre dans les systèmes UNIX: ```language-bash grep hop file.txt | wc -l ``` Ici un processus exécute la fonction `grep`, qui écrit les lignes du fichier `file.txt` où `hop` est présent sur la sortie standard (enfin c'est ce que `grep` croit). Le système d'exploitation redirige en fait la sortie dans un "tuyau" (*pipe*) UNIX. L'autre côté du *pipe* est connecté à l'entrée du processus `wc` qui compte les lignes dans le flot de données en entrée et affiche le résultat. On a donc que `grep` produit des données et `wc` les consomme avec un buffer entre deux. --- Question # Pouvez-vous imaginer une autre application de ce type? --- Comme la mémoire tampon est une ressource partagée, il faut trouver un mécanisme de synchronisation pour éviter les accès concurrents. Pour simplifier, représentons le buffer par un simple entier[^2]. On a ensuite deux fonctions: * la fonction `put()`{.language-c} qui va insérer une valeur dans la mémoire tampon. * la fonction `get()`{.language-c} qui va récupérer une valeur de la mémoire tampon. Finalement, on a également besoin de savoir combien de données sont stockées dans le buffer. Pour cela on utilise un simple compteur. ```language-c int buffer; int counter = 0; // le compteur void put(int value) { assert(counter == 0 && "trying to put into a full buffer"); counter = 1; buffer = value; } int get() { assert(counter == 1 && "trying to get from empty buffer"); counter = 0; return buffer; } ``` La fonction `put()`{.language-c} suppose que le `buffer` ne contient rien (`counter == 0`{.language-c}) et vérifie l'état avec une `assert()`{.language-c}. Ensuite elle incrémente la valeur du compteur à 1 et assigne la valeur `value`{.language-c} au `buffer`{.language-c}. A l'inverse la fonction `get()`{.language-c} fait l'hypothèse que le `buffer` contient une valeur (`counter == 1`{.language-c}) et vérifie également cet état avec une `assert()`{.language-c}. A présent, il faut des fonctions qui auront la sagesse nécessaire pour appeler les fonctions `put()`{.language-c} et `get()`{.language-c} au moments opportuns (quand `counter` a la bonne valeur, zéro ou un respectivement). Grâce au design astucieux de `put()`{.language-c} et `get()`{.language-c} on saura très vite si on fait quelque chose de faux, car les assertions vont faire planter le programme. Ces actions seront effectuées par deux types de threads: les threads **producteurs** et les threads **consommateurs**. Pour simplifier, écrivons d'abord une version complètement fausse et naive mais qui illustre ce que ferait un thread producteur qui met `loops`{.language-c} valeurs dans le buffer et un thread consommateur qui récupère à l'infini les valeurs stockées dans le buffer. ```language-c // rien n'est initialisé c'est normal c'est une illustration // complètement absurde void *producer(void *arg) { int loops = (int)*arg; for (int i = 0; i < loops; ++i) { put(i); } } void *consumer(void *arg) { while (1) { int i = get(); printf("%d\n", i); } } ``` Il est évident que ce code ne peut pas fonctionner. Les `assert()`{.language-c} passeraient le temps à tout arrêter. On a des sections critiques dans `put()`{.language-c} et `get()`{.language-c} qui ne sont pas du tout gérées. Mais vous voyez l'idée de ce qu'on essaie de faire. Juste utiliser un verrou pour protéger, `put()`{.language-c} et `get()`{.language-c} comme on l'a fait jusqu'ici ne peut pas fonctionner. --- Question # Pourquoi? --- Il nous faut en fait un moyen de prévenir chacun des threads de l'état du buffer afin qu'il puisse effectuer une action appropriée. ### Une solution, fausse La solution la plus simple serait d'ajouter une variable de condition et son verrou associé. Un code du genre de celui se trouvant ci-dessous par exemple ```language-c int loops = 10; // une initialisation au hasard pthread_cond_t cond; pthread_mutex_t mutex void *producer(void *arg) { for (int i = 0; i < loops; ++i) { pthread_mutex_lock(&mutex); if (count == 1) { pthread_cond_wait(&cond, &mutex); } put(i); pthread_cond_signal(&cond); pthread_mutex_unlock(&mutex); } } void *consumer(*void arg) { for (int i = 0; i < loops; ++i) { pthread_mutex_lock(&mutex); if (count == 0) { pthread_cond_wait(&cond, &mutex); } int tmp = get(); pthread_cond_signal(&cond); pthread_mutex_unlock(&mutex); printf("%d\n", tmp); } } ``` Si nous avons un thread producteur et un thread consommateur ce code fonctionne. Par contre, si nous ajoutons plus de threads cela ne fonctionne plus. --- Question # Que se passe-t-il dans le cas où nous avons deux threads consommateurs pour un producteur? --- Le premier problème est relié à l'instruction `if`{.language-c} avant l'appel à `wait()`{.language-c}. Le second est relié à l'utilisation d'une unique variable de condition. Voyons à présent ce qui peut se passer. Soient les threads consommateurs $T_{c_1}$ et $T_{c_2}$ et le thread producteur $T_p$. Supposons que $T_{c_1}$ s'exécute. Il acquière le verrou et vérifie s'il y a quelque chose à consommer dans le frigo... euh... dans le buffer. Comme il n'y a rien il se met en attente et libère le verrou. Ensuite le thread $T_p$ s'exécute. Il acquière le verrou et vérifie si le buffer est vide. Comme il l'est il peut y déposer une valeur. Il va ensuite signaler que le buffer est rempli. Cela va mettre $T_{c_1}$ en état de réveil et prêt à être exécuté (il ne s'exécute pas encore). Finalement pour $T_p$, il se rend compte que le buffer est plein et va se mettre en sommeil. A ce moment précis, $T_{c_2}$ est ordonnancé. Il acquière le verrou, lit la valeur dans le buffer, signale qu'il a lu et libère le verrou. A ce moment précis, si $T_{c_1}$ est ordonnancé, il va acquérir le verrou et continuer son exécution là où elle c'était arrêtée. Il va donc tenter de lire à son tour dans le buffer, mais celui-ci sera vide! L'assertion dans `get()`{.language-c} nous sautera au visage et arrêtera l'exécution. Au moins, nous sommes sauvés d'un comportement erroné que nous croyions correct. Pour prévenir cette erreur, nous aurions dû empêcher $T_{c_1}$ de tenter de consommer une valeur déjà consommée. En fait le signal envoyé par $T_p$ doit être considéré comme une indication que quelque chose a changé. Il n'y a en revanche aucune garantie que cela sera toujours le cas lorsque le thread mis en sommeil sera réveillé. La solution ici est de remplacer le `if`{.language-c} par un `while`{.language-c}. De cette façon $T_{c_1}$ aurait vérifié que la condition a bien changé après son réveil et avoir acquis le verrou. Ainsi il n'aurait pas essayé de consommer une valeur déjà consommée. --- Règle # Toujours utiliser une boucle `while`{.language-c} et jamais de `if`{.language-c}.[^3] --- Voilà un premier problème de réglé, mais nous ne sommes pas sortis d'affaire. En effet, un autre problème existe. Imaginons que $T_{c_1}$ et $T_{c_2}$ s'exécutent en premier et sont mis en sommeil. $T_p$ s'exécute alors, met une valeur dans le buffer et réveille un thread, disons $T_{c_1}$. $T_p$ refait un tour de boucle, libère et réacquière le verrou et réalise que le buffer est plein. Il se met en sommeil. $T_{c_1}$ peut s'exécuter (alors que $T_{c_2}$ et $T_p$ sont endormis). Il refait un tour dans la boucle `while`{.language-c}, trouve le buffer plein et le vide. Il signale qu'il a fini et va se rendormir. Si à présent c'est $T_{c_2}$ qui se réveille (on a aucune garantie sur l'ordre de l'exécution), il va trouver le buffer vide et se rendormir. On a donc les trois threads en sommeil avec aucun moyen de les réveiller (pas de prince·esse charmant·e, ni rien)! On a besoin d'un autre signal qui soit dirigé: un consommateur doit réveiller un producteur et vice-versa. Pour corriger ce bug, nous pouvons simplement utiliser **deux** variables de condition: une relié au remplissage du buffer, l'autre à sa vidange comme dans le code ci-dessous. ```language-c pthread_cond_t fill, empty; // pas oublier d'initialiser hein pthread_mutex_t mutex; void *producer(void *arg) { for (int i = 0; i < loops; ++i) { pthread_mutex_lock(&mutex); while (count == 1) { pthread_cond_wait(&empty, &mutex); } put(i); pthread_cond_signal(&fill); pthread_mutex_unlock(&mutex); } } vois *consumer(*void arg) { for (int i = 0; i < loops; ++i) { pthread_mutex_lock(&mutex); while (count == 0) { pthread_cond_wait(&fill, &mutex); } int tmp = get(); pthread_cond_signal(&empty); pthread_mutex_unlock(&mutex); printf("%d\n", tmp); } } ``` Dans ce code, on voit que les threads producteurs attendent sur la condition `empty`{.language-c} et signalent la condition `fill`{.language-c}, alors que les threads consommateurs attendent sur la condition `fill`{.language-c} et signalent la condition `empty`{.language-c}. ### Aller plus haut On vient de voir comment résoudre le problème producteurs/consommateurs pour un buffer unique. Cette solution est généralisable pour permettre des applications plus concurrentes et efficaces. Pour ce faire, on va créer un buffer avec plus d'espaces. Ainsi il sera possible de produire plus de valeurs et de les consommer avant que les threads se mettent en sommeil. Pour ce faire, nous créons un buffer qui sera cette fois un tableau statique. On doit garder une trace du taux de remplissage, ainsi que de la position à laquelle nous devons lire. On modifie donc les fonctions `put()`{.language-c} et `get()`{.language-c} de la façon suivante ```language-c #define MAX 100; int buffer[MAX]; int fill_pos = 0; int read_pos = 0; int count = 0; void put(int value) { buffer[fill_pos] = value; fill_pos = (fill_pos + 1) % MAX; count += 1; } int get() { int tmp = buffer[read_pos]; read_pos = (read_pos + 1) % MAX; count -= 1; return tmp; } ``` Une toute petite adaptation à la vérification nécessaire avant le `wait()`{.language-c} ou `signal()`{.language-c} et le tour est joué ```{.language-c} pthread_cond_t empty, fill; pthread_mutex_t mutex; void *producer(void *arg) { for (int i = 0; i < loops; ++i) { pthread_mutex_lock(&mutex); while (count == MAX) { pthread_cond_wait(&empty, &mutex); } put(i); pthread_cond_signal(&fill); pthread_mutex_unlock(&mutex); } } void *consumer(void *arg) { for (int i = 0; i < loops; ++i) { pthread_mutex_lock(&mutex); while (count == 0) { pthread_cond_wait(&fill, &mutex); } int value = get(); pthread_cond_signal(&empty); pthread_mutex_unlock(&mutex); printf("%d\n", value); } } ``` On voit que le producteur ne se met en sommeil que lorsque le buffer est plein et à l'inverse le consommateur n'appelle `wait()`{.language-c} que lorsque le buffer est vide. ## Signalisation globale Dans certains cas, on aimerait être un peu plus large lorsqu'on réveille des threads. En particulier, on aimerait pouvoir réveiller tous les threads endormis à la fois. Afin d'illustrer le problème considérons l'exemple d'une application d'allocation de mémoire sur la pile. La pile a une taille maximale `MAX_HEAP_SIZE`{.language-c}. Nos threads vont allouer un certain nombre d'octets `void *allocate(int size)`{.language-c} et libérer un certain nombre de bytes de la mémoire `void free(void *ptr, int size)`{.language-c}. Lorsque la mémoire est pleine, les fils d'exécution qui sont en charge de l'allocation doivent se mettre en sommeil. Lorsque de la mémoire se libère il faut signaler que de la place est à nouveau disponible. Une approche simple serait le code suivant ```language-c int avail_bytes = MAX_HEAP_SIZE; // nombre d'octets disponibles // comme d'habitude, une variable de condition et son verrou pthread_cont_t cond; pthread_mutex_t mutex; void *allocate(int size) { pthread_mutex_lock(&mutex); while (avail_bytes < size) { // on doit avoir assez de mémoire pthread_cond_wait(&cond, &mutex); } void *ptr = ...; // on récupère la mémoire depuis le tas avail_bytes -= size; pthread_mutex_unlock(&mutex); return ptr; } void free(void *ptr, int size) { pthread_mutex_lock(&mutex); avail_bytes += size; pthread_cond_signal(&cond); pthread_mutex_unlock(&mutex); } ``` --- Question # Ce code a un problème. Lequel? --- Imaginons qu'à un moment donné on ait plus de place en mémoire (`avail_bytes == 0`{.language-c}). Un premier thread $T_1$ appelle `allocate(100)`{.language-c} et un deuxième thread $T_2$ appelle `allocate(10)`{.language-c}. Chacun de ces threads va appeler `wait()`{.language-c} et se mettre en sommeil. A présent, un troisième thread appelle `free(40)`{.language-c}, il libère 40 bytes et signale qu'il y a de la mémoire disponible. Si $T_1$ est réveillé, il retournera en sommeil car il n'y a pas la place suffisante pour allouer et le programme se retrouve à ne rien faire alors qu'il pourrait. Il suffisait de réveiller $T_2$ bon sang! La solution la plus simple ici est de réveiller **tous** les threads grâce à la fonction `pthread_cond_broadcast()`{.language-c} ```language-c int pthread_cond_broadcast(pthread_cond_t *cond); ``` Tous les threads attendant après l'appel de `wait()`{.language-c} seront réveillés, ils vérifieront le nombre de bytes disponibles et se remettront en sommeil immédiatement si l'espace n'est pas disponible. [^1]: Il ne faut pas oublier qu'il faut également initialiser les variables de condition et éventuellement les détruire aussi. [^2]: Cela pourrait aussi être un pointeur vers une structure de donnée plus complexe. [^3]: C'est pas toujours nécessaire, mais ça ne fait pas de mal! [^4]: R. H. Arpaci-Dusseau et A. C. Arpaci-Dusseau, *Operating Systems: Three Easy Pieces*, Arpaci-Dusseau Books, ed. 0.91, (2015).