From 1f3fef50d7f11671f30bd0fe0a9a07e1dc9e677f Mon Sep 17 00:00:00 2001 From: Orestis Malaspinas <orestis.malaspinas@hesge.ch> Date: Wed, 20 May 2020 12:14:03 +0200 Subject: [PATCH] updated makefile --- hakyll-bootstrap/Main.hs | 5 +- hakyll-bootstrap/cours/bugs.md | 8 +- hakyll-bootstrap/cours/cond_var.md | 700 ------------------ hakyll-bootstrap/cours/intro_api.md | 536 -------------- hakyll-bootstrap/cours/intro_os.md | 141 ---- hakyll-bootstrap/cours/intro_processus.md | 441 ----------- hakyll-bootstrap/cours/sema.md | 847 --------------------- hakyll-bootstrap/cours/structures_conc.md | 487 ------------ hakyll-bootstrap/cours/verrous.md | 853 ---------------------- hakyll-bootstrap/hakyll-bootstrap.cabal | 3 + hakyll-bootstrap/stack.yaml | 3 + index.md | 2 +- 12 files changed, 16 insertions(+), 4010 deletions(-) delete mode 100644 hakyll-bootstrap/cours/cond_var.md delete mode 100644 hakyll-bootstrap/cours/intro_api.md delete mode 100644 hakyll-bootstrap/cours/intro_os.md delete mode 100644 hakyll-bootstrap/cours/intro_processus.md delete mode 100644 hakyll-bootstrap/cours/sema.md delete mode 100644 hakyll-bootstrap/cours/structures_conc.md delete mode 100644 hakyll-bootstrap/cours/verrous.md diff --git a/hakyll-bootstrap/Main.hs b/hakyll-bootstrap/Main.hs index 6a5313c..79b8597 100644 --- a/hakyll-bootstrap/Main.hs +++ b/hakyll-bootstrap/Main.hs @@ -158,7 +158,9 @@ pandocOptions :: WriterOptions pandocOptions = defaultHakyllWriterOptions { writerExtensions = defaultPandocExtensions - , writerHTMLMathMethod = MathJax "" + , writerHTMLMathMethod = MathJax "" + , writerNumberSections = True + , writerTableOfContents = True } -- Pandoc extensions used by the myPandocCompiler @@ -200,6 +202,7 @@ bibtexCompiler = do >>= readPandocWith defaultHakyllReaderOptions >>= return . writePandocWith pandocOptions + cfg :: Configuration cfg = defaultConfiguration diff --git a/hakyll-bootstrap/cours/bugs.md b/hakyll-bootstrap/cours/bugs.md index a3d2fb4..27ef384 100644 --- a/hakyll-bootstrap/cours/bugs.md +++ b/hakyll-bootstrap/cours/bugs.md @@ -11,15 +11,17 @@ chapters: true numberSections: false chaptersDepth: 1 sectionsDepth: 3 -lang: fr +lang: + - babel-lang: fr documentclass: article -papersize: A4 +papersize: a4 cref: false urlcolor: blue toc: false +toc-depth: 2 date: 2020-01-01 mathjax: on -include-before: <script src="css/prism.js"></script> +include-before: [<script src="css/prism.js"></script>] --- # Les bugs les plus courants diff --git a/hakyll-bootstrap/cours/cond_var.md b/hakyll-bootstrap/cours/cond_var.md deleted file mode 100644 index 07454a8..0000000 --- a/hakyll-bootstrap/cours/cond_var.md +++ /dev/null @@ -1,700 +0,0 @@ ---- -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). \ No newline at end of file diff --git a/hakyll-bootstrap/cours/intro_api.md b/hakyll-bootstrap/cours/intro_api.md deleted file mode 100644 index a2c2b9a..0000000 --- a/hakyll-bootstrap/cours/intro_api.md +++ /dev/null @@ -1,536 +0,0 @@ ---- -author: -- Orestis Malaspinas, Steven Liatti -title: Introduction à l'API des `pthreads` -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> ---- -\newcommand{\dd}{\mathrm{d}} -\newcommand{\real}{\mathbb{R}} -\newcommand{\integer}{\mathbb{Z}} -\renewcommand{\natural}{\mathbb{N}} - -# API des `pthreads` - -Le contenu de ce chapitre est basé sur l'excellent livre de R. H. Arpaci-Dusseau et A. C. Arpaci-Dusseau[^2]. - -Dans ce chapitre, nous allons brièvement introduire l'api de base de la gestion de threads en `C` -via la librairie POSIX, `pthreads`{.language-c}. - -Toutes les fonctions introduites ici sont déclarées dans le fichier `pthread.h`{.language-c}. Il est donc -sous-entendu que nous inclurons toujours ce fichier dans nos codes de cette manière : - -```language-c -#include <pthread.h> -``` - -Par ailleurs, il faut également faire l'édition des liens avec la librairie `pthread`{.language-c}, option `-lpthread`. - -Je vous recommande d'utiliser les options suivantes pour compiler votre code : - -```language-bash -gcc -g -Wall -Wextra -std=gnu11 -fsanitize=address -fsanitize=leak - -fsanitize=undefined -o main main.c -lpthread -``` - -Il faut noter qu'on utilise l'option `-std=gnu11` afin que certaines fonctionnalités comme les barrières de synchronisation soient reconnues. -L'autre solution est d'ajouter la ligne préprocesseur suivante **avant** l'inclusion des headers - -```language-c -#define __GNU_SOURCE -#include <pthread.h> -``` - -Finalement, il se peut que toute la documentation ne soit pas installée par défaut sur votre système (concernant les barrières par exemple). -Pour les installer, il faut le package `manpages-posix-dev` pour les distribution dérivées de "Debian". - -# Création et terminaison de threads - -## Création de threads - -Afin de créer un thread il faut appeler la fonction `pthread_create()` -dont l'entête est reproduite ci-dessous : - -```language-c -int pthread_create(pthread_t *thread, - const pthread_attr_t *attr, - void *(*start_routine)(void *), - void *arg); -``` - -Cette fonction aura pour effet de créer un thread et exécuter la fonction `start_routine()`{.language-c} à l'intérieur de celui-ci. - -Elle prend en paramètres : - -1. Un pointeur vers un type `pthread_t`{.language-c}, qui est un identifiant -du thread, qui va permettre d'interagir avec ledit thread. -2. Une structure `pthread_attr_t`{.language-c} qui contient un certain nombre d'attributs d'un thread. -Si ce pointeur vaut `NULL`{.language-c}, alors les attributs ont une valeur par défaut (cela fera presque toujours l'affaire dans -ce cours). -3. Un pointeur vers une fonction qui a une signature un peu compliquée : - - elle retourne un pointeur `void *`; - - elle prend en paramètre un pointeur `void *`; -4. Un pointeur `void *`{.language-c} qui représente les paramètres de la fonction `start_routine()`{.language-c}. - -Le but étant de pouvoir passer n'importe quelle fonction, retournant une valeur quelconque et prenant des paramètres -d'un type quelconque. - -Par ailleurs, la fonction `pthread_create()`{.language-c} retourne un entier qui donne une information -sur la réussite ou non de la création du thread. Si le retour de la fonction est 0 tout s'est bien passé, -sinon il y a eu une erreur. - -**Pensez à toujours bien vérifier le retour de `pthread_create()` !** - ---- - -Exemple (Création de thread) # - -Dans cet exemple[^3], nous créons un thread, qui appelle la fonction -`func()`{.language-c} qui a pour argument. Nous souhaitons -que cette fonction ait un argument `char *`{.language-c} et devons donc -explicitement caster le pointeur `void *arg`{.language-c}. - - -```language-c -#include <pthread.h> -#include <stdio.h> -#include <stdlib.h> - -void *func(void *arg) { - char *msg = (char *) arg; // type casting of the arg - printf("Message = %s\n", msg); - return NULL; -} - -int main(int argc, char *argv[]) { - pthread_t t; - char *msg = "My first thread!"; - if (pthread_create(&t, NULL, func, msg) != 0) { - perror("Thread creation error."); // affiche le message sur le canal d'erreur - return EXIT_FAILURE; - } - return EXIT_SUCCESS; -} -``` - -On note aussi que nous testons explicitement que la création du thread s'est -bien passée. Si une erreur se produit (`pthread_create()`{.language-c} retourne autre chose que 0), nous afficherons le message -`Thread creation error`{.language-c}. - ---- - ---- - -Question # - -Que va afficher ce code? - ---- - -## Terminaison de threads - -Maintenant que nous avons créé un thread, nous devons aussi pouvoir en gérer la terminaison. -Cette gestion se fait avec l'appel à la fonction `pthread_join()`{.language-c} dont l'entête est reproduite ci-dessous : - -```language-c -int pthread_join(pthread_t thread, void **value_ptr); -``` - -Lors de l'appel de cette fonction, le thread principal *attend* la terminaison du thread `thread` -créé précédemment. Elle prend en paramètre l'identifiant du thread dont on attend la fin, -et un pointeur vers un pointeur `void`{.language-c}. Ce pointeur est en fait un pointeur vers le type de retour -de la fonction `start_routine()`{.language-c}. Il est très important que `value_ptr`{.language-c} soit un pointeur de pointeur, -car on **change** la valeur passée en argument. Ainsi si cette valeur est un pointeur, la seule façon de la modifier est -d'avoir une indirection supplémentaire et donc d'avoir un double pointeur. De plus, -le type de ces pointeurs est `void`{.language-c}, afin de pouvoir retourner n'importe quel type. Si nous -ne souhaitons rien retourner, nous pouvons simplement appeler cette fonction avec `NULL` -en second paramètre. - -Il faut également noter que comme dans le cas de la création d'un thread, la fonction `pthread_join()` -retourne un entier qui nous dira si la terminaison du thread s'est bien passée. - -**Pensez à toujours bien vérifier le retour de `pthread_join()` !** - ---- - -Exemple (Jointure de thread) # - -Dans l'exemple précédent, nous constatons que très souvent -(presque toujours) le thread créé n'avait jamais le temps de s'exécuter avant la fin du programme. Nous corrigeons ce problème ici, en appelant la fonction -`pthread_join()`{.language-c} depuis le thread principal de notre programme. - -```language-c -#include <pthread.h> -#include <stdio.h> -#include <stdlib.h> - -void *func(void *arg) { - char *msg = (char *) arg; // type casting of the arg - printf("Message = %s\n", msg); - return NULL; -} - -int main(int argc, char *argv[]) { - pthread_t t; - char *msg = "My first thread!"; - - if (pthread_create(&t, NULL, func, msg) != 0) { - perror("Thread creation error."); // affiche le message sur le canal d'erreur - return EXIT_FAILURE; - } - - if (pthread_join(t, NULL) != 0) { // attente que le thread se termine - perror("Thread join error"); - return EXIT_FAILURE; - } - - return EXIT_SUCCESS; -} -``` - ---- - -## Exercices : création/terminaison - -1. Écrire une librairie de création et terminaison des threads, vérifiant le retour -des fonctions `pthread_create()`{.language-c} et `pthread_join()`{.language-c} : ce seront des wrappers que vous réutiliserez un peu tout -le temps dans le reste du cours, **faites les consciencieusement !** -2. Écrire un petit programme utilisant votre librairie, créant 10 threads et affichant : - - ``` - Hello World from thread %d. - ``` - - où `%d`{.language-c} est le numéro de votre thread. Puis le thread principal affichera le message : - - ``` - Hello from main thread. - ``` -3. Finalement, avant que le thread principal n'affiche son message, -garantissez que tous les autres threads soient terminés. Que constatez-vous comme différence? -4. Écrivez un petit programme qui crée 10 threads. Chaque thread va incrémenter 10'000 fois une variable entière -`n`{.language-c} définie dans le thread principal. Quelle devrait être la valeur de `n`{.language-c} à la fin de notre programme -s'il s'exécutait de façon séquentielle ? Que constatez-vous ici ? Quelle solution proposeriez-vous -pour résoudre ce problème ? -5. Écrivez un petit programme qui crée 10 threads. Chaque thread va incrémenter 10'000 fois une variable entière -`n`{.language-c} définie à l'intérieur de lui-même, puis retournera cette valeur. Quelle devrait être la valeur de `n`{.language-c} à la fin de notre programme -s'il s'exécutait de façon séquentielle ? Que constatez-vous ici ? - -## Attributs de threads - -Comme nous l'avons vu plus haut, lorsque nous créons un thread, il faut -passer en argument un `pthread_attr_t *` à la fonction `pthread_create()`. - -Cette structure est initialisée/détuite à l'aide des fonctions - -```language-c -int pthread_attr_init(pthread_attr_t *attr); -int pthread_attr_destroy(pthread_attr_t *attr); -``` - -Il existe plusieurs attributs configurables (voir `man pthread_attr_init` pour une liste exhaustive). A titre d'exemple, nous pouvons configurer -l'attribut `PTHREAD_CREATE_DETACHED` à l'aide de la fonction - -```language-c -int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate); -``` - -Cet attribut permet d'éviter à avoir à *joindre* le thread créé (cela devient impossible de joindre un thread détaché). Le système libère automatiquement les ressources une fois le thread terminé. - ---- - -Exemple (Thread détaché) # - -```language-c -#include <pthread.h> -#include <stdio.h> -#include <stdlib.h> - -void *func() { - printf("I am a detached thread, although I don't know it.\n"); - return NULL; -} - -int main() { - pthread_attr_t attr; - pthread_attr_init(&attr); - pthread_attr_setdetachstate(&attr,PTHREAD_CREATE_DETACHED); - pthread_t t; - pthread_create(&t, &attr, func, NULL); - pthread_join(t, NULL); // cela ne sert à rien... - pthread_attr_destroy(&attr); - return EXIT_SUCCESS; -} -``` - ---- - -Une autre façon de détacher un thread est d'appeler la fonction - -```language-c -int pthread_detach(pthread_t thread); -``` - -## Terminaison de threads alternatives - -Nous avons vu qu'un thread peut se terminer quand la fonction qu'il exécute -retourne. Il existe deux autres façon pour un thread de se terminer. - -1. Il se termine lui même (auto-terminaison) avec la fonction - - ```language-c - void pthread_exit(void *retval); - ``` -2. Un autre thread le termine (annulation) avec la fonction - - ```language-c - int pthread_cancel(pthread_t thread); - ``` - -### L'auto-terminaison - -Lors de l'appel à `pthread_exit()` le thread se termine et retourne la -valeur `retval` (cette donction est appelée implicitement lorsqu'un thread se termine avec `return`) au thread effectuant la jointure. - -Un cas particulier estlorsque le thread principal appelle `pthread_exit()`. Dans ce cas-là , le programme bloque et attend que tous les threads seterminent -avant de terminer le processus. - -### L'annulation - -L'annulation d'un thread est un processus un peu plus complexe. -En effet, la fonction - -```language-c -int pthread_cancel(pthread_t thread); -``` - -annule le thread `thread` depuis un autre thread. Cette fonction retourne -`0` en cas de succès. Si le thread est déjà terminé, un code d'erreur sera retourné. Bien que cela soit le comportement par défaut, tout thread n'est pas "annulable". Pour fixer la politique d'annulation d'un thread, -il faut qu'il appelle la fonction - -```language-c -int pthread_setcancelstate(int state, int *oldstate); -``` - -où `sate` est `PTHREAD_CANCEL_ENABLE` (valeur par défaut, autorise l'annulation) et `PTHREAD_CANCEL_DISABLE` (interdit l'annulation). - -Comme l'annulation d'un thread peut-être particulièrement dangereuse (le thread appelant `pthread_cancel()` n'a aucune idée de l'état dans lequel -le thread à terminer se trouve), on définit aussi un type d'annulation avec -la fonction - -```language-c -int pthread_setcanceltype(int type, int *oldtype); -``` - -La valeur de `type` peut avoir deux valeur différentes - -* `PTHREAD_CANCEL_DEFERRED`, l'annulation peut se faire en des points d'annulation précis (défaut). -* `PTHREAD_CANCEL_ASYNCHRONOUS`, l'annulation peut se faire n'importe quand. - -Finalement, un point d'annulation se définit avec la fonction - -```language-c -void pthread_testcancel(void); -``` - -Lorsqu'un thread appelle cette fonction, son annulation sera permise aux points où elle est appelée. - ---- - -Remarque # - -Évidemment, comme rien ne peutetre simple, certains appels système (`fopen`, `write`, ...) agissent comme des points d'annulation. La liste complète se trouve sur `man pthreads`. - ---- - -## Autres fonctions - -Il existe encore d'autres fonctions potentiellement utiles, mais nous n'allons pas toutes lesvoir en détail. Vous trouverez une liste non-exhaustive ci-dessous. - -* `pthread_t pthread_self();` retourne l'identifiant du thread. -* `int pthread_equal(pthread_t t1, pthread_t t2);` vérifie l'égalité entre les identifiants de 2 threads (seule façon portable de vérifier l'égalité). - - - - -# Les verrous - -Le verrou permet de résoudre le problème de non *atomicité* rencontré ci-dessus. -Dans le cas de l'incrémentation de notre entier `n`{.language-c}, nous avons une **section critique** : -l'opération `n = n + 1`{.language-c} peut être interrompue à tout moment par l'exécution d'un autre thread. - -Pour protéger les sections critiques de nos codes, le plus simple des mécanismes est -**l'exclusion mutuelle**. Ce mécanisme est représenté à l'aide de **mutex**-es (*mutual exclusion*) -dans la librairie POSIX. De façon simplifiée, la syntaxe pour protéger notre section critique serait : - -```language-c -pthread_mutex_t lock; -pthread_mutex_lock(&lock); // acquisition du verrou -n = n + 1; // section critique -pthread_mutex_unlock(&lock); // libération du verrou -``` - -On définit d'abord notre verrou `lock`{.language-c}, de type `pthread_mutex_t`{.language-c} (typiquement c'est une variable globale), -puis on acquiert le verrou. À ce moment-là , aucun autre thread ne peut l'acquérir, la section critique est effectuée sur le thread -ayant acquis le verrou pendant que les autres attendent. Dès que le verrou est libéré, un autre thread peut l'acquérir, et ainsi de suite. - -Afin que votre code marche et soit correct, il manque cependant deux ou trois choses : - -1. Le mutex doit toujours être initialisé : - - Soit de façon statique : - - ```language-c - pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; - ``` - - Soit de façon dynamique : - - ```language-c - int rc = pthread_mutex_init(&lock, NULL); // attention à vérifier le retour - ``` -2. La vérification du retour lors de l'acquisition du mutex. -3. Vous devez détruire votre mutex et le mettre dans un état non-initialisé, à l'aide de la fonction - - ```language-c - int rc = pthread_mutex_destroy(&lock); - ``` - -## Exercices : verrous - -1. Ajouter à votre librairie de wrappers une fonction acquérant un verrou et testant son retour. -2. Ajouter à votre librairie de wrappers une fonction initialisant un verrou et testant son retour. -3. Ajouter à votre librairie de wrappers une fonction détruisant un verrou et testant son retour. -4. Réécrire votre code incrémentant une variable `n`{.language-c} 10'000 fois dans 10 threads en utilisant cette fois -un verrou pour protéger la section critique. -5. Réfléchissez à une expérience vous permettant de mesurer le coût de calcul lié à au verrouillage-déverrouillage d'un verrou dans le cas avec un seul thread, dans le cas où on a plusieurs fils d'exécution. à présent, mettez-la en œuvre! - -# Variables de condition - -Les *variables de condition* sont utilisées lorsque nous voulons faire en sorte qu'un signal soit envoyé entre -différents fils d'exécution. Il existe deux fonctions principales qui sont utilisées dans ce cas: - -```language-c -int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex); -int pthread_cond_signal(pthread_cond_t *cond); -``` - -La fonction `pthread_cond_wait()`{.language-c} met le thread qui l'appelle dans un état de sommeil: il attend qu'un autre fil d'exécution -lui envoie un signal pour le réveiller. On constate que cette fonction prend un `pthread_mutex_t`{.language-c} en argument. -On doit donc avoir **acquis un verrou** afin de pouvoir l'appeler et on veut **modifier** l'état du verrou (d'où le pointeur): -il est en fait libéré lors de l'appel à `pthread_cond_wait()`{.language-c} afin de pouvoir être acquis par d'autres threads. -De plus lorsque le thread est réveillé, il réacquiert le verrou, et s'assure ainsi -être toujours dans la section où l'exclusion mutuelle est respectée. - -La fonction `pthread_cond_signal()`{.language-c} va permettre au thread qui l'appelle de signaler qu'il a effectué -une opération qui est digne d'intérêt et que la sieste est (peut-être) terminée. -Il est en général important d'acquérir le verrou lorsque vous entrez dans la section critique -d'où vous allez effectuer votre signal. - -Comme pour le cas du `pthread_mutex_t`{.language-c} la variable de condition doit être initialisée[^1]: - -```language-c -p_thread_cond_t cond = PTHREAD_COND_INITIALIZER; // initialisation statique -p_thread_cond_t cond; -pthread_cond_init(&cond, NULL); // initialisation dynamique - // (ici les attributs sont ceux par défaut) -pthread_cond_destroy(&cond); // destruction dynamique -``` - -L'initialisation dynamique a l'avantage qu'elle effectue la vérification que tout s'est bien passé (ou pas). - -L'utilisation de `pthread_cond_wait()`{.language-c} et `pthread_cond_signal()`{.language-c} peut être résumée comme suit: - -1. Initialisation de la variable de condition et du verrou. -2. **Thread 1** tentative d'acquisition du verrou: - - Quand l'acquisition est effectuée - - Vérification de la variable de condition: - - Si la variable de condition n'est pas remplie, libération du verrou: mise en sommeil. - - Lorsque variable de condition est remplie, tentative d'acquisition du verrou: réveil. - - Libération du verrou. -3. **Thread 2** tentative d'acquisition du verrou: - - Quand l'acquisition est effectuée. - - Signalisation de la variable de condition. - - Libération du verrou. - -## Exercice: variables de conditions - -1. Ajoutez à votre librairie de wrappers, un wrapper de la fonction `pthread_cond_wait()`{.language-c} et testant son retour. -2. Ajoutez à votre librairie de wrappers, un wrapper de la fonction `pthread_cond_init()`{.language-c} et testant son retour. -3. Ajoutez à votre librairie de wrappers, un wrapper de la fonction `pthread_cond_destroy()`{.language-c} et testant son retour. -4. Ajoutez à votre librairie de wrappers, un wrapper de la fonction `pthread_cond_signal()`{.language-c} et testant son retour. -5. Créer un petit programme effectuant les tâches suivantes. - - **Thread 1**: tant qu'une variable `ready`{.language-c} est fausse, attendre un signal, puis continuer. - - **Thread 2**: attendre une seconde (utiliser `sleep(1)`{.language-c} de `unistd.h`) mettre la variable `ready`{.language-c} à une valeur vraie et signaler le changement. -6. Réécrire le programme ci-dessus, mais en créant 10 threads qui attendent un signal. - -<!-- 4. Que pourrait-il se passer si le code que vous avez écrit ci-dessus (verrou et variable de condition) -était remplacé par une simple variable booléenne? --> - -# Les barrières de synchronisation - -Nous avons déjà vu que la fonction `pthread_join()`{.language-c} -permettait d'attendre la fin de l'exécution d'un thread -et donc de synchroniser l'exécution d'un code multi-threadé. -Il se peut qu'on ne veuille synchroniser qu'un certain nombre des threads sans -qu'ils soient pour autant terminés. Pour ce faire, on utilise les **barrières de -synchronisation**. Une barrière de synchronisation fonctionne de la manière suivante: - -1. Le nombre, $n$, de threads à synchroniser est spécifié lors de la création de la barrière. -2. Chaque thread notifie son arrivée à la barrière. -3. Tant que $n$ threads n’ont pas notifié la barrière, ceux déjà arrivés sont bloqués. -4. Une fois que tous les threads ont notifié la barrière, celle-ci débloque tous les threads en attente (un par un, dans un ordre indéterminé). -5. La barrière est ensuite **réinitialisée** à la valeur spécifiée lors de sa création: la barrière est réutilisable. - -La syntaxe des barrières est la suivante: - -```language-c -int pthread_barrier_init(pthread_barrier_t *barrier, - const pthread_barrierattr_t *attr, unsigned count); // initialisation -int pthread_barrier_wait(pthread_barrier_t *barrier); // mise en attente -int pthread_barrier_destroy(pthread_barrier_t *barrier); // destruction des ressources -``` - -La fonction `pthread_barrier_init()`{.language-c} initialise une barrière `barrier`{.language-c} pour -`count`{.language-c} threads. Le deuxième argument, `attr`{.language-c}, est un ensemble d'attributs, nous pouvons le laisser à `NULL`{.language-c}. La notification de l'arrivée de chaque thread se fait à l'arrivée de la fonction `pthread_barrier_wait()`{.language-c}. Finalement, les ressources de la barrière sont libérées -lors de l'appel à la fonction `pthread_barrier_destroy()`{.language-c}. -Cette fonction est bloquante, il faut donc s'assurer qu'aucun thread -n'est bloqué à son appel, car le programme pourrait fort bien se retrouver bloqué jusqu'à la fin des temps. - -Comme d'habitude ces fonctions renvoient `0` en cas de succès. -Il est donc nécessaire de bien vérifier que tout se passe bien -lors de l'appel à ces fonctions. - -## Exercice: barrière de synchronisation - -1. Ajoutez à votre librairie de wrappers, un wrapper de la fonction `pthread_barrier_init()`{.language-c} et testant son retour. -2. Ajoutez à votre librairie de wrappers, un wrapper de la fonction `pthread_barrier_wait()`{.language-c} et testant son retour. -3. Ajoutez à votre librairie de wrappers, un wrapper de la fonction `pthread_barrier_destroy()`{.language-c} et testant son retour. -4. Écrire un petit programme prenant en argument un nombre de threads. -Chaque thread fera les actions suivantes: - - * tirera un nombre aléatoire entier, $t\leq 10$ et affichera son numéro, ainsi que $t$; - * attendra $t$ secondes puis affichera un message avec son identifiant; - * se synchronisera avec les autres threads; - -5. A l'aide d'un mutex et d'une variable de condition -écrivez votre propre barrière rien qu'à vous. - - -[^1]: Puis détruite dans le cas de l'initialisation dynamique. -[^2]: R. H. Arpaci-Dusseau et A. C. Arpaci-Dusseau, *Operating Systems: Three Easy Pieces*, Arpaci-Dusseau Books, ed. 0.91, (2015). -[^3]: Ce code se trouve dans le fichier <https://githepia.hesge.ch/orestis.malaspin/cours_prog_conc/blob/master/exemples/intro_api/pthread_create.c> . \ No newline at end of file diff --git a/hakyll-bootstrap/cours/intro_os.md b/hakyll-bootstrap/cours/intro_os.md deleted file mode 100644 index 7ca780b..0000000 --- a/hakyll-bootstrap/cours/intro_os.md +++ /dev/null @@ -1,141 +0,0 @@ ---- -author: -- Orestis Malaspinas, Steven Liatti -title: Introduction vague aux systèmes d'exploitation -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 -mathjax: on -date: 2020-01-01 ---- - -# Avant-propos - -Ce chapitre est fortement inspiré du cours en ligne *Introduction to Operating Systems* se trouvant sur [https://classroom.udacity.com/courses/ud923](https://classroom.udacity.com/courses/ud923). - -# Généralités sur les systèmes d'exploitation - -Dans ce chapitre, nous allons brièvement introduire ce qu'est un système d'exploitation (ou Operating System, abrégé OS, en anglais) -et comment il fait fonctionner un ordinateur. Mais avant de donner une -vague définition de ce qu'est un système d'exploitation, -nous allons d'abord rappeler en quoi consiste un ordinateur. - -Un ordinateur est constitué de plusieurs composants: un ou plusieurs processeurs (ou Central Processing Units, abrégé CPU, en anglais) qui -peut contenir plusieurs cœurs, de la mémoire, une carte réseau ou wifi, -une carte graphique, un disque dur, des périphériques USB, ... -Tous ces composants seront utilisés par une ou plusieurs applications -(le butineur, le lecteur multimédia, l'éditeur de texte, -Dota 2, ...). - -## Qu'est-ce qu'un système d'exploitation? - -Le système d'exploitation est une couche de logiciel qui fait le lien entre les applications et les différents composants matériel -d'un ordinateur. Il n'existe pas de définition unique de ce qu'est un système -d'exploitation. Nous allons plutôt le définir ici par les tâches qu'il est capable -d'effectuer: - -0. A un accès privilégié direct au matériel. -1. Il cache la complexité du matériel à l'utilisateur et aux développeurs d'applications. -2. Il gère les ressources matérielles. Quelle application peut utiliser quelle ressource et à quel moment elle peut le faire (leur ordonnancement). -3. Il isole et protège les ressources. Il empêche que plusieurs applications utilisent les mêmes parties des ressources au même moment. En particulier, différentes applications n'ont pas accès à la même partie de la mémoire en même temps. - ---- - -Exemple # - -Lorsqu'on lit ou on écrit de la musique sur une mémoire USB et le disque dur d'un ordinateur, à aucun moment il y a besoin de réfléchir où exactement sur le disque, nous allons écrire/lire les bits des fichiers. -Il empêche qu'un fichier soit écrasé sans que l'utilisateur le veuille. -De plus, il n'y a pas de différence notable entre les deux pour un utilisateur -alors que le chemin pour accéder à l'un ou à l'autre est très différent. -De plus, à aucun moment il n'y a besoin d'ordonnancer les différentes applications sur le processeur. Toutes ces opérations complexes sont effectuées pour nous par le système d'exploitation. - ---- - -Quels systèmes d'exploitation connaissez-vous? - -1. Windows. -2. Mac-OS (BSD), Linux: basé sur Unix. -3. Android. -4. iOS. -5. .... - -Dans cette introduction, on va plutôt discuter les systèmes Linux. - ---- - -Quiz # - -Choisissez parmi les possibilités suivantes quels sont les différents composants d'un système d'exploitation: - -1. Un éditeur de fichier. -2. Un système de fichier. -3. Un driver de carte graphique. -4. La mémoire cache. -5. Un compilateur. -6. Un ordonnanceur. - ---- - -## De quoi est constitué un système d'exploitation - -Il y a trois concepts de base qui constituent un système d'exploitation. - -1. Les abstractions: comme les processus, les fils d'exécution (ou threads), les fichiers, les socket, les pages mémoire, ... -2. Les mécanismes: la création, l'ordonnancement des processus/threads, lire/écrire dans dans un fichier, allouer/désallouer de la mémoire, ... -3. Les politiques: qui déterminent comment les mécanismes vont utilisent les abstractions du matériel sous jacent. - ---- - -Exemple # - -Afin de gérer de la mémoire, le système d'exploitation utilise **l'abstraction** de la page mémoire. Une page mémoire correspond à -une adresse de taille fixe (4kb par exemple). Le système d'exploitation -va également avoir des **mécanismes** pour interagir avec cette page: -allouer la page dans la mémoire, ou la maper dans l'espace d'adressage d'un processus pour qu'il puisse accéder à ce qu'il y a dans cette page mémoire. Cette page peut être déplacée dans la mémoire ou même sur le disque dur s'il y a besoin de faire de la place dans la mémoire (swapping). -Ces dernières actions sont basées sur la **politique** du système d'exploitation. Une façon de décider si une page doit être copiée sur le disque dur peut être de prendre la page qui a été utilisée le moins récemment. - ---- - -## Mécanismes de protection d'un système d'exploitation - -Le système d'exploitation doit contrôler et gérer les ressources matérielles d'un ordinateur. -Pour ce faire il doit avoir des **privilèges** particuliers pour accéder aux différents composants. -Il y a deux modes principaux d'accès au matériel: - -1. Le mode utilisateur (sans privilèges). -2. Le mode "noyau" (avec privilèges). - -Le système d'exploitation opère évidemment en mode noyau, alors que les -applications (processus) eux opèrent en mode utilisateur. Il existe une -barrière ne permettant pas aux applications d'effectuer -des opérations privilégiées sur le matériel, seul le système d'exploitation -peut les effectuer. Cette barrière est directement supportée par les matériel. En fait, si un processus tente d'effectuer directement -une opération réservée au mode noyau, la tentative sera capturée et -la main sera passée au système d'exploitation pour gérer ce qu'il convient de faire. Afin qu'une application puisse effectuer -une opération privilégiée elle doit passer au travers du système d'exploitation, via un **appel système**. Un appel système est une interface qu'expose le système d'exploitation afin de permettre aux -processus d'effectuer de demander au système d'exploitation d'effectuer -une opération privilégiée pour eux. Par exemple, `open(file)`{.language-c} pour ouvrir un fichier, -`mmap(memory)`{.language-c} pour allouer de la mémoire, créer un fil d'exécution, ... - -# Résumé - -Dans ce chapitre, nous avons très brièvement discuté ce qu'est un système d'exploitation. -Il permet de cacher la complexité du matériel aux applications en mettant en place des abstractions -et des mécanismes qui permettent de contrôler le matériel et de protéger -les applications entre elles, et de les isoler. De plus il sert à gérer les différentes -applications qui sont exécutées sur un ordinateur. - -Dans le chapitre suivant, nous allons voir ce qu'est plus en détail une application (ou processus) -et comment différents processus sont gérés par le système d'exploitation. \ No newline at end of file diff --git a/hakyll-bootstrap/cours/intro_processus.md b/hakyll-bootstrap/cours/intro_processus.md deleted file mode 100644 index 6c63efb..0000000 --- a/hakyll-bootstrap/cours/intro_processus.md +++ /dev/null @@ -1,441 +0,0 @@ ---- -author: -- Orestis Malaspinas, Steven Liatti -title: Introduction aux processus et leur gestion -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> ---- - -# Avant-propos - -Ce chapitre est fortement inspiré du cours en ligne *Introduction to Operating Systems* se trouvant sur -<https://classroom.udacity.com/courses/ud923>. - -# Les processus - -Dans le chapitre précédent, nous avons discuté des concepts clés des -systèmes d'exploitation et comment ils gèrent le matériel pour les applications à l'aide -d'abstractions et représentent les opérations effectuées sur le matériel à l'aide de différents mécanismes. Dans ce chapitre, nous allons discuter -une abstraction particulière: le processus. - -## Qu'est-ce qu'un processus? - -Une application est un programme qui se trouve sur un support physique (le disque dur, une mémoire flash, ...): elle est "statique" ou en d'autres termes elle n'est pas en train de s'exécuter. Un processus (ou tâche) -est une instance d'un programme en **cours d'exécution**: le programme est chargé en mémoire et commence à s'exécuter (c'est une entité active). - ---- - -Exemple # - -Une instance d'un éditeur de texte dans lequel vous êtes en train d'éditer un code pour le cours de concurrence est un processus. Un autre -processus est le lecteur de musique sur lequel vous écoutez le dernier -morceau de Ludwig von 88. Cela peut également être votre browser que vous avez oublié de fermer et qui tourne arrière plan. Vous pouvez voir tous les processus sur votre machine en effectuant la commande `top`. - ---- - -## De quoi est composé un processus? - -Un processus englobe tout ce dont a besoin notre programme pour s'exécuter: - -* Son code; -* Les données statiques; -* Les variables que l'application a besoin d'allouer; -* ... - -Chaque élément doit être repéré par une adresse unique en mémoire. -En résumé, une abstraction pour représenter l'état d'un processus -est un espace mémoire qui est défini par une séquence d'adresses. - -Chaque état d'un processus apparaît dans une région différente en mémoire. -Ils sont séparés en trois parties principales: - -1. Une partie statique contenant toutes les données disponibles pour démarrer le processus (le code, -les chaînes de caractères statiques, ...). -2. *Le tas*: durant l'exécution, un processus peut allouer dynamiquement de la mémoire, cela est fait sur le tas. -Le tas est un espace mémoire non contigu, qui peut contenir des "trous" (des régions que le processus ne peut -pas accéder). -3. *La pile*: Contrairement au tas qui n'a aucune structure particulière, la pile est une structure LIFO qui va changer de taille dynamiquement. Typiquement les appels de fonctions (ainsi que leur données) sont mises sur la pile. - -Les adresses mémoire qui décrivent l'état d'un processus sont dites virtuelles: -elles ne doivent pas forcément correspondre aux adresses physiques de la mémoire. -Le "mapping" entre les adresses virtuelles et la mémoire physique est géré par -le système d'exploitation. Cette façon de faire a l'avantage de découpler la gestion -de la mémoire physique (et de la simplifier) de l'utilisation qu'aimerait en faire -les différents processus. En fait, à chaque fois qu'un processus alloue une variable, -le système d'exploitation lui alloue une adresse virtuelle et -un mapping sera fait avec une adresse physique. Ce mapping sera inséré dans -une table et pourra être utilisé à chaque fois que l'adresse virtuelle sera accédée -par le processus qui l'aura alloué. - -Comme nous l'avons dit précédemment, toutes les adresses mémoires ne sont pas -forcément allouées par un processus: l'espace utilisé peut contenir des trous. -Par ailleurs, -on peut même se retrouver dans un état où il n'existe pas assez d'espace -physique pour allouer toute la mémoire nécessaire à un processus. -Par exemple, si on a un espace d'adressage virtuel qui a une longueur de 32 bits, -et qu'on veut stocker en mémoire un fichier de plus de 4 Gb, on va -dépasser la mémoire disponible. De même, on peut assez aisément dépasser la mémoire -disponible sur un serveur (même très puissant) si on fait tourner plusieurs processus -très gourmands en mémoire. La virtualisation de la mémoire -permet au système d'exploitation de gérer ces cas en décidant quelle partie de la mémoire virtuelle -est mappée sur la mémoire physique et laquelle ne l'est pas. -Lorsque la mémoire physique est épuisée, une partie de l'état d'un processus peut être copiée -sur le disque dur pour faire de la place: on dit qu'elle est "swapée". -La partie de la mémoire mise sur le disque peut ainsi être récupérée -à n'importe quel moment. Sans entrer dans des considérations -sur la façon exacte dont la gestion est faite, il faut se souvenir à présent -que le système d'exploitation doit garder -une trace de ce qui se passe à tout moment avec la mémoire de chaque processus, -non seulement pour pouvoir retrouver la mémoire liée à un processus mais également pour -pouvoir décider si un accès est illégal ou non. - ---- - -Question # - -Si on a deux processus, $P_1$ et $P_2$, tournant en même temps sur un système, lequel de ces trois espaces -d'adressage sera utilisé en général ? - -* $P_1: 0--32'000$, $P_2: 32'001--64'000$. -* $P_2: 0--32'000$, $P_1: 32'001--64'000$. -* $P_1: 0--64'000$, $P_2: 0--64'000$. - ---- - -## Quelles informations le système d'exploitation possède sur les processus? - -Pour pouvoir gérer un processus, un système d'exploitation doit savoir -ce qu'il est en train de faire. En fait, il doit être capable d'interrompre un processus -et de le faire recommencer depuis l'état exact où il se trouvait au moment où il a été arrêté. - -Comment le système d'exploitation connaît l'état exact d'un processus ? - -Lorsqu'on écrit un programme, on écrit du texte. Puis pour l'exécuter, il faut le compiler -et produire un fichier binaire. Un fichier binaire est une suite d'instructions -qui ne sont pas forcément exécutées de façon séquentielle (il peut y avoir des boucles, -des branchements, ...). A n'importe quel moment, le processeur doit savoir où le programme se trouve -dans la séquence d'instructions. Cette information est stockée dans un registre sur le processeur et est appelée le -compteur du programme (Program Counter, PC). Il existe un grand nombre de registres actifs lors de l'exécution d'un processus -(ils peuvent contenir des adresses mémoire, ou des informations qui vont influencer l'ordre des instructions par exemple). -Une autre partie importante dans un processus est sa pile et en particulier ce qu'il y a à son sommet. -Cette information est contenue dans le pointeur de pile (stack pointer) qui lui aussi doit -être connu à tout moment. Il existe encore d'autres structures nécessaires. Tout cela est -contenu dans le bloc de contrôle du processus (Process Control Block, PCB). - -Le PCB est une structure du système d'exploitation qui est maintenue pour chaque -processus qu'il gère. Il contient : - -* un compteur de programme (représentant l'endroit où on se trouve dans la suite d'instructions du processus); -* le pointeur de pile (représentant le sommet de la pile du processus); -* des registres et ce qu'ils contiennent; -* on peut y trouver d'autres choses, comme la liste des fichiers ouverts ou des informations relatives à l'utilisation du CPU pour l'ordonnancement; - -Le PCB est créé et initialisé lors de la création du processus (par exemple, le compteur de programme -sera mis sur la première instruction du programme). -Il est mis à jour lorsque l'état du processus change. Par exemple, -lorsque le processus demande plus de mémoire, -le système va allouer cette mémoire, créer de nouvelles adresses virtuelles -valides pour le processus et mettre à jour -les informations concernant les adresses -du processus (leur limite et leur validité). - ---- - -Exemple # - -Faisons un exemple de ce que cela veut dire. -Imaginons que deux processus $P_1$ et $P_2$ sont gérés -par le système d'exploitation. Ils sont déjà créés et donc -leur PCB sont déjà quelque part en mémoire. - -Supposons que seul $P_1$ est en cours d'exécution et que $P_2$ -est en attente. A un moment donné, $P_1$ est mis en attente (pour une raison ou une autre) -le système d'exploitation doit donc sauvegarder toutes les informations relatives à $P_1$ -dans le PCB de $P_1$. Ensuite, il va exécuter $P_2$, donc il doit charger -toutes les informations contenues dans le PCB de $P_2$ et mettre à -jour les registres du processeur avec. Ensuite, si $P_2$ demande plus de mémoire, -de nouvelles adresses virtuelles seront créées, et le PCB sera mis à jour avec ces nouvelles informations. Ainsi, si $P_2$ se termine, -ou est mis en attente, les informations relatives à $P_2$ seront mises dans son PCB -et le PCB de $P_1$ sera chargé et les registres mis à jour pour continuer l'exécution de $P_1$. - ---- - -### Changement de contexte - -Chaque fois qu'un échange est effectué entre deux processus tel que vu dans l'exemple ci-dessus, -on appelle cela un **changement de contexte**. -Plus formellement, un changement de contexte est le mécanisme utilisé par le système -d'exploitation pour changer de contexte d'exécution d'un processus à un autre. -Dans l'exemple ci-dessus, cela se passe quand on passe de l'exécution de $P_1$ -à $P_2$ et de $P_2$ à $P_1$. - -Un changement de contexte a un coût de calcul certain. En premier lieu, -il y a des coûts directs : - -1. Le nombre de cycles CPU nécessaires à la sauvegarde de l'état de $P_1$ dans son PCB; -2. Le nombre de cycles nécessaires au chargement du PCB de $P_2$ en mémoire. - -En second lieu, il y a également des coûts indirects : lorsqu'on exécute un processus, -une partie de ses données vont se trouver dans la mémoire cache du CPU (qui est très rapide à accéder -par rapport à l'accès à la mémoire de l'ordinateur, on parle de un à deux ordres de grandeur plus rapide). -Lors d'un changement de contexte entre $P_1$ et $P_2$, les données de $P_1$ ne seront plus d'aucune -utilité dans le cache du CPU et les données de $P_2$ devront y être chargées depuis la mémoire pour remplacer -les données de $P_1$. - -## Les états d'un processus - -On a vu qu'un processus peut être dans un état **en attente** ou **en exécution**. -Un processus en exécution peut être interrompu et un changement de contexte peut se produire. -A ce moment là , le processus sera mis en attente. Il existe deux autres états pour un processus : -**éligible** ou **stoppé**. Étudions le cycle de la vie d'un processus pour voir à quoi correspondent -ces deux autres cas. Lorsqu'un processus est exécuté, le système fait un certain nombre de vérifications -puis crée et initialise son PCB et lui alloue des ressources initiales. Puis, le processus -est mis dans l'état *éligible* (il n'a pas encore démarré sur le CPU). Il va attendre -que l'ordonnanceur l'autorise à démarrer et à ce moment, il rentre dans l'état en exécution. -Depuis cet état, il peut être interrompu et mis en attente (par exemple s'il doit effectuer -une opération I/O importante), puis retournera dans l'état éligible. -Une autre solution est qu'il soit interrompu, par l'utilisateur par exemple (ctrl+z) ou à cause d'une erreur. -Il est possible que cet arrêt soit définitif ou non (ctrl+z, bg fera en sorte que le processus reprenne en -arrière plan). Dans ce cas il est dans un état *stoppé*. Si cela est définitif le processus est terminé. - -## Interactions entre processus - -Par défaut, il n'existe pas de communications d'un processus à un autre. -En particulier, la mémoire entre deux processus est isolée pour éviter -des problèmes tragiques. Néanmoins, -le système d'exploitation donne la possibilité aux processus de communiquer entre eux. -Par exemple, on peut imaginer un serveur web consistant de deux processus. Le premier -est un front-end qui accepte les requêtes des clients et le second est une base de données -stockant les profils des utilisateurs et des informations diverses. -Pour faire communiquer les processus, il faut des mécanismes supplémentaires : **les mécanismes de communication inter-processus**. -Ils aident à transférer des informations d'un espace d'adressage à un autre, -tout en maintenant l'isolation entre les processus. En très résumé, le système d'exploitation -met à disposition une mémoire tampon dans laquelle les processus peuvent lire et écrire -ce qui permet la mise en place d'un canal de communication potentiellement bi-directionnel. -L'inconvénient principal de cette technique est que le coût de calcul est -relativement élevé, car cela demande de copier -des quantités parfois très grandes de données et de les relire. Une autre façon de faire -communiquer les processus est d'avoir des régions de la mémoire qui sont mappées par les deux processus -et ainsi ils peuvent **partager** une partie de leur mémoire respective et n'ont pas besoin de faire des copies. Le problème principal de cette approche est qu'il est très simple de faire des tas d'erreurs -et de corrompre la mémoire d'un autre processus. - -# Les threads - -Nous venons de discuter des processus et en partie de comment ils sont gérés. -Les processus tels qu'ils sont décrits ici ne peuvent être exécutés que sur -un seul CPU. Si nous voulons qu'un processus soit exécutable sur plus -d'un CPU afin de tirer profit des processeurs modernes, -ce processus doit pouvoir posséder plusieurs contextes d'exécution. - -Ces contextes d'exécution dans un seul processus sont appelés **fils d'exécution** ou **threads** en anglais. -Dans ce chapitre, nous allons décrire ce que sont les threads, en quoi ils sont différents -des processus et quels sont leurs avantages et inconvénients. - -## Les différences entre un fil d'exécution et un processus - -Voyons à présent les différences entre un fil d'exécution et un processus. -Un processus est caractérisé par son espace d'adressage mémoire qui contient -les adresses virtuelles et leur cartographie vers leurs contreparties physiques, -son code, ses données, etc. Il est également caractérisé par son contexte d'exécution (ses -registres, son pointeur de pile, ...) qui est représenté par son PCB. - -Les fils eux, représentent des contextes d'exécution indépendants. -Ils font partie du même espace d'adressage mémoire et vont partager -les mappings des adresses virtuelles vers les adresses physiques. -Ils vont également partager le code, les fichiers et les données. -Néanmoins, ils vont exécuter des instructions **différentes**, -accéder peut-être à des adresses différentes en mémoire, ... -Cela signifie que chaque thread aura un compteur de programme différent, -un pointeur de pile différent, une pile différente, et des -registres différents. Ces informations seront donc stockées dans -des structures propres à chaque thread. -La représentation du PCB pour le système d'exploitation -sera bien plus complexe que ce que nous venons de discuter pour les processus -n'ayant qu'un seul fil d'exécution. Il contiendra toutes -les informations partagées par chacun des threads, mais également toutes les informations -qui seront propres à chaque fil d'exécution du processus. - -## Les avantages du multi-threading - -Discutons d'abord pourquoi le multi-threading est avantageux. - -Imaginons le cas de l'équation de la chaleur que nous avons vue en exercices. -Nous pouvons assez aisément imaginer découper la matrice de températures -en plusieurs blocs et répartir le travail sur plusieurs threads, -chacun exécutant le même code mais s'occupant d'une partie différente des données. -Cela ne veut pas dire qu'ils exécutent exactement la même opération à un point -donné dans le temps, ils devront donc chacun avoir leurs registres, -leur pile, ... Mais on voit assez aisément que si plus d'un processeur (ou plus d'un cœur) -est disponible, le calcul sera grandement accéléré grâce à la parallélisation du calcul. - -Une autre façon d'optimiser l'exécution d'un programme sur un processeur multi-coeurs -est de diviser ce programme en différentes sous-tâches. Par exemple, -on peut avoir un thread gérant l'I/O, un autre l'affichage, un autre les entrées -au clavier, et encore un autre qui gère la souris. - -On peut également imaginer avoir une gestion plus efficace des threads dans une application -en différenciant leurs priorités. On peut imaginer mettre plus de fils d'exécution -au service de parties plus importantes de l'application. - -Il est également possible de spécialiser les threads comme dans une chaîne de montage. -Chaque thread est responsable d'une partie bien déterminée de l'application, -et effectuera toujours les mêmes opérations sur les mêmes données. De cette façon, sur des -systèmes multi-processeurs, on pourra avoir (éventuellement) les mêmes données présentes dans le cache -de chaque coeur et ainsi gagner en performance. - -Mais vous allez me demander : "Pourquoi ne pas simplement faire tout cela avec -un système multi-processus?" Et vous aurez raison. -En fait l'avantage des fils d'exécution est leur relative "légèreté". Si on -utilise un système multi-processus, il faut exécuter chacun des processus -sur un processeur différent. Si cela est le cas, cela signifie que les -processus ne partageront pas le même espace d'adressage et auront un contexte d'exécution totalement -différent. Cela signifie par conséquent qu'il faudra allouer plusieurs espace d'adressage, -rendant le cas multi-processus beaucoup plus gourmand en ressources. -De plus, il est nécessaire d'utiliser les mécanismes de communication inter-processus qui -sont beaucoup plus coûteux. - -## Le multi-threading sur un seul CPU - -La plupart des exemples que nous avons donnés jusque là concernent des applications qui tournent sur plusieurs CPUs. -Le multi-threading existe depuis bien avant l'apparition des processeurs possédant plusieurs cœurs[^1]. -Il doit bien y avoir une raison à cela. En fait, on peut imaginer le scénario suivant. -Imaginons qu'un thread, $T_1$, effectue une requête -vers le disque dur. A ce moment-là , le disque a besoin d'un certain temps -pour répondre à la requête. Pendant ce temps, $T_1$ ne fait qu'attendre. -Le CPU devrait donc simplement attendre sans rien faire. Si ce temps est plus long -que le temps qu'il faudrait pour faire deux changements de contexte, il peut être avantageux -d'utiliser un autre thread, $T_2$, pour effectuer une autre opération en attendant -que le disque ait répondu à la requête de $T_1$. -Néanmoins, cela est également vrai pour les processus. Souvenez-vous -que le changement de contexte d'un processus est beaucoup plus long -(il faut créer un nouvel espace d'adressage, passer par l'ordonnanceur du -système d'exploitation, etc). Le temps de changement de contexte pour -les fils d'exécution est lui beaucoup plus court et il sera beaucoup plus -rapide de faire ce changement de contexte et donc il sera bien plus aisé -d'obtenir des gains de performance. - ---- - -Question # - -Est-ce que les assertions suivantes s'appliquent aux fils d'exécutions, aux processus ou aux deux ? - -* Peuvent partager le même espace d'adressage. -* Prennent plus de temps pour effectuer un changement de contexte. -* Ont un contexte d'exécution. -* Doit posséder des mécanismes de communication. - ---- - -## Les différents modèles de multi-threading - -Il existe un grand nombre de modèles pour décomposer le travail à effectuer par un processus -par plusieurs threads. - -Nous allons brièvement en discuter trois ici : - -* Le modèle maître-esclave. -* Le modèle pipeline. -* Le modèle pair. - -<!-- TODO: Ajouter exemple --> -<!-- Nous allons considérer un exemple de tâche à effectuer, afin d'illustrer les différents modèles. --> - -### Le modèle maître-esclave - -Le modèle **maître-esclave** (boss/worker model) se caractérise par la présence d'un thread **maître** et d'un certain nombre -de threads **esclaves**. Le maître est chargé de donner du travail aux esclaves et les esclaves -sont responsables d'effectuer une tâche entière qui leur est affectée. Les esclaves se synchronisent -avec le maître lorsqu'ils ont fini leur tâche. Ce modèle est limité par la performance du maître. Si -celui-ci est trop lent à répartir le travail, les esclaves passeront leur temps à l'attendre, -son travail doit donc être le plus simple possible et limiter un maximum les interactions -entre le maître et les esclaves. Par ailleurs, on essaie de limiter au maximum les dépendances -entre esclaves (ce qui ruinerait la performance). - -Un exemple de la vie de tous les jours pourrait être le suivant. Considérons une entreprise -fabriquant des marteaux. Ces marteaux sont fabriqués à la demande et sur mesure. Une fois la commande passée il faut réaliser les -tâches suivantes : - -1. Accepter la commande; -2. Lire la commande; -3. Découper le manche dans un manche plus gros; -4. Fondre la tête; -5. Peindre le manche et le vernir; -6. Assembler le manche et la tête; -7. Envoyer le marteau. - -La répartition du travail entre le maître et les esclaves pourrait être la suivante. -Le maître se contente d'accepter la commande et de la passer à un esclave libre. -Il effectue ainsi un minimum de travail et est immédiatement disponible pour traiter une autre commande. Ensuite l'esclave s'occupe du reste: c'est-à -dire des parties 2 à 7. La tâche principale du maître est de trouver un esclave libre pour -lui donner son travail (mais comme tout bon esclave, il n'aime pas trop travailler alors -il se cache). Comment fait donc le maître ? Une façon de débusquer l'esclave est -pour le maître de garder une liste des esclaves libres. Il doit également -attendre que l'esclave ait accepté sa tâche. Cette façon de faire -n'est donc pas idéale. En revanche, chaque esclave est totalement découplé des autres esclaves. Une autre façon de faire, serait d'avoir une file -entre le maître et les esclaves qui viennent se servir en travail tout seuls. L'avantage de cette approche est que le maître n'a jamais besoin de savoir quel -esclave est libre ou non : il ne fait que mettre un travail dans la file. Le désavantage est que les esclaves doivent se synchroniser entre eux pour éviter de faire le travail à double. Cette méthode est en général préférable, car -la limitation principale de performance est dûe au maître plutôt qu'aux esclaves. - -### Le modèle pair - -Dans le modèle **pair** (peer model), tous les threads effectuent leur part du travail -en même temps et n'ont pas de chef (ni Dieu, ni Maître). -Dans ce modèle, le thread principal crée tous les autres threads -puis il devient l'équivalent des autres fils d'exécution. -Chaque thread doit gérer à sa manière ses données (il n'y -a pas d'autre thread responsable pour lui donner du travail, -donc il doit les récupérer seul) et -doit avoir son propre mécanisme pour se synchroniser si nécessaire. -Le modèle pair est très indiqué pour les problèmes dont les entrées -sont bien déterminées. Elles permettent de s'affranchir du maître -dans le modèle maître/esclave, car il n'y a aucune gestion à faire -pour le partage du travail. Nous avons vu un tel exemple (l'équation de la chaleur) et en verrons d'autres. D'autres exemples typiques sont -les multiplications de matrices, la recherche en parallèle -dans une base de données, ... Dans le cas de la fabrication de marteau, si le marteau est suffisamment grand, on pourrait imaginer que -plusieurs travailleurs libres tailleraient le manche, le peindraient -et verniraient différentes parties en même temps afin d'accélérer le processus. - -### Le modèle pipeline - -Le modèle **pipeline** est très similaire à la chaîne de montage. -Le travail global est subdivisé en sous-tâches et chaque thread se spécialise -dans une sous-tâche. Ainsi, on espère que les threads spécialisés seront -plus efficaces que des threads généralistes. Un peu comme dans une chaîne de montage humaine -où chaque travailleur fait de façon très efficace (mais très répétitive) qu'une seul partie de la chaîne. - -Dans notre exemple du marteau (cela pourrait aussi être une faucille), -cela voudrait dire qu'on a sept threads qui effectuent chacun une tâche : -un qui accepte la commande, un qui la lit, et ainsi de suite. -La performance de cette approche est clairement limitée -par la tâche la plus lente à effectuer, il est donc primordial -que chacune soit bien équilibrée. A première vue, cette façon de faire ne semble -pas être très efficace, car chaque thread doit attendre sur le thread précédent pour -effectuer sa tâche. En fait cela n'est pas un problème. Lorsque le premier thread reçoit la première commande, il l'accepte et la passe au thread suivant. Il est donc libre de recevoir une autre commande pendant que le deuxième thread lit la commande. Quand le deuxième thread a lu la commande et la passe au troisième thread, il peut recevoir la commande suivante du premier, et ainsi de suite. Dans ce modèle, il est nécessaire d'avoir un mécanisme pour que les threads se passent le travail. On peut imaginer une approche où chaque thread du niveau $n$ -passe le travail à un thread libre de niveau $n+1$ mais cela nécessiterait un surcoût dans la mesure où il faudrait que le thread -$n$ attende la réponse du thread $n+1$ pour savoir s'il a accepté le travail. A nouveau, la solution de la file d'attente est la plus adaptée. -Les threads du niveau $n+1$ viennent récupérer dans la file -leur travail et se synchronisent pour éviter que plusieurs -ne prennent la même tâche. L'inconvénient principal de cette -méthode est que chaque niveau doit avoir une charge de travail équivalente et qu'il est assez complexe de maintenir -une charge équivalente au cours du temps : si une tâche commence à prendre plus de temps, il devient nécessaire de revoir l'équilibrage entier du pipeline. On peut, par exemple, imaginer utiliser le modèle pair -pour améliorer les performances d'un étage de la chaîne -en rajoutant des threads. - -[^1]: On peut se poser la question de façon plus générale. Peut-on quand même tirer partie d'un code -multi-threadé si on a plus de threads que de cœurs? diff --git a/hakyll-bootstrap/cours/sema.md b/hakyll-bootstrap/cours/sema.md deleted file mode 100644 index f534925..0000000 --- a/hakyll-bootstrap/cours/sema.md +++ /dev/null @@ -1,847 +0,0 @@ ---- -author: -- Orestis Malaspinas, Steven Liatti -title: Les sémaphores -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 sémaphores - -Le contenu de ce chapitre est basé sur l'excellent livre de R. H. Arpaci-Dusseau et A. C. Arpaci-Dusseau[^7]. - -Le sémaphore est une autre primitive de synchronisation inventée -par E. Dijkstra. En fait, les sémaphores peuvent être utilisés -pour remplacer toutes les autres primitives de synchronisation que -nous avons vues. - -Ici, nous allons voir comment utiliser les sémaphores pour synchroniser -des threads à l'intérieur d'un même processus, mais ces objets -sont beaucoup plus génériques et peuvent aussi servir -pour synchroniser *différents processus*. - -## Définition - -Un sémaphore est une structure contenant un nombre entier -que nous pouvons manipuler avec deux fonctions. Dans l'API POSIX -elles s'appellent `sem_wait()`{.language-c} et `sem_post()`{.language-c}[^1]. -Comme un sémaphore contient un entier, il faut l'initialiser -avec la fonction `sem_init()`{.language-c}. L'initialisation -prend trois paramètres, une pointeur vers le sémaphore, de type `sem_t`{.language-c}, -un entier, `pshared`{.language-c} spécifiant s'il est partagé entre les threads d'un -même processus (dans ce cas il vaut 0) ou entre processus (il vaut une valeur différente de zéro), et la valeur -de l'entier non-signé contenu dans le sémaphore, `value`{.language-c}. -Comme toujours cette fonction retourne 0 en cas de succès. - -```language-c -int sem_init(sem_t *sem, int pshared, unsigned int value); -``` - -Un sémaphore "à un" sera ainsi initialisé comme - -```language-c -#include <semaphore.h> -sem_t s; -int sem_init(&s, 0, 1); -// 0 est pour un sémaphore entre les threads d'un même processus -// 1 pour initialiser le sémaphore à un. -``` - -Avant de nous intéresser à leur implémentation[^2], intéressons-nous aux fonctions -`sem_wait()`{.language-c} et `sem_post()`{.language-c} qui servent à -interagir avec un sémaphore initialisé. En particulier, voyons -comment les utiliser. - -Comme décrit dans le pseudo-c ci-dessous, -les fonction `sem_wait()`{.language-c} va décrémenter -la valeur du sémaphore de un et retourner immédiatement si la valeur du sémaphore -est plus grande ou égale à zéro, sinon se mettre en sommeil. - -```language-c -int sem_wait(sem_t *s) { - // décrémente de un la valeur du sémaphore - // si la valeur du sémaphore est < 0 s'endormir -} -``` - -Si `sem_wait()`{.language-c} est appelé plusieurs fois à la suite -les threads mis en sommeil sont insérés dans une file, en attente de réveil. -On constate donc que si le sémaphore est négatif, sa valeur correspond au -nombre de fils qui sont en attente. Bien que ce ne soit pas forcément utile en pratique, c'est une bonne chose à se rappeler pour comprendre -comment fonctionnent les sémaphores. - -A l'inverse, `sem_post()`{.language-c} incrémentera la valeur du sémaphore -de un, et s'il y a un thread ou plus en sommeil, en réveiller un. - -```language-c -int sem_post(sem_t *s) { - // incrémente de un la valeur du sémaphore - // réveiller un thread endormi, si au moins un dort -} -``` - -## Les sémaphores comme verrous - -Notre but ici est de construire un verrou à l'aide d'un -sémaphore. - ---- - -Question # - -Pouvez-vous imaginer comment faire? - ---- - -En fait, il suffit d'initialiser le sémaphore à un, et d'avoir -un appel à `sem_wait()`{.language-c} et à `sem_post()`{.language-c}, -avant et après la section critique que nous souhaitons protéger -avec le verrou, comme on peut le voir dans le code ci-dessous. - -```language-c -sem_t s; -sem_init(&s, 0, 1); - -sem_wait(&s); -// Section critique -sem_post(&s); -``` - -Pour être sûr que j'ai bien compris ce qui se passe ici, je -vais expliquer plus en détails ce qui se passe. -Le sémaphore est initialisé à un. Puis, le premier thread, $T_1$, qui -appelle `sem_wait()`{.language-c} va décrémenter sa valeur à `0`{.language-c} -et retourner (comme la valeur du sémaphore est $\geq$`0`{.language-c}). - -Si aucun thread n'appelle `sem_wait()`{.language-c}, avant que -$T_1$ appelle `sem_post()`{.language-c}, la valeur du sémaphore -sera remise à un et un autre thread pourra appeler -`sem_wait()`{.language-c} et retourner immédiatement. - -Si en revanche, pendant que $T_1$ est dans la section critique, -un autre thread, $T_2$, appelle `sem_wait()`{.language-c}, -la valeur du verrou sera décrémentée à $-1$ et $T_2$ sera mis -en sommeil. Lorsque $T_1$ sera à nouveau ordonnancé, -il appellera `sem_post()`{.language-c}, incrémentera -le sémaphore de un (il aura la valeur `0`{.language-c}) et réveillera -$T_2$ qui pourra entrer dans la section critique. -Lorsque $T_2$ sort de la section critique et appelle -`sem_post()`{.language-c} à son tour, il fait à nouveau -passer le sémaphore à un et n'a pas de thread à réveiller. - ---- - -Question # - -Que se passe-t-il avec trois threads? - ---- - -Si maintenant, nous avons $T_1$, $T_2$ et $T_3$, trois threads qui -tentent d'acquérir le verrou. $T_1$ arrive en premier `sem_wait()`{.language-c}, il décrémente le sémaphore à 0 et entre dans la -section critique. Si $T_2$ appelle `sem_wait()`{.language-c} -à son tour avant que $T_1$ soit sorti de la section critique, -le sémaphore passe à `-1`{.language-c} et le thread se retrouve en sommeil. -Finalement, si $T_3$ arrive également à appeler `sem_wait()`{.language-c} -avant que $T_1$ soit sorti de la section critique, alors -le sémaphore passe à `-2`{.language-c} et $T_3$ est mis en sommeil. Finalement, -lorsque $T_1$ arrive enfin à sortir de la section critique, -appelle `sem_post()`{.language-c}, -ce qui fait passer le sémaphore à `-1`{.language-c} et réveille un thread (disons $T_2$). -Celui-ci à son tour appelle `sem_post()`{.language-c}, le sémaphore passe -à `0`{.language-c} et $T_3$ est réveillé. Il appelle également `sem_post()`{.language-c} -ce qui incrémente le sémaphore à `1`{.language-c} à nouveau et ne réveille personne -car personne ne peut être réveillé. Le sémaphore étant à -un de nouveau le "verrou-sémaphore" peut être acquis par un autre thread éventuellement. - -## Les sémaphores pour ordonner une séquence - -Les sémaphores peuvent aussi être utiles pour ordonner des événements -dans un programme concurrent. On peut par exemple souhaiter -attendre qu'une liste se remplisse pour récupérer des éléments à l'intérieur. -Nous avons déjà utilisé les variables de condition de cette façon -lorsque nous avions des threads qui attendaient qu'on leur -signale que l'état de l'application avait changé. - -On peut utiliser les sémaphores de façon similaire. Imaginons -le cas très simple d'un thread thread $T_1$ créant un thread $T_2$, -et que $T_1$ attende que $T_2$ se termine. Un exemple de ce genre -de programme pourrait être le suivant. - -```language-c -sem_t s; - -void *t2(void *arg) { - printf("Eh bien, tu vas attendre.\n"); - sem_post(&s); // signal here - return NULL; -} - -int main() { - sem_init(&s, 0, 0); - printf("J'aimerais bien attendre...\n"); - - pthread_t tid; - pthread_create(&tid, NULL, t2, NULL); - sem_wait(&s); - - printf("Merci de m'avoir fait attendre.\n"); - - return EXIT_SUCCESS; -} -``` - -Comme on le voit ici le sémaphore doit être initialisé à zéro. -Si `main()`{.language-c} atteint `sem_wait()`{.language-c} -avant que `t2()`{.language-c} atteigne `sem_post()`{.language-c}, il -décrémente la valeur du sémaphore à `-1`{.language-c} et se met en sommeil. -Lorsque `t2()`{.language-c} est ordonnancé et atteint `sem_post()`{.language-c} -il remet le sémaphore à `0`{.language-c} et réveille `main()`{.language-c}. -Les deux threads continuent ensuite leur vie[^3]. - -L'exécution de ce code afficherait - -```txt -J'aimerais bien attendre... -Eh bien, tu vas attendre. -Merci de m'avoir fait attendre. -``` - -## Le problème producteurs/consommateurs revisité - -Comme nous venons de le voir, on peut exprimer -la signalisation avec les sémaphores -et pas seulement avec les variables de conditions. -Bien que l'effet soit le même le raisonnement est différent. - -Dans cette section, nous allons revisiter le problème -producteurs/consommateurs avec les sémaphores. - ---- - -Rappel # - -On a des threads qui écrivent dans un buffer -d'une taille certaine, à l'aide d'une fonction -`put()`{.language-c} et d'autres qui lisent -depuis le buffer à l'aide d'une fonction `get()`{.language-c}. -Il faut bien synchroniser les threads afin -que le buffer ne soit pas vide quand on essaie de lire -et à l'inverse qu'on essaie pas d'écrire dans un buffer plein. - -Pour simplifier, le buffer est un tableau d'entiers -et on essaie de lire et d'écrire un entier à la fois. -Les fonctions `put()`{.language-c} et `get()`{.language-c} sont comme ci-dessous - -```language-c -#define MAX 1 -int buffer[MAX]; // MAX est déclaré avant - -int fill = 0; -int use = 0; - -void put(int val) { - buffer[fill] = val; - fill = (fill + 1) % MAX; -} - -int get() { - int tmp = buffer[use]; - use = (use + 1) % MAX; - return tmp; -} -``` - ---- - -### Tentative ratée - -En nous inspirant de ce que nous avons fait avec -les variables de conditions, nous allons utiliser -deux sémaphores: `empty`{.language-c} et `full`{.language-c}. Le code pourrait ressembler au code ci-dessous. - -```language-c -sem_t empty, full; - -void *producer(void *arg) { - for (int i = 0; i < loops; ++i) { - sem_wait(&empty); - put(i); - sem_post(&full); - } - return NULL; -} - -void *consumer(void *arg) { - int tmp = 0; - while (tmp != -1) { // condition d'arrêt - sem_wait(&full); - tmp = get(); - sem_post(&empty); - printf("%d\n", tmp); - } -} - -int main() { - sem_init(&empty, 0, MAX); // MAX buffers sont vides - sem_init(&full, 0, 0); // 0 buffers sont pleins -} -``` - -Imaginons que `MAX=1`{.language-c} et qu'il y a deux threads -$T_1$ et $T_2$ sur un seul CPU. Dans notre scénario $T_1$ est -le producteur et $T_2$ le consommateur. Si $T_1$ est ordonnancé -en premier, il va appeler `sem_wait(&full)`{.language-c} -en premier. Comme `full`{.language-c} est initialisé à `0`{.language-c}, il sera décrémenté à `-1`{.language-c} et -sera mis dans l'état *bloqué*. A présent $T_2$ est ordonnancé -comme il est seul, et va entrer dans sa boucle, -décrémenter le sémaphore `empty`{.language-c} à `0`{.language-c} -(on l'a initialisé à `MAX=1`{.language-c} rappelez-vous), -appeler `put(i)`{.language-c} , puis appeler -`sem_post(&full)`{.language-c} qui va incrémenter la valeur -du sémaphore `full`{.language-c} à `0`{.language-c} -et réveiller $T_1$ (le mettre -dans l'état *prêt*). Ensuite il peut se passer deux choses: - -1. $T_2$ continue son exécution et appeler `sem_wait(&empty)`{.language-c}. Il verra alors que la valeur de `empty`{.language-c} -est nulle et sera bloqué. -2. Si $T_2$ est interrompu et que $T_1$ est ordonnancé, -il consommera la valeur dans le buffer, incrémentera -`empty`{.language-c} à `1`{.language-c}. - -Dans les deux cas, le fonctionnement est bien celui qu'on souhaite. -En fait même avec plus de deux threads cela fonctionne (même -si c'est un peu compliqué à voir...). - ---- - -Question # - -Que se passe-t-il à présent si `MAX>1`{.language-c}? - ---- - -Dans le cas où `MAX>1`{.language-c}, nous avons plusieurs -producteurs et plusieurs consommateurs. Disons qu'il y a -deux producteurs $P_1$ et $P_2$ qui arrivent plus ou moins -en même temps à l'appel de `put()`{.language-c}. Si $P_1$ -passe en premier dans la fonction `put()`{.language-c} -et remplit `buffer[0]`{.language-c} avec la première valeur, -si ensuite avant d'incrémenter `fill`{.language-c} -$P_1$ est interrompu et $P_2$ remplit également -`buffer[0]`{.language-c}. Nous avons un **accès concurrent**!!! -On perd des données des producteurs et notre modèle -ne fonctionne pas. - -### Tentative ratée bis - -Dans cette implémentation on a pas d'exclusion mutuelle: -quand on écrit/lit dans le buffer, rien ne nous garantit qu'on -ne est le·la seul·e à le faire. On va utiliser -le sémaphore binaire (le "mutex-sémaphore") -de la section précédente -autour de `get()`{.language-c} et `put()`{.language-c} et -voir comment on s'en sort. - -```language-c -sem_t empty, full, mutex; - -void *producer(void *arg) { - for (int i = 0; i < loops; ++i) { - sem_wait(&mutex); - sem_wait(&empty); - put(i); - sem_post(&full); - sem_post(&mutex); - } - return NULL; -} - -void *consumer(void *arg) { - int tmp = 0; - while (tmp != -1) { // condition d'arrêt - sem_wait(&mutex); - sem_wait(&full); - tmp = get(); - sem_post(&empty); - sem_post(&mutex); - printf("%d\n", tmp); - } -} - -int main() { - sem_init(&empty, 0, MAX); // MAX buffers sont vides - sem_init(&full, 0, 0); // 0 buffers sont pleins - sem_init(&mutex, 0, 1); // It's a lock! -} -``` - ---- - -Question # - -Est-ce que ça marche? - ---- - -Ici, on pourrait se croire soti·e·s d'affaire. En fait non... -Il y a de très grandes chances qu'on ait un interblocage. -Reprenons nos deux threads $T_1$ (producteur) et $T_2$ (consommateur). $T_2$ est ordonnancé en premier, -il acquière le `mutex`{.language-c}, appelle `sem_wait(&full)`{.language-c} -mais comme il n'y a rien à lire dans le buffer il se met -en état bloqué et attend que $T_1$ le réveille. Sauf que -$T_1$ ne pourra jamais le réveiller, car il ne pourra jamais -acquérir le verrou: **deadlock**! En quelques mots: -$T_1$ détient le verrou et attend un signal de $T_2$ qui pourrait -envoyer le signal mais il attend la libération du verrou. - -### Tentative réussie - -Après tant de déceptions, il est temps de faire le code qui marche. - ---- - -Question # - -La solution est pourtant simple. Saurez-vous la trouver? - ---- - -En fait pour résoudre le problème il suffit d'inverser -l'ordre signal-verrou. Le code suivant fonctionne. - -```language-c -sem_t empty, full, mutex; - -void *producer(void *arg) { - for (int i = 0; i < loops; ++i) { - sem_wait(&empty); - sem_wait(&mutex); - put(i); - sem_post(&mutex); - sem_post(&full); - } - return NULL; -} - -void *consumer(void *arg) { - int tmp = 0; - while (tmp != -1) { // condition d'arrêt - sem_wait(&full); - sem_wait(&mutex); - tmp = get(); - sem_post(&mutex); - sem_post(&empty); - printf("%d\n", tmp); - } -} - -int main() { - sem_init(&empty, 0, MAX); // MAX buffers sont vides - sem_init(&full, 0, 0); // 0 buffers sont pleins - sem_init(&mutex, 0, 1); // It's a lock! -} -``` - -## Les verrous lecteurs/rédacteurs - -Pour certaines classes de problèmes, -nous avons besoin de verrous qui soient plus flexibles. En particulier, -différentes classes de structures de données peuvent nécessiter -différentes sortes de verrous. Par exemple, imaginons un certain -nombre d'opérations à effectuer sur des listes: l'insertion et -la recherche. Alors que l'insertion change l'état de la liste, -la recherche ne fait que lire dans la structure. Ainsi, aussi longtemps -que nous pouvons garantir qu'aucune insertion ne se fait, -on peut autoriser autant de recherches concurrentes qu'on le souhaite. -Nous allons donc écrire ici un verrou **lecteurs/rédacteur** -basé sur les sémaphores. - -Nous voulons donc créer un verrou, de type `rwlock_t`{.language-c}, -qui permettra à autant de lecteurs -que nous le voulons de lire des données, mais pas pendant qu'un rédacteur -est en train d'écrire. Nous devrons donc verrouiller/déverrouiller -de deux façons différentes. Une fois nous verrouillerons en écriture: -seul le thread rédacteur peut entrer dans la section critique. L'autre -fois, nous verrouillerons en écriture: autant de threads -lecteurs qu'on le souhaite pourront -entrer dans la section critique, qui ne sera protégée que du/des threads -rédacteurs. Nous aurons donc besoin de deux classes de -verrouillage/déverrouillage: un `acquire_/release_readlock`{.language-c} et un `acquire_/release_writelock`{.language-c}. Pour le verrou lecteur, il faut l'acquérir -lorsque le premier lecteur entre dans la section critique. -On le symbolise par un sémaphore `writelock`{.language-c}. Il ne -sera libéré qu'au moment où le dernier lecteur aura -fini sa lecture. Il faut donc avoir un moyen de garder -la trace du nombre de lecteurs simultanés: on introduit une variable -entière `num_readers`{.language-c} dans ce but. Finalement, cette variable -doit être également protégée par un second verrou -(nommé `lock`{.language-c}), pour éviter -sa modification par plusieurs lecteurs simultanément. -La partie du verrouillage pour le rédacteur est plus simple: -il suffit de verrouiller/déverrouiller le sémaphore binaire -`writelock`. Le code ci-dessous effectue cette implémentation. - -```language-c -typedef struct __rwlock_t { - int num_readers; - sem_t lock; - sem_t writelock; -} rwlock_t; - -void rwlock_init(rwlock_t *rw) { - rw->num_readers = 0; - sem_init(&lock, 0, 1); - sem_init(&writelock, 0, 1); -} - -void rwlock_acquire_readlock(rwlock_t *rw) { - sem_wait(&rw->lock); - rw->num_readers += 1; - if (rw->num_readers == 1) { // le premier lecteur acquière le verrou - sem_wait(&rw->writelock); // le sémaphore passe à 0, - // s'il est libre ou à < 0 s'il l'est pas - } - sem_post(&rw->lock); -} - -void rwlock_release_readlock(rwlock_t *rw) { - sem_wait(&rw->lock); - rw->num_readers -= 1; - if (rw->num_readers == 0) { // le dernier lecteur libère le verrou - sem_post(&rw->writelock); // le sémaphore passe à 1, - // et réveille un éventuel rédacteur - } - sem_post(&rw->lock); -} - -void rwlock_acquire_writelock(rwlock_t *rw) { - sem_wait(&rw->writelock); // verrouillage classique -} - -void rwlock_release_writelock(rwlock_t *rw) { - sem_post(&rw->writelock); // déverrouillage classique -} -``` - -Cette implémentation fonctionne. On voit par contre qu'il peut -y avoir un problème d'équité. En effet, il est assez facile -de voir que les threads rédacteurs peuvent souffrir -de la famine à cause des threads lecteurs. Pour éviter -ce problème il faudrait imaginer un système qui -empêche des nouveaux threads lecteurs d'entrer dans le verrou -quand un thread rédacteur attend. - ---- - -Exercice # - -Réaliser un verrou lecteurs/rédacteurs plus équitable.[^6] - ---- - -```language-c -typedef struct __rwlock_t { - int num_readers; - sem_t lock; - sem_t writelock; - sem_t waitforwrite; -} rwlock_t; - -void rwlock_init(rwlock_t *rw) { - rw->num_readers = 0; - sem_init(&lock, 0, 1); - sem_init(&writelock, 0, 1); - sem_init(&waitforwrite, 0, 1); -} - -void rwlock_acquire_readlock(rwlock_t *rw) { - sem_wait(&rw->waitforwrite); // Un seul lecteur à la fois. Vérouillé - // tant qu'un rédacteur attend - sem_post(&rw->waitforwrite); - sem_wait(&rw->lock); - rw->num_readers += 1; - if (rw->num_readers == 1) { // le premier lecteur acquière le verrou - sem_wait(&rw->writelock); // le sémaphore passe à 0, - // s'il est libre ou à < 0 s'il l'est pas - } - sem_post(&rw->lock); -} - -void rwlock_release_readlock(rwlock_t *rw) { - sem_wait(&rw->lock); - rw->num_readers -= 1; - if (rw->num_readers == 0) { // le dernier lecteur libère le verrou - sem_post(&rw->writelock); // le sémaphore passe à 1, - // et réveille un éventuel rédacteur - } - sem_post(&rw->lock); -} - -void rwlock_acquire_writelock(rwlock_t *rw) { - sem_wait(&rw->waitforwrite); // bloque les lecteurs si sur le point d'écrire - sem_wait(&rw->writelock); // verrouillage classique -} - -void rwlock_release_writelock(rwlock_t *rw) { - sem_post(&rw->writelock); // déverrouillage classique - sem_post(&rw->waitforwrite); // libere les lecteurs apres écriture -} -``` - -## Le dîner des philosophes - -Le problème du *dîner des philosophes* a été proposé et résolu par -E. Dijkstra. C'est un problème classique de concurrence qui -concerne le partage de ressources informatiques: l'ordonnancement et leur allocation. - -{#fig:philosophes width=50%} - -Considérons cinq philosophes, qui ont chacun à leur droite une -fourchette (cela fait donc cinq fourchettes au total également, -voir @fig:philosophes). -Ils ont devant eux une assiette avec de la nourriture -(par exemple un magnifique et juteux filet de bœuf parfaitement -cuit, donc bleu[^4]). Les philosophes peuvent faire deux choses: penser et manger. Ils peuvent penser pendant un temps indéfini -et n'ont besoin d'aucun matériel pour le faire. -En revanche, pour manger chaque philosophe a besoin de deux fourchettes, -celle qui se trouve à sa gauche et celle qui se trouve à sa droite, -mais chaque fourchette ne peut être tenue que par un seul philosophe -à la fois. Il faut donc que les philosophes reposent leurs fourchettes -dès qu'ils ont fini de manger. Ils peuvent également -se saisir des fourchettes sans que les deux soient libres, -mais doivent impérativement avoir les deux en main pour manger. - -Le problème est de réussir à faire en sorte que les philosophes -ne meurent pas de faim, car chacun va manger ou penser, mais -aucun des philosophes ne sait quand les autres vont faire. - -Il est plus difficile que prévu d'écrire un algorithme robuste -à ce problème. Par exemple, l'algorithme suivant ne fonctionnerait -pas. Chaque philosophe (qui représentent en fait des threads) -fera ces actions - -* Penser jusqu'à ce que la fourchette de droite soit libre, quand elle l'est, la ramasser. -* Penser jusqu'à ce que la fourchette de gauche soir libre, -quand elle l'est la ramasser. -* Manger un certain temps lorsque les deux fourchettes sont -en ma possession. -* Poser la fourchette de gauche. -* Poser la fourchette de droite. -* Recommencer dès le début. - -On voit assez facilement, que cet algorithme peut produire un -interblocage. En effet, si par malheur les cinq philosophes -décident de ramasser la fourchette de droite en même temps, -ils vont se retrouver bloqués, sans moyen -de se débloquer. Un autre problème qui pourrait se produire -est qu'un philosophe meure de faim, car il n'arrive pas à -se saisir des deux fourchettes, pendant qu'un ou plusieurs autres se gavent de nourriture. - -Le problème principal ici, c'est que nous n'utilisons aucune -primitive d'exclusion mutuelle permettant de synchroniser l'accès -aux ressources. Voyons à comment en pratique résoudre ce problème. - -Chaque philosophe va effectuer en boucle la séquence d'opérations suivantes (code en pseudo-c). - -```language-c -while(1) { - think(); - get_forks(); - eat(); - put_forks(); -} -``` - -Alors que `tink()`{.language-c} et `eat()`{.language-c}, -sont deux opérations qui peuvent d'effectuer sans synchronisation, -les fonctions `get_forks()`{.language-c} et `put_forks()`{.language-c} doivent être écrites avec plus de soin -pour éviter famine, interblocages, et tenter d'avoir -un maximum de philosophes en train de manger (parce que manger c'est bon). - -Avant d'aller plus loin, on introduit deux fonctions qui nous -seront utiles. - -```language-c -int get_left(int p) { - return (p + 4) % 5; -} - -int get_right(int p) { - return p; -} -``` - -Alors que la fourchette de droite de chaque philosophe a le même -indice que lui, celle de gauche est obtenue grâce à cette -opération modulo. On a par exemple que $P_0$ -a à sa droite $f_0$ et à sa gauche $f_{(0+4)\%5}=f_4$ -comme prévu (ouf). - -Nous allons également utiliser cinq sémaphores -pour synchroniser l'accès aux fourchettes. - -```language-c -sem_t forks[5]; -``` - -### Une solution ratée - -Comme vous commencez à en avoir l'habitude, on commence -par écrire une solution qui ne marche pas. -On commence à initialiser toues les sémaphores -à un. - -```language-c -void get_forks(void *arg) { - int p = *(int *)arg; - sem_wait(&forks[left(p)]); - sem_wait(&forks[right(p)]); -} - -void put_forks(void *arg) { - int p = *(int *)arg; - sem_post(&forks[left(p)]); - sem_post(&forks[right(p)]); -} -``` - -Ici, on a simplement écrit l'algorithme décrit un peu plus haut. -Chaque philosphe commence par attendre que sa fourchette -de gauche soit libre, puis que celle de droite soit libre -avant de pouvoir manger. Puis, lorsqu'il a fini, il repose la fourchette de -gauche, puis celle de droite et se remet à penser. - -Exactement comme on l'a discuté tout à l'heure, cette solution -peut donner lieu à un *deadlock*. Si chaque philosophe -prend en même temps sa fourchette de gauche, -tous les philosophes attendront sur leur fourchette -de droite et seront bloqués. - -### Une solution possible - -La façon la plus simple de résoudre ce problème est de modifier -l'ordre d'acquisition d'un des philosophes. Si $P_0$ -acquière d'abord la fourchette de droite au lieu de celle de gauche -il n'y a pas moyen que tous les philosophes se retrouvent en attente. - -```language-c -void get_forks(void *arg) { - int p = *(int *)arg; - if (p == 0) { - sem_wait(&forks[right(p)]); - sem_wait(&forks[left(p)]); - } else { - sem_wait(&forks[left(p)]); - sem_wait(&forks[right(p)]); - } -} -``` - -### Le mot de la faim - -Il existe toute une classe de problèmes similaires pour réfléchir -sur les problèmes de concurrence. En particulier, citons -le problème du **barbier assoupi**, ou des **fumeurs de cigarettes**[^5]. - -## L'implémentation des sémaphores - -Histoire de comprendre un peu mieux le fontionnement des sémaphores, -essayons d'en cronstruire un à partir des primitives de synchronisation -que nous avons à disposition: les verrous et les variables -de condition. Nous allons appeler ces sémaphores maison les *séphamores*. - -En plus de la variable de condition et du verrou, il faut -utiliser un entier qui stocke la valeur du séphamore. - -```language-c -typedef struct __seph_t { - int value; - phtread_cond_t cond; - pthread_mutex_t mutex; -} seph_t; -``` - -Ensuite, il faut implémenter trois fonctions: l'intialisation, `init()`{.language-c}, la mise en attente `wait()`{.language-c}, et le réveil -`post()`{.language-c}. Ces trois fonctions peuvent être implémentées -de la façon suivante. - -```language-c -// Un seul thread appelle ça. -void seph_init(seph_t &s, int value) { - s->value = value; - pthread_cond_init(&s->cond, NULL); - pthread_mutex_init(&s->mutex, NULL); -} - -// Un seul thread appelle ça. -void seph_destroy(seph_t &s, int value) { - s->value = value; - pthread_cond_destroy(&s->cond); - pthread_mutex_destroy(&s->mutex); -} - -void seph_wait(seph_t &s) { - pthread_mutex_lock(&s->mutex); - while (s->value <= 0) { - pthread_cond_wait(&s->cond); - } - s->value -= 1; - pthread_mutex_unlock(&s->mutex); -} - -void seph_post(seph_t &s) { - pthread_mutex_lock(&s->mutex); - s->value += 1; - pthread_cond_signal(&s->cond); - pthread_mutex_unlock(&s->mutex); -} -``` - ---- - -Question # - -Cette façon d'implémenter les sémaphores ne correspond pas tout à fait -à ce que nous avons vu au début de ce chapitre. Pouvez-vous dire -quelle est la différence? - ---- - -En fait, on constate que le sémaphore tel que nous l'avons implémenté ici -n'a pas d'équivalence entre le nombre de threads en attente -et sa valeur. En effet, ici la valeur du sémaphore ne sera jamais plus -petite que zéro. - - -[^1]: Historiquement ces fonctions s'appelaient `P()`{.language-c} et -`V()`{.language-c} pour "prolaag" (contraction voulant dire essayer et -diminuer) et "verhoog" pour augmenter en néerlandais, la langue de E. Dijkstra. -[^2]: Il est clair que comme plusieurs threads vont appeler ces fonctions -elles devront contenir des sections critiques qu'il faudra gérer avec soin. -On suppose pour le moment que toutes ces opérations sont atomiques. -[^3]: Le cas où `t2()` atteint `sem_post()` -en premier est laissé à faire en exercice au lecteur. Le premier -`merge request` gagne $0.1$ points sur l'examen. -[^4]: Comme vous pouvez le remarquer ils ont que des fourchettes -pour manger du filet de bœuf c'est pas super réaliste, mais c'est -pas moi qui ai posé le problème... -[^5]: Fumer nuit gravement à la santé. -[^6]: Cet exercice est laissé à faire en exercice au lecteur. Le premier -`merge request` gagne $0.1$ points sur l'examen. -[^7]: R. H. Arpaci-Dusseau et A. C. Arpaci-Dusseau, *Operating Systems: Three Easy Pieces*, Arpaci-Dusseau Books, ed. 0.91, (2015). \ No newline at end of file diff --git a/hakyll-bootstrap/cours/structures_conc.md b/hakyll-bootstrap/cours/structures_conc.md deleted file mode 100644 index 8cfc741..0000000 --- a/hakyll-bootstrap/cours/structures_conc.md +++ /dev/null @@ -1,487 +0,0 @@ ---- -author: -- Orestis Malaspinas, Steven Liatti -title: Structures de données concurrentes -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 structures de données basées sur les verrous - -Le contenu de ce chapitre est basé sur l'excellent livre de R. H. Arpaci-Dusseau et A. C. Arpaci-Dusseau[^3]. - -Dans ce chapitre, nous allons brièvement voir comment utiliser des verrous -dans des structures de données standards de façon à ce qu'elles soient -sûres à l'utilisation dans des applications multi-threadées. -La difficulté principale est de garder une bonne performance tout en garantissant un fonctionnement correct. - -## Les compteurs concurrents - -La structure de données la plus simple qu'on puisse imaginer est un simple -compteur. Nous avons déjà vu dans les exercices comment incrémenter -de façon fausse un compteur de façon concurrente, puis une façon juste mais peu efficace. Histoire de formaliser tout ceci un peu ici, -écrivons un compteur atomique dans la structure `counter_t`{.language-c} -ainsi que les opérations d'incrémentation et de décrémentation. - -```language-c -typedef struct __counter_t { - int value; // la valeur du compteur - pthread_mutex_t lock; // le mutex protégeant le compteur -} counter_t; - -void init(counter_t *cnt) { - cnt->value = 0; - pthread_mutex_init(&cnt->lock, NULL); -} - -void increment(counter_t *cnt) { - pthread_mutex_lock(&cnt->lock); - cnt->value += 1; - pthread_mutex_unlock(&cnt->lock); -} - -void decrement(counter_t *cnt) { - pthread_mutex_lock(&cnt->lock); - cnt->value -= 1; - pthread_mutex_unlock(&cnt->lock); -} - -int get_value(counter_t *cnt) { - pthread_mutex_lock(&cnt->lock); - int value = cnt->value; - pthread_mutex_unlock(&cnt->lock); - - return value; -} -``` - -Ici, nous avons rendues atomiques les opérations d'incrémentation, de décrémentation et de retour de la valeur du compteur en protégeant les -sections critiques (d'incrémentation, de décrémentation et du compteur) -à l'aide de verrous. Notre structure de données est donc parfaitement -concurrente et fonctionne correctement. Néanmoins, on peut assez facilement -se rendre compte que cette méthode peut souffrir d'un problème de performances. Néanmoins, on a ici une méthode très très simple -de rendre une structure de données concurrente: ajouter un verrou! -Pour rendre ces structures plus efficaces, il faut utiliser la ruse. - -Pour illustrer le problème de performances d'un code n'utilisant -qu'un simple verrou, on peut s'intéresser à la performance du code de l'incrémentation de notre compteur un million de fois. Si on mesure le temps d'exécution du code sur un, deux ou quatre fils d'exécution, -on obtient le tableau ci-dessous : - -| | 1 thread | 2 threads | 4 threads | -| --------------|:--------------:|:---------------:|:--------------:| -| Temps $[ms]$ | 0.031 | 0.149 | 0.447 | - -On voit un ralentissement assez spectaculaire de l'exécution (plus d'un facteur 10) entre le cas à un seul thread et le cas à 4 threads. Ici, -la quantité de travail à effectuer étant constante par processeur (chaque -CPU devait incrémenter un million de fois la variable) on aimerait que -le temps total reste constant (ce qui serait une **mise à l'échelle parfaite**, ou **perfect scaling**). - -Différentes approches pour résoudre ce problème ont été proposées. Ici, nous allons étudier celle du **approximate counter**. L'idée générale -est d'avoir **plusieurs** compteurs logiques locaux (un par CPU) et un compteur **global**. On aura en plus un verrou **local** par CPU, -lié aux compteurs locaux (celui-ci n'est nécessaire qu'en cas d'exécution de plusieurs -threads par CPU), un verrou **global** pour le compteur global. -On va incrémenter "localement" tous les compteurs des CPUs qui se synchroniseront -avec leurs verrous locaux respectifs. Comme les threads dans leurs CPUs respectifs -peuvent incrémenter leurs compteurs sans contention, la mise à l'échelle -sera bonne. Néanmoins, il est nécessaire de synchroniser les -compteurs entre les CPUs afin de pouvoir lire la valeur du compteur -si nécessaire. On devra donc périodiquement transférer les valeurs -des compteurs locaux au compteur global de temps en temps, -en additionnant tous les compteurs locaux au compteur global. -A ce moment là les compteurs locaux seront remis à zéro. - -Le moment où les compteurs locaux seront synchronisés dépendra d'une variable, $s$. Plus $s$ sera grand, moins la valeur stockée dans $s$ sera précise (plus il y aura de chances qu'elle ne contienne pas la bonne valeur), mais plus le calcul aura une bonne performance sur plusieurs threads. A l'inverse un petit $s$ aura de moins bonnes performances, mais -donnera une valeur plus correcte du compteur. -Considérons un exemple où on a 4 threads $T_1$-$T_4$, avec quatre compteurs locaux $L_1$ à $L_4$, et où $s=4$. -Un exemple d'exécution est résumé dans la table ci-dessous - -| Temps | $L_1$ | $L_2$ | $L_3$ | $L_4$ | $G$ | -| --------------|:--------------:|:--------------:|:--------------:|:--------------:|:--------------:| -| 0 | 0 | 0 | 0 | 0 | 0 | -| 1 | 1 | 0 | 1 | 1 | 0 | -| 2 | 2 | 0 | 1 | 2 | 0 | -| 3 | 3 | 1 | 1 | 3 | 0 | -| 4 | 4 | 1 | 2 | $4\rightarrow 0$ | 4 (de $L_4$) | -| 5 | $4\rightarrow 0$ | 2 | 3 | 0 | 8 (de $L_1$) | - -Les compteurs locaux s'incrémentent chacun à leur vitesse, mais lorsqu'un d'entre eux atteint $s=4$, sa valeur -est ajoutée au compteur global $G$, et il est remis à zéro. On voit donc pourquoi -la valeur est approximée. Au temps 5, le compteur -a été incrémenté 13 fois, hors la valeur que nous pouvons lire -dans le compteur global est de huit uniquement. - -On peut à présent écrire le code suivant pour le compteur approximé : - -```language-c -typedef struct __approx_counter_t { - int glob_counter; // global counter - pthread_mutex_t glob_mutex; // global mutex - - int *local_counter; // local counter array - int threshold; // threshold where we communicate -} approx_counter_t; - -void init(approx_counter_t *ac, int threshold) { - ac->threshold = threshold; - - ac->glob_counter = 0; - pthread_mutex_init(&ac->glob_mutex, NULL); - - ac->local_counter = malloc(num_threads * sizeof(int)); - ac->local_mutex = malloc(num_threads * sizeof(pthread_mutex_t)); - for (int i = 0; i < num_threads; ++i) { - ac->local_counter[i] = 0; - pthread_mutex_init(&ac->local_mutex[i], NULL); - } -} - -void increment_by(approx_counter_t *ac, int tid, int amount) { - ac->local_counter[tid] += amount; - if (ac->local_counter[tid] >= ac->threshold) { - pthread_mutex_lock(&ac->glob_mutex); // verrou global - ac->glob_counter += ac->local_counter[tid]; - pthread_mutex_unlock(&ac->glob_mutex); // fin verrou global - ac->local_counter[tid] = 0; - } -} - -int get_counter(approx_counter_t *ac) { - pthread_mutex_lock(&ac->glob_mutex); - int value = ac->glob_counter; - pthread_mutex_unlock(&ac->glob_mutex); - return value; -} -``` - -Il faut noter que dans notre structure `approx_counter_t`{.language-c} -nous n'avons pas utilisé de verrou local. On suppose ici par simplicité qu'il n'y a qu'un thread par cœur. - -On peut à présent mesurer la performance de ce compteur pour un $s$ donné si on incrémente un million de fois un compteur sur un à quatre threads. - -| | 1 thread | 2 thread | 4 thread | -| --------------|:--------------:|:--------------:|:--------------:| -| Temps, $s=1$, $[ms]$ | 0.053 | 0.36 | 0.69 | -| Temps, $s=10$, $[ms]$ | 0.012 | 0.044 | 0.086 | -| Temps, $s=100$, $[ms]$ | 0.008 | 0.018 | 0.032 | -| Temps, $s=1000$, $[ms]$ | 0.009 | 0.01 | 0.012 | - -## La liste chaînée concurrente - -Une liste chaînée est une structure de données où un élément de notre -liste contient une valeur, ainsi qu'un pointeur vers le prochain -élément de la liste (vous avez déjà vu ces choses là en première). -Un petit code tout simple implémentant -l'insertion en tête de la liste d'un élément -entier peut s'écrire de la façon suivante, ainsi qu'une -fonction permettant de déterminer si un élément est bien présent -dans votre liste peut se trouver ci-dessous : - -```language-c -typedef struct __node_t { - int key; - struct __node_t *next; -} node_t; - -typedef struct __list_t { - node_t *head; -} list_t; - -void init(list_t *l) { - l->head = NULL; -} - -int insert(list_t *l, int key) { - node_t *new = malloc(sizeof(node_t)); - if (new == NULL) { - printf("Malloc failed.\n"); - return -1; - } - new->key = key; - l->head = new; - return 0; -} - -int lookup(list_t *l, int key) { - node_t *current = l->head; - while (current) { - if (current->key) { - return 0; - } - current = current->next; - } - return -1; -} -``` - -Il est évident qu'avec ce petit bout de code il est impossible -d'insérer et de vérifier la présence ou non d'un élément dans notre -liste de façon concurrente. - -On veut à présent réécrire ce code pour qu'il soit utilisable dans une application multi-threadée. La première approche serait de protéger -le moment de l'insertion, ainsi que la lecture par un verrou -qui serait intégré dans notre structure `list_t`. Le code deviendrait - -```language-c -typedef struct __node_t { - int key; - struct __node_t *next; -} node_t; - -typedef struct __list_t { - node_t *head; - pthread_mutex_t mutex; -} list_t; - -void init(list_t *l) { - l->head = NULL; - pthread_mutex_init(&l->mutex, NULL); -} - -int insert(list_t *l, int key) { // on retourne vrai si tout s'est bien passé - pthread_mutex_lock(&l->mutex); // début de la section critique - node_t *new = malloc(sizeof(node_t)); - if (new == NULL) { - printf("Malloc failed.\n"); - pthread_mutex_unlock(&l->mutex); // fin de la section critique - return -1; // error - } - new->key = key; - new->next = l->head; - l->head = new; - pthread_mutex_unlock(&l->mutex); // fin de la section critique - - return 0; // success -} - -int lookup(list_t *l, int key) { - pthread_mutex_lock(&l->mutex); // début de la section critique - node_t *current = l->head; - while (current) { - if (current->key == key) { - pthread_mutex_unlock(&l->mutex); // autre fin de la section critique - return 0; - } - current = current->next; - } - pthread_mutex_unlock(&l->mutex); // autre fin de la section critique - - return -1; -} -``` - -Regardons d'abord la fonction `lookup()`{.language-c}. On constate -qu'au tout début on `lock()`{.language-c} notre verrou, afin de protéger la -partie où on va vérifier si un élément est présent ou non dans notre -liste. Il faut en effet être certain·e·s qu'aucun nouvel élément n'est -ajouté pendant que nous lisons, car cela pourrait avoir des effets dramatiques[^1]. Le verrou est libéré non seulement lorsqu'on a trouvé -l'élément `key`{.language-c} mais également lorsqu'il est absent: -il y a deux endroits distincts où il faut penser à faire un `unlock()`{.language-c}. En effet, il faut libérer le verrou **avant** de sortir -de la fonction sinon le verrou ne sera jamais déverrouillé... - -La fonction `insert()`{.language-c} verrouille le `mutex`{.language-c}, crée l'élément suivant dans la liste et l'insère en tête -avant de déverrouiller le `mutex`{.language-c}. Rien de très fou. Néanmoins, il faut remarquer -que le verrou doit être libéré dans le cas où `malloc()`{.language-c} -retourne une erreur. Bien que cela ne se produise pas souvent, -ce cas peut conduire à des threads qui attendent indéfiniment -après une erreur d'allocation. De façon générale, -il faut être prudent·e lorsqu'on a des branchement conditionnels -qui modifient l'exécution d'un programme. Souvent c'est -dans ce genre de branchement que se trouvent les erreurs -les plus difficiles à détecter. - -Cette façon d'écrire une liste chaînée concurrente -est simple, mais nous utilisons trop de branchements conditionnels -ce qui augmente les chances d'introduire des erreurs. -Il est possible de modifier légèrement le code -afin d'avoir un plus petit nombre de chemins -possibles pour le code, réduisant le nombre de libérations -de verrous à effectuer : - -```language-c -void init(list_t *l) { - l->head = NULL; - pthread_mutex_init(&l->mutex, NULL); -} - -void insert(list_t *l, int key) { // on retourne vrai si tout s'est bien passé - node_t *new = malloc(sizeof(node_t)); // malloc est thread safe - if (new == NULL) { - printf("Malloc failed.\n"); - return; // error - } - new->key = key; - - pthread_mutex_lock(&l->mutex); // début de la section critique - new->next = l->head; - l->head = new; - pthread_mutex_unlock(&l->mutex); // fin de la section critique -} - -int lookup(list_t *l, int key) { - int ret_val = -1; - pthread_mutex_lock(&l->mutex); // début de la section critique - node_t *current = l->head; - while (current) { - if (current->key == key) { - ret_val = 0; - break; - } - current = current->next; - } - pthread_mutex_unlock(&l->mutex); // fin de la section critique - - return ret_val; -} -``` - -Ici, nous constatons que nous avons simplement enlevé -l'acquisition du verrou devant `malloc()`{.language-c}. -Cela est possible, car `malloc()`{.language-c} est *thread-safe*. -Nous n'avons besoin de verrouiller que lorsqu'on écrit ou qu'on -lit dans la liste. Par ailleurs, en ne retournant qu'une seule fois -depuis `lookup()`{.language-c}, on s'affranchit -de déverrouiller une fois supplémentaire. - -Ce genre de liste chaînée n'a pas une grande efficacité -lorsqu'on augmente le nombre de threads. Néanmoins, nous n'avons pas réussi -à faire beaucoup mieux. Une technique explorée par les chercheurs -a été le "hand-over-hand locking". L'idée est la suivante. Au lieu -d'avoir un seul verrou pour toute la liste chaînée, -on a un verrou par noeud. Lorsqu'on parcourt la liste, -on acquière d'abord le verrou du nœud suivant et libère le -verrou du nœud courant. Ainsi, la liste peut être parcourue de façon -concurrente. Néanmoins, le coût de verrouillage/déverrouillage -rend cette façon de faire moins efficace en pratique. - -## La file concurrente - -De façon similaire à ce que nous avons fait jusque là , nous -allons écrire une file concurrente. Faites si vous voulez -la partie simple consistant à écrire une file séquentielle, -puis à la rendre concurrente à l'aide d'un verrou -de la façon la plus triviale possible. Ici, nous allons -étudier un algorithme proposé par Michael et Scott[^2]. -Un code reproduisant leur idée se trouve ci-dessous : - -```{.language-c} -typedef struct __node_t { - int value; - struct __node_t *next; -} node_t; - -typedef struct __queue_t { - node_t *head; - node_t *tail; - pthread_mutex_t head_lock; - pthread_mutex_t tail_lock; -} queue_t; - -void init(queue_t *q) { - node_t *tmp = malloc(sizeof(node_t)); - tmp->next = NULL; - q->head = q->tail = tmp; - pthread_mutex_init(&q->head_lock, NULL); - pthread_mutex_init(&q->tail_lock, NULL); -} - -void enqueue(queue_t *q, int value) { - node_t *tmp = malloc(sizeof(node_t)); - assert(tmp != NULL); - - tmp->value = value; - tmp->next = NULL; - - pthread_mutex_lock(&q->tail_lock); - q->tail->next = tmp; - q->tail = tmp; - pthread_mutex_unlock(&q->tail_lock); -} - -int dequeue(queue_t *q, int *value) { - pthread_mutex_lock(&q->head_lock); - node_t *tmp = q->head; - node_t *newHead = tmp->next; - if (newHead == NULL) { - pthread_mutex_unlock(&q->head_lock); // attention branchement - return -1; // queue was empty - } - *value = newHead->value; - q->head = newHead; - pthread_mutex_unlock(&q->head_lock); // attention branchement - - free(tmp); - return 0; -} -``` - -Dans ce code, on constate qu'il y a deux verrous: un pour la queue et un -pour la tête. On peut ainsi avoir une approche suffisamment fine pour -enfiler ou défiler de façon concurrente (les deux opérations -ne sont pas mutuellement exclusives). Cela est rendu possible -par la création d'un nœud fictif lors de la création de la file. -Sans lui les fonction `enqueue()`{.language-c} et `dequeue()`{.language-c} -ne pourraient avoir lieu de façon concurrente. - -## La table de hachage concurrente - -A l'aide de la liste concurrente que nous avons implémenté tout à l'heure, -il est très simple de créer une table de hachage concurrente (très simple) -concurrente: elle sera statique. - -```language-c -#define BUCKETS (101) - -typedef struct __hash_t { - list_t lists[BUCKETS]; -} hash_t; - -void init(hash_t *h) { - for (int i = 0; i < BUCKETS; i++) { - list_init(&h->lists[i]); - } -} - -int insert(hash_t *h, int key) { - int bucket = key % BUCKETS; - return list_insert(&h->lists[bucket], key); -} - -int lookup(hash_t *h, int key) { - int bucket = key % BUCKETS; - return list_lookup(&H->lists[bucket], key); -} -``` - -On constate que cette table de hachage ne nécessite aucun nouveau verrou. -Toutes les sections critiques sont cachées dans les fonctions -de la liste chaînée. En effet, la table de hachage n'est -rien d'autre qu'un tableau de listes chaînées dans le cas -simple où le nombre d'alvéoles est statique. - ---- - -Exercice # - -Implémenter la table de hachage dynamique. - ---- - -[^1]: Plusieurs bébés chats sont morts à la suite de lectures non protégées de listes concurrentes. -[^2]: M. Michael, M. Scott, *Nonblocking Algorithms and Preemption-safe Locking on by Multiprogrammed Shared-memory Multiprocessors* *Journal of Parallel and Distributed Computing*, **51**, No. 1, (1998). -[^3]: R. H. Arpaci-Dusseau et A. C. Arpaci-Dusseau, *Operating Systems: Three Easy Pieces*, Arpaci-Dusseau Books, ed. 0.91, (2015). \ No newline at end of file diff --git a/hakyll-bootstrap/cours/verrous.md b/hakyll-bootstrap/cours/verrous.md deleted file mode 100644 index ea838e9..0000000 --- a/hakyll-bootstrap/cours/verrous.md +++ /dev/null @@ -1,853 +0,0 @@ ---- -author: -- Orestis Malaspinas, Steven Liatti -title: Introduction aux verrous -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 verrous - -Le contenu de ce chapitre est basé sur l'excellent livre de R. H. Arpaci-Dusseau et A. C. Arpaci-Dusseau[^3]. - -Dans ce chapitre nous allons discuter plus en détails le concept de verrous. -Nous allons d'abord voir comment nous pourrions tenter de construire -un verrou dans un programme et se rendre compte que cela est -quelque chose de compliqué pour que le verrou soit effectivement -un mécanisme d'exclusion mutuelle (qu'il remplisse bel et bien son rôle) et qu'il -soit efficace. - -## Les verrous: généralités - -Le but d'un verrou est de s'assurer qu'uniquement un fil d'exécution à la fois peut accéder -à une **section critique** d'un code. Une section critique contient des variables (contenant des données) -partagées par plusieurs threads qui y sont modifiées (si on ne fait que lire des données -il n'y a pas de problème en principe). Un exemple, de protection peut se voir avec l'exemple du compteur -des exercices de la section sur l'api des threads POSIX - -```language-c -pthread_mutex_t verrou = PTHREAD_MUTEX_INITIALIZER; -int counter = 0; - -// du code - -pthread_mutex_lock(&verrou); -counter += 1; -pthread_mutex_unlock(&verrou); - -// encore du code -``` - -Un verrou est une variable, qui peut être dans deux états: - -1. Libre (déverrouillé, disponible): aucun thread ne possède le verrou. -2. Occupée (acquis, verrouillé): exactement un thread possède le verrou et se trouve dans une section critique du programme. - -On constate en premier lieu que dans l'interface POSIX un verrou est un `mutex`{.language-c} pour **mutual exclusion**. - -La syntaxe pour le verrouillage/déverrouillage du verrou est très simple. -Lorsque que `pthread_mutex_lock()`{.language-c} est appelé dans un thread, le fil d'exécution tente -d'acquérir le verrou. S'il y parvient (cela veut dire qu'aucun autre thread ne l'a verrouillé) -il devient le **propriétaire** du verrou, celui-ci entre dans l'état occupé (c'est pour cela que nous passons -**la référence** au verrou à `pthread_mutex_lock()`{.language-c}), -et le thread entre dans une section critique. -S'il n'y parvient pas, il va rester bloqué dans cette fonction, jusqu'à ce -qu'il puisse acquérir le verrou. - -Quand le propriétaire d'un verrou (il doit l'avoir verrouillé avant), -appelle la fonction `pthread_mutex_unlock()`{.language-c}, -à la fin d'une section critique. Un autre thread peut donc en devenir le propriétaire -et entrer dans sa section critique. Il n'y a aucune garantie quant à l'ordre -de l'acquisition du verrou par plusieurs threads, si plusieurs sont en attente -à leur fonction `pthread_mutex_lock()`{.language-c}. En revanche, -si aucun fil d'exécution n'attend pour devenir propriétaire du verrou, -il passe dans l'état libre (c'est pour cela que nous passons -**la référence** au verrou à `pthread_mutex_lock()`{.language-c}). - -On peut également noter qu'un verrou n'est rien d'autre qu'une variable. -On peut donc en définir différents tout au long de l'exécution d'un programme. -Chacun peut protéger des structures de données différentes dépendant -de la **granularité**, **fine** ou **grossière** que chacun veut donner -aux section critiques à protéger. Utiliser une approche plus fine -permet "d'augmenter la concurrence" mais cela vient aussi avec -une plus grande chance d'introduire des bugs. - ---- - -Remarque # - -Il est important de noter que l'api des threads ne permet aucun contrôle sur l'ordre -de leur exécution (tout est géré par l'OS). Les verrous permettent de récupérer -un peu de contrôle en garantissant qu'un seul thread à la fois entre une section critique. - ---- - -## Construisons notre verrou - -Comme nous allons le voir dans cette section, construire un verrou n'est pas simple du tout. -En particulier, il serait tentant d'utiliser une simple variable booléenne pour -garantir l'exclusion mutuelle. En fait, on se rendra compte que cela n'est pas possible -sans l'aide du matériel et de l'OS. - ---- - -Notations # - -Pour plus de généralité (et de simplicité dans les notations), nous noterons -l'acquisition du verrou par la fonction `lock()`{.language-c} et -sa libération `unlock()`{.language-c} et n'utiliserons pas l'api -POSIX dans ce qui suit. - ---- - -Avant de construire un verrou à proprement parler, nous devons d'abord -définir quelles propriétés il doit avoir: - -1. Il doit garantir **l'exclusion mutuelle**. Cela est la tâche primordiale d'un verrou. -S'il ne garantit pas qu'un seul thread à la fois peut accéder à la partie -du code protégée par le verrou, alors le verrou est inutile. -2. Il doit être aussi **équitable** que possible. Tous les fils d'exécution doivent -avoir la possibilité d'acquérir le verrou, sinon ils risquent une terrible -**famine** et n'auront jamais accès à la section critique. -3. La **performance** doit être bonne. Il y a deux cas de figure possibles: - * le cas où il n'y a qu'un fil qui effectue le verrouillage/déverrouillage. - * le cas où plusieurs threads sur un seul processeur combattent pour - acquérir le verrou. - * le cas où plusieurs threads, sur plusieurs CPUs sont en concurrence - pour acquérir le verrou. -Quel est le coût de calcul de cette opération dans ces cas? -Quelle sera la performance d'un verrou dans chacun de ces cas? - -Dans ce qui suit, nous allons considérer plusieurs technique et voir -comment elles se comparent entre-elles pour chacun des critères. - -### Les interruptions - -Une des premières façon de protéger une section critique a été -de désactiver les **interruptions**[^1]. Ainsi, en empêchant -notre programme de s'interrompre pendant qu'il entre dans une section critique, nous -nous assurions qu'aucun autre fil ne peut interrompre le code se trouvant dans une section critique. -Puis lorsque la section critique est terminée, le processeur peut à nouveau interrompre -le thread s'il le souhaite. - -Une façon de se représenter ce comportement en pseudo-c, serait d'avoir les fonctions -`lock()`{.language-c} et `unlock()`{.language-c}. - -```language-c -void lock() { - disable_interrupts(); -} - -void unlock() { - enable_interrupts(); -} -``` - -Cette façon de procéder a l'avantage d'être d'une extrême simplicité. -Il y a en revanche plusieurs problèmes fondamentaux également. - ---- - -Question # - -Lesquels voyez-vous? Réfléchissez fort! Plus fort! C'est toujours pas assez fort! - -Voilà c'est mieux. - ---- - -Le premier problème est que la désactivation/réactivation des interruptions est une action *privilégiée* qu'on ne peut pas laisser -tout le monde effectuer et certainement pas des threads contrôlés par un utilisateur: on ne peut avoir aucune -confiance que ce genre d'opération privilégiée sera utilisée de façon raisonnable. Imaginons -que notre confiance soit mal placée en un programme appelant `lock()`{.language-c} au début de son -code et rendant le processeur complètement inaccessible jusqu'à ce qu'il se termine? Imaginons -qu'en plus il ne se termine jamais. La seule solution serait donc de relancer l'ordinateur. Comme -on le voit ici, il faut un degré de confiance dans les applications qui est beaucoup trop élevé -si on utilise les interruptions comme outil de synchronisation. - -Un deuxième problème est que ce système ne peut pas fonctionner sur des systèmes -comprenant plusieurs processeurs. Si les threads tournent sur des processeurs différents -désactiver les interruptions, n'empêchera pas les threads se trouvant sur des CPUs -différents d'entrer dans des sections critiques. Même en supposant qu'on a assez -confiance dans nos applications, cette solution ne marchera simplement pas -sur des systèmes multi-processeurs. - -Troisièmement, désactiver les interruptions peut entraîner la "perte" de certaines -interruptions primordiales (émises par d'autres processus). Cela peut arriver lorsqu'il faut réveiller -un thread après la fin d'une opération de lecture sur le disque, ou l'arrivée d'un paquet réseau. - -Finalement, cette approche est inefficace. C'est probablement la raison la moins importante, -mais sur le matériel actuel, effectuer ce genre d'opérations est très lent. - -La désactivation des interruptions est par conséquent plus utilisées dans -les ordinateurs, mais est encore très présente dans les systèmes embarqués. -Néanmoins, il y a quelques cas extrêmes où le système d'exploitation -lui-même utilise ce mécanisme pour garantir l'atomicité de certains accès -à des structures internes. Le système opérationnel se faisant confiance à lui-même, -le problème de devoir faire confiance à un processus disparaît. - -### Un verrou raté - -Essayons pour jouer de construire un verrou uniquement grâce à des commandes -logicielles. Pour ce faire, nous allons définir notre verrou comme une simple variable -booléenne, `bool locked`{.language-c}. -Le code ci-dessous implémente le verrou à l'aide d'une variable booléenne. - -```language-c -typedef struct { - bool locked; -} lock_t; - -void init(lock_t *mutex) { - mutex->locked = false; -} - -void lock(lock_t *mutex) { - while (mutex->locked == true) { - // do nothing; - } - mutex->locked = true; -} - -void unlock(lock_t *mutex) { - mutex->locked = false; -} -``` - -Il fonctionne de la façon suivante: - -* La fonction `init()`{.language-c} initialise le verrou à `false`{.language-c}. -* La fonction `lock()`{.language-c} vérifie dans une boucle `while`{.language-c}. Si le verrou -est libre il est mis à `true`{.language-c}. -* La fonction `unlock()`{.language-c} met simplement la valeur du verrou à `false`{.language-c}. - -Le fonctionnement de ce verrou est très simple. Le premier thread appelant `lock()` -changera `locked` à `true` et pourra entrer dans sa section critique. N'importe quel autre thread -essayant de devenir propriétaire du verrou entrera dans la boucle `while` et sera -en **attente active** (**spin-wait** en anglais) jusqu'à ce que le verrou soit -libéré par le premier thread et qu'il assigne `false` à la variable `locked`. -A ce moment là , le verrou peut être acquis par un autre thread et ainsi entrer dans la section -critique. - -"Pour tout problème complexe, il existe une solution simple et élégante... et fausse"[^2]. Ici nous sommes dans ce cas. Bien que simple et élégante cette solution est également fausse. - ---- - -Question # - -Pourquoi cette solution est-elle fausse? - -Réfléchissez avant de lire la suite. Faites même un dessin si cela peut vous aider. - ---- - -On peut assez voir sur le listing ci-dessous qu'il n'y a pas d'exclusion mutuelle. - -| Thread 1 | Thread 2 | -| ----------------------|---------------| -| `lock()`{.language-c} | | -| `while (locked == true)`{.language-c} | | -| **interruption** on passe au thread 2 | | -| | `lock()`{.language-c} | -| | `while (locked == true)`{.language-c} | -| | `locked = true`{.language-c} | -| | **interruption** on passe au thread 1 | | -| `locked = true // A-R-G-L!`{.language-c} | | - -Mais que se passe-t-il donc? Le thread 1 tente d'acquérir le verrou. Alors qu'il teste -la valeur de `locked`{.language-c} et sort du `while`{.language-c}. -A ce moment précis, le thread un est préempté, un changement de contexte a lieu et l'exécution est passée au thread 2. -Celui-ci appelle également la fonction `lock()`{.language-c}. La valeur de `locked`{.language-c} -étant toujours `false`{.language-c} il sort de la boucle `while`{.language-c} et assigne -la valeur `true`{.language-c} à la variable `locked`{.language-c}. A ce point -le verrou ne devrait plus pouvoir être acquis par un autre thread. -Hors si une seconde interruption se produit et la main est repassée au thread un, -il continue exactement où il s'est arrêté, c'est-à -dire après -être sorti de la boucle `while`{.language-c}. Il va donc également assigner la valeur -`true`{.language-c} à `locked`{.language-c} et continuer son exécution. -On voit donc ici que deux threads distincts peuvent acquérir le verrou et entrer dans leurs -sections critiques respectives. - -Ce verrou **ne fonctionne pas**. - -Par ailleurs, mais cela est secondaire étant donné que ce verrou n'en est pas un, -la technique consistant à avoir une attente active avec le `while`{.language-c} -gaspille beaucoup de ressources sans raison. Cela est même absolument terrible dans -le cas d'un système avec un seul CPU. - ---- - -Question # - -A votre avis pourquoi? - -Je veux voir vos méninges se tordre! - ---- - -### Un verrou qui marche: attente active et **test-and-set** - -Comme il est en pratique impossible de se baser sur le mécanisme des interruptions -pour construire des mécanismes d'exclusion mutuelle, les ingénieurs systèmes -ont dû créer du matériel qui supporte des mécanismes de verrou. Le plus simple de tous -à comprendre est l'instruction **test-and-set** (ou **échange atomique**). -Cette instruction est **atomique**, c'est-à -dire qu'il est garanti qu'elle ne sera **jamais** -interrompue. Cette garantie est apportée par le matériel directement. Une telle instruction -aurait une syntaxe en C qui serait la suivante - -```language-c -bool test_and_set(bool *old_ptr, bool new) { - bool old = *old_ptr; // on stocke la valeur pointée par d'old_ptr - *old_ptr = new; // on assigne `new` à `*old_ptr` - return old; // on retourne l'ancienne valeur d'`*old_ptr` -} -``` - -Souvenez-vous que cette instruction n'est jamais implémentée comme cela dans un code. -Cette commande si vous l'implémentez comme cela ne sera pas atomique -(e système d'exploitation fera tout son possible pour ruiner vos plans), il vous faut le -support du matériel pour y arriver. - ---- - -Remarque # - -Dans ces pseudo-codes, on utilise des booléens pour des questions de clarté. -En pratique, ce sont des entiers qui sont utilisés (voire des bits uniquement). - ---- - -Avec l'instruction `test_and_set()`{.language-c}, on peut construire un verrou avec -attente active de la façon suivante - -```language-c -typedef struct { - bool locked; -} lock_t; - -void init(lock_t *mutex) { - mutex->locked = false; -} - -void lock(lock_t *mutex) { - while (test_and_set(&mutex->locked, true) == true) { - // do nothing; - } -} - -void unlock(lock_t *mutex) { - mutex->locked = false; -} -``` - -Examinons ce qui se passe à présent lorsqu'un thread appelle -`lock()`{.language-c} et que le verrou est libre (`locked == false`{.language-c}). Quand ce thread appelle la fonction -`test_and_set()`{.language-c}, il recevra en retour l'ancienne -valeur de `locked`{.language-c}, donc `false`{.language-c}, et -sortira de la boucle `while`{.language-c} immédiatement. -De plus, le thread assignera également **atomiquement** -la valeur `true`{.language-c} à `locked`{.language-c}. -Le verrou sera donc acquis et aucun autre thread ne pourra y accéder -jusqu'à ce que ce même thread appelle la fonction `unlock()`{.language-c}. - -L'autre possibilité est quand le verrou est déjà possédé par un thread. -Un autre thread appelant la fonction `lock()`{.language-c}. -Il appellera la fonction `test_and_set(locked, true)`{.language-c}. -Cette fonction retournera la valeur stockée dans `locked`{.language-c} -qui se trouve être `true`{.language-c} et lui assignera `true`{.language-c} à nouveau. Le thread entrera donc -dans la boucle `while`{.language-c} et répétera l'opération -jusqu'à ce que le verrou soit libéré. - ---- - -Question # - -Quelle est la grande différence entre cette version du verrou, -et notre version avec la simple variable booléenne? - ---- - -La différence est que dans la version `test_and_set()`{.language-c} -le test et l'assignation sont **une seule opération atomique** -qui ne peut pas être interrompue (c'est une garantie du matériel). -De cette façon nous garantissons qu'un seul thread peut acquérir le verrou -à la fois. - -Ce verrou est dit à **attente active** (ou **spin-lock**). En résumé il gaspille des cycles CPU jusqu'à ce que le verrou soit libéré. Ce système ne fait pas de -sens sur les systèmes CPU s'il n'y a pas un ordonnanceur préemptif (un ordonnanceur qui va préempter un processus après un certain temps quoi qu'il arrive). En effet, si le processus se retrouve dans la boucle `while`{.language-c} en attendant que le verrou soit libéré, il n'en -sortira jamais. Il faut que ce thread soit préempté et que le thread -possédant le verrou sorte de sa section critique (et ainsi libère le verrou) pour que le programme puisse continuer à s'exécuter, sinon... - -### Évaluation de l'efficacité des spin-lock - -Maintenant que nous avons construit un verrou qui verrouille et garantit l'exclusion mutuelle (c'était notre première règle -pour avoir un "bon" verrou) nous pouvons nous intéresser -aux autre critères que nous avons énumérés précédemment. - -Le deuxième critère est l'équité. - ---- - -Question # - -Est-ce que le spin-lock est équitable? Les threads ont-ils tous une chance d'acquérir le verrou? - ---- - -La réponse à cette question est **non** de ce que nous avons brièvement -discuté précédemment. Il n'y a aucune garantie qu'un thread particulier puisse acquérir un spin-lock à un moment ou un autre. Il n'est pas du tout -garanti non plus que les threads ne subissent pas la famine. Et comme -tout ce qui n'est pas garanti il faut penser que cela va se passer, et -en général cela sera au pire moment possible! - -Le troisième critère est la performance. - ---- - -Question # - -Quelle est la performance d'un spin-lock? Que se passe-t-il -pour plusieurs fils d'exécution sur un CPU unique? Et qu'en est-il pour plusieurs CPUs? - ---- - -Le problème de la performance des spin-locks se manifeste surtout lorsqu'on se trouve sur un système avec un seul CPU (ou lorsqu'il y a plus de threads que de CPUs). Dans ce cas, -si le thread se trouve préempté alors qu'il est dans la section -critique, les autres threads devront attendre que le -thread ayant acquis le verrou soit à nouveau exécuté -pour enfin pouvoir accéder au verrou. - -Dans le cas de systèmes multi-CPUs où le nombre de thread -est plus petit ou égal au nombre de CPUs, le problème se pose moins. Les autres processeurs se contenteront de tourner dans -leur boucle `while`{.language-c} tant que le verrou -ne sera pas libéré. Mais cela n'a pas un coût notable, -on utilise juste des cycles CPUs à ne rien faire. - -### L'atomicité dans la vraie vie: `compare-and-exchange` - -La primitive hardware utilisée dans les systèmes `x86` -est le `compare-and-exchange`. Le pseudo-code C de cette -instruction ressemblerait à - -```language-c -bool compare_and_exchange(bool *ptr, bool expected, bool new) { - bool actual = *ptr; - if (actual == expected) { - *ptr = new; - } - return actual; -} -``` - -Dans cette instruction, on teste d'abord si la valeur -contenue à l'adresse `ptr`{.language-c} est égale -à une valeur attendue, `expected`. Si cela est le cas, -on met à jour cette valeur à une nouvelle valeur `new`. - ---- - -Exercice # - -Comment construirait-on un verrou avec attente active avec cette instruction? - ---- - -<!-- ```language-c -void lock(lock_t *lock) { - while (compare_and_exchange(&lock->flag, false, true) == true){ - // do nothing - } -} -``` --> - -Mais pourquoi introduire une seconde instruction alors que -le `test_and_set()`{.language-c} faisait l'affaire? -En fait, le `compare_and_exchange()`{.language-c} est une instruction est un peu plus puissante que le `test_and_set()` -mais pas pour construire un verrou avec attente active. -Si par miracle nous avons le temps, nous en dirons plus -si nous parlons de synchronisation sans verrou. - -### Un verrou équitable - -Pour ce verrou, nous avon besoin d'un autre type d'instruction atomique. Une d'un type permettant d'incrémenter une valeur, tout en retournant l'ancienne. -Ce type d'instruction s'appelle `fetch-and-add`. -En pseudo-code C, cela ressemblerait à - -```language-c -int fetch_and_add(int *ptr) { - int old = *ptr; - *ptr = old + 1; - return old; -} -``` - -Avec cette instruction, nous pouvons construire le verrou par ticket (ticket lock). Le verrou à ticket est un peu plus complexe -qu'un simple `flag`. Nous allons utiliser un numéro de tour, ainsi qu'un ticket pour créer le verrou. Le code pour -ce type de verrou serait du genre - -```language-c -typedef struct { - int ticket; - int turn; -} lock_t; - -void lock_init(lock_t *lock) { - lock->ticket = 0; - lock->turn = 0; -} - -void lock(lock_t *lock) { - int myturn = fetch_and_add(&lock->ticket); - while (lock->turn != myturn) { - // do nothing - } -} - -void unlock(lock_t *lock) { - lock->turn += 1; -} -``` - -Avant de tenter d'acquérir le verrou, celui-ci -incrémente la valeur du ticket et retourne son ancienne valeur. Si le ticket avait inscrit comme valeur, la -valeur du tour du thread en question, il peut sortir de la boucle `while`. Sinon, il doit attendre. Lorsque le thread -qui a acquis le verrou sort de sa section critique et qu'il déverrouille le verrou, il incrémente la valeur du tour -se trouvant dans le verrou qui est partagé par tous les -threads. Le threads dont c'est le tour en prochain (s'il y en a un) peut acquérir le verrou. - -### L'inversion de priorité - -Une autre raison de ne pas utiliser les verrous à attente active -est que sur certains systèmes ils ne fonctionnent pas... Ce problème -est connu sous le nom de **l'inversion de priorité**. - -Imaginons un système avec deux fils d'exécution, $T_1$ et $T_2$. -De plus $T_2$ a une priorité d'ordonnancement plus élevée -que $T_1$, et donc l'ordonnanceur préemptera toujours $T_1$ -pour exécuter $T_2$ lorsque les deux threads sont exécutables. -$T_1$ ne pourra s'exécuter que lorsque $T_2$ est bloqué (en train d'effectuer -une opération I/O). -Imaginons à présent que $T_2$ est bloqué (en train d'effectuer une opération -I/O par exemple). $T_1$ s'exécute et acquière le verrou et -pénètre dans sa section critique. Si à ce moment là , $T_2$ -repasse en attente d'exécution, $T_1$ sera préempté, car -$T_2$ a une priorité plus élevée. $T_2$ essaiera d'acquérir le verrou, -et sera donc en attente dans la boucle `while`{.language-c}. Mais comme $T_1$ -ne sera jamais ordonnancé (à cause de sa plus faible priorité) -le système sera bloqué et rien ne va se passer. - -En fait, le problème d'inversion de priorité peut arriver même avec -des verrous sans attente active. En effet, imaginons à présent trois threads -$T_1$, $T_2$, et $T_3$ avec $T_3$ ayant une priorité plus haute que $T_1$ et $T_2$, -qui lui a une priorité plus élevée que $T_1$. Si $T_1$ acquière le verrou -et que $T_3$ entre dans l'état d'attente d'exécution, il sera immédiatement ordonnancé -et $T_1$ préempté. Mais comme $T_1$ est propriétaire du verrou, $T_3$ est bloqué. -Si à présent $T_2$ commence à s'exécuter, comme il a une priorité plus élevée -il passera toujours avant $T_1$, et donc il y a une chance pour que $T_1$ -ne soit jamais ordonnancé, bloquant $T_3$ étant donné qu'il attend que $T_1$ -libère le verrou. - -Il existe différentes façons de se prémunir contre les inversions de priorité. -La plus simple étant d'avoir tous les threads avec les mêmes priorités. -Une autre façon serait d'avoir un mécanisme où les threads avec des priorités plus -élevées attendant sur des threads avec une priorité plus basse puisse augmenter -la priorité des threads avec une priorité plus basse. - -## Aller plus loin que le verrou à attente active - -Comme nous l'avons discuté plus haut, les verrous à attente -active marchent et sont très simples. En revanche, ils sont inéquitables et inefficaces. En particulier, si un changement de contexte -intervient lorsqu'un thread est dans une section critique, nous -avons un problème. -Pour pouvoir aller plus loin, il nous faut -le soutient du système d'exploitation. - -### L'approche simple: céder - -Afin d'aller plus loin, comme on vient de le dire, on va avoir besoin de -l'aide du système d'exploitation. Pour ce faire la stratégie -sera très simple dans un premier temps. -Plutôt que de tourner dans le vide, -on va céder (yield) le contrôle du CPU à un autre thread. - -A titre d'illustration, En pseudo-c un verrou ressemblerait à quelque chose de ce genre - -```language-c -void init(lock_t *mutex) { - mutex->locked = false; -} - -void lock(lock_t *mutex) { - while (compare_and_exchange(&mutex->locked, false, true) == true) { - yield(); // on passe le CPU - } -} - -void unlock(lock_t *mutex) { - mutex->locked = false; -} -``` - -où `yield()`{.language-c} est une primitive du système d'exploitation -ayant pour effet de faire passer le fil d'exécution qui l'appelle -de l'état **en cours d'exécution** à l'état **prêt**. Ainsi, -l'ordonnanceur du système d'exploitation peut promouvoir -un autre thread à l'état **en cours d'exécution**. - ---- - -Exemple (favorable) # - -Considérons un processus tournant sur un CPU avec deux threads, $T_1$ et $T_2$. Supposons que $T_1$ a acquis le verrou et se trouve dans sa section critique. Si un changement de contexte intervient à ce moment-là , -et que $T_2$ essaie d'acquérir le verrou, il le trouvera verrouillé -et entrera dans la boucle `while`{.language-c} et sera mis dans l'état -**en attente**, donnant une chance au système d'exploitation -d'ordonnancer $T_1$ à nouveau. Celui-ci pourra terminer sa section critique. - ---- - -En fait, cette méthode de céder le CPU lorsque le verrou est déjà acquis par un autre thread, fait reposer toute la responsabilité -sur le système d'exploitation. On vient de voir que c'est une approche raisonnable pour un cas avec peu de threads. Lorsque leur nombre devient trop nombreux cela est beaucoup moins le cas. - ---- - -Exemple (moins favorable) # - -Imaginons à présent cent threads donnant un combat à mort pour l'acquisition du verrou, $T_{1-100}$. Supposons que $T_{48}$ -a acquis le verrou et est entré dans sa section critique. C'est ce moment -précis que choisit le système d'exploitation (détestant $T_{48}$) pour le préempter. Un autre thread sera ordonnancé et trouvera le verrou -acquis, il appellera donc `yield()`{.language-c} et sera mis **en attente**. Si nous supposons que l'ordonnanceur a une stratégie qui consiste à passer la main à chaque thread à tour de rôle, -nous aurons 99 changements de contexte avant que $T_{48}$ puisse -reprendre la main et en finir avec sa section critique. -Cela reste beaucoup mieux que le spin-lock qui aurait dû -être préempté 99 fois pour revenir à $T_{48}$ mais ce n'est pas parfait, -car cela nécessite quand même un grand nombre de changements de contexte. - ---- - -Bien que bien plus efficace que les spin-locks, ce type de verrou n'est pas parfait. -Comme nous venons de le voir avec notre exemple à 100 threads, -il est nécessaire d'effectuer des changements de contexte et passer la main pour acquérir le verrou. Cela peut être une opération coûteuse -et n'est pas très satisfaisante. Par ailleurs, il n'est pas exclu qu'un -fil d'exécution se retrouve coincé dans une boucle où il cède -le CPU indéfiniment et va se retrouver dans un état de **famine**: il n'entrera jamais dans sa section critique. Ce problème -est encore plus sérieux que celui de la performance, donc il faut trouver une meilleure solution. - -### L'utilisation des files - -Afin de remédier aux problèmes que nous avons énoncé plus tôt (le gaspillage de ressources, mais plus important encore la famine), -il est nécessaire d'utiliser une autre approche. En fait, -le problème est dû à la trop grande confiance que nous devons -avoir en l'ordonnanceur du système d'exploitation. -Une solution est d'exercer un plus grand contrôle sur quel - thread doit être le prochain à pouvoir acquérir un verrou -quand il a été libéré par son précédent propriétaire. - -Pour ce faire, nous allons utiliser un système de file et le support -du système d'exploitation. Pour simplifier, nous allons utiliser -le support du système de `Solaris` pour réveiller et mettre en sommeil -des threads. L'API est la suivante: - -* `park()`{.language-c} qui met le thread qui appelle cette fonction en attente; -* `unpark(tid)`{.language-c} qui réveille the thread dont l'identifiant est `tid`{.language-c}. - -Avec ces deux appels, il est possible de construire un verrou qui: - -1. Mettra en attente un thread qui tentera d'acquérir un verrou déjà verrouillé. -2. Réveillera ce thread lorsque le verrou devient libre. - -Le code suivant permet de créer un verrou selon ce modèle. - -```language-c -typedef { - bool locked; - bool guard; - queue_t *q; // une file -} lock_t; - -void init(lock_t *mutex) { - mutex->locked = false; - mutex->guard = false; - queue_init(mutex->q); -} - -void lock(lock_t *mutex) { - while (test_and_set(&lock->gard, true) == true) { - // la valeur de guard est acquise en - // tournant dans la boucle while - } - if (mutex->locked == false) { - mutex->locked = true; // le verrou est maintenant acquis - mutex->guard = false; // la garde est maintenant fausse - } else { - queue_add(mutex->q, get_id()); // on met l'id du thread dans la queue - m->guard = false; - park(); // on met le thread en sommeil - } -} - -void unlock(lock_t *mutex) { - while (test_and_set(&lock->gard, true) == true) { - // la valeur de guard est acquise en - // tournant dans la boucle while - } - if (queue_empty(mutex->q)) { - mutex->locked = false; // le verrou est libéré si aucun thread - // est en attente - } else { - unpark(queue_remove(mutex->q)); // si un thread est en attente - // lui garder le thread - } - mutex->guard = false; -} -``` - ---- - -Remarque # - -On peut constater qu'il y a un verrou à attente active caché dans ce verrou. -En effet, lors de la manipulation de la variable `locked`{.language-c} -et de la file, on utilise l'instruction `compare_and_exchange()`{.language-c} combiné à un `while`{.language-c}. Néanmoins, cela est moins problématique que lorsque le verrou protège des sections critiques -étant donné que le nombre -d'instructions est limité (on met à jour une variable -et ajoute un élément dans une file) en comparaison de la protection -d'une section critique qui peut contenir beaucoup de calculs et -ainsi avoir plus de chances d'être préemptée par le système d'exploitation. - ---- - -Ce verrou fonctionne à l'aide de deux variables booléennes et d'une file d'attente. En premier lieu, nous avons la variable `lcoked`{.language-c} -qui détermine si le verrou est acquis ou non. En second lieu, -vient la variable `guard`{.language-c} qui elle protège `locked`{.language-c} -et la file d'attente contre les accès concurrents. -Finalement, la file d'attente enregistre les identifiants -des threads tentant de prendre possession du verrou -mais ayant été recalés. - -Lorsque qu'on appelle la fonction `lock()`{.language-c} et que le verrou -est déjà détenu par un autre thread, on ajoute l'identifiant (`tid`{.language-c}) -du thread se battant pour l'acquisition du verrou dans une file d'attente et en mettant `gard`{.language-c} à `false`{.language-c} -avant d'utiliser la fonction `park()`{.language-c}. - ---- - -Question # - -Que se passe-t-il si on met `gard`{.language-c} à false après `park()`{.language-c} -plutôt? - ---- - -En fait, on voit bien que si `gard`{.language-c} est mise à jour après `park()`{.language-c} on ne déverrouille jamais le verrou utilisé -pour protéger la file et `locked`. De plus, on ne met jamais `locked` à -`true` après le réveil du thread (après `unpark(tid)`{.language-c}). -Ceci est dû au fait que lors du réveil, le thread continue son -exécution après l'appel à `park()`{.language-c}. Comme à ce moment-là -il n'as pas acquis `guard`{.language-c}, il serait -très dangereux de lui faire modifier `locked`{.language-c}. -Enfin, ce code contient un accès concurrent potentiel. -Si par malheur le système d'exploitation, préemptait -le thread entre le moment où on libère `gard`{.language-c} et l'appel -à `park()`{.language-c}. A ce moment-là , le thread pense que le verrou est -acquis par un autre thread. Si à ce moment-là un autre thread prend la main et parvient à libéré le verrou, lorsque notre premier thread reprendrait la main, il pourrait se retrouver en attente infinie -de libération d'un verrou déjà libéré... - -Pour ce prémunir contre ce problème, un autre appel a été créé dans Solaris: `setpark()`{.language-c}. En appelant cette fonction, un thread indique au système qu'on est sur le point d'appeler `park()`{.language-c}. -Ainsi, si `unpark()`{.language-c} est appelé avant que `park()`{.language-c} soit effectivement appelé, `park()`{.language-c} retourne immédiatement plutôt que de se mettre en sommeil. De cette façon -on évite le sommeil à durée indéterminée. - -### Et sous Linux? - -Comme on l'a vu avec les verrous appels systèmes, il n'existe pas de -façon unique de définir les appels vers le noyaux -pour fabriquer un verrou. Dans les systèmes linux, on a à disposition -le **futex** qui est similaire aux fonctionnalités de Solaris, mais - où chaque futex a un espace mémoire spécifique, ainsi qu'une -file d'attente qui est directement implémentée dans le noyau. -Ci-dessous, vous trouverez l'imprémentation des fonctions -`lock()`{.language-c} et `unlock()`{.language-c} pour -le `mutex` (qui n'est rien d'autre qu'un entier) dans la librairie `libc` (cela se trouve dans `lowlevellock.h`). - -```language-c -void mutex_lock (int *mutex) { - int v; - /* Bit 31 was clear, we got the mutex (the fastpath) */ - if (atomic_bit_test_set (mutex, 31) == 0) - return; - atomic_increment (mutex); - while (1) { - if (atomic_bit_test_set (mutex, 31) == 0) { - atomic_decrement (mutex); - return; - } - /* We have to waitFirst make sure the futex value - we are monitoring is truly negative (locked). */ - v = *mutex; - if (v >= 0) - continue; - futex_wait (mutex, v); - } -} -void mutex_unlock (int *mutex) { - /* Adding 0x80000000 to counter results in 0 if and - only if there are not other interested threads */ - if (atomic_add_zero (mutex, 0x80000000)) - return; - /* There are other threads waiting for this mutex, - wake one of them up. */ - futex_wake (mutex); -} -``` - -Regardons ce qui se passe ici. En premier lieu, -nous voyons que notre verrou n'est rien d'autre qu'un entier. -Dans le bit de poids fort, on stocke l'information sur le verrou -(est-il acquis ou pas) et dans le reste des bits, on stocke -le nombre de threads qui attendent. Donc si `mutex` -est négatif le verrou est déjà détenu par un thread (si ce bit est mis -à 1, l'entier est négatif). - -Pour l'acquisition : - * Si le verrou n'est pas acquis, on incrémente atomiquement -l'entier, puis ont attend activement pour voir si le verrou est toujours (vraiment) acquis. On vérifie par conséquent si la valeur de l'entier est bien négative. Puis, on met le thread en attente via `futex_wait(mutex, v)`{.language-c}. Cet appel système met le thread le thread appelant en sommeil, si la valeur de `mutex` est la même que celle de `v`. Sinon -il retourne immédiatement (pour se prémunir de l'attente indéterminée -qu'on a vue plus haut). - -Pour la libération: - * Si le verrou est à zéro (et que la file est vide), -on ajoute `0x80000000`. On a donc que la valeur du verrou est: -`10000000000000000000000000000000`. Sinon, on reveille -le thread suivant avec `futex_wait(mutex)`, où `mutex` est l'adresse du -thread à réveiller. - -Il faut noter que cette méthode est très efficace quand il n'y a pas de -contention pour acquérir le verrou. En effet, lorsqu'un seul thread combat -avec lui-même pour acquérir le verrou, il ne fera que deux opération très simples: un `test_and_set` atomique sur un seul bit pour l'acquisition, puis une addition atomique pour la libération. - - - -<!-- DIRE UN MOT SUR LES FUTEX: COMPARE AND EXCHANGE IS ATOMIC --> - - -[^1]: Sur un système mono-processeur, une interruption est une instruction -matérielle permettant de signaler au processeur qu'il doit sauver l'état dans lequel il se trouve -et s'interrompre pour gérer un événement de plus haute priorité. -[^2]: Citation attribuée à H. L. Mencken. -[^3]: R. H. Arpaci-Dusseau et A. C. Arpaci-Dusseau, *Operating Systems: Three Easy Pieces*, Arpaci-Dusseau Books, ed. 0.91, (2015). \ No newline at end of file diff --git a/hakyll-bootstrap/hakyll-bootstrap.cabal b/hakyll-bootstrap/hakyll-bootstrap.cabal index 47a2a24..0389b75 100644 --- a/hakyll-bootstrap/hakyll-bootstrap.cabal +++ b/hakyll-bootstrap/hakyll-bootstrap.cabal @@ -18,3 +18,6 @@ executable blog , pandoc-crossref , hakyll , hakyll-images + , process + , text + , filepath diff --git a/hakyll-bootstrap/stack.yaml b/hakyll-bootstrap/stack.yaml index 7a0e8ad..2a9b203 100644 --- a/hakyll-bootstrap/stack.yaml +++ b/hakyll-bootstrap/stack.yaml @@ -44,6 +44,9 @@ extra-deps: - data-accessor-template-0.2.1.16 - roman-numerals-0.5.1.5 - hakyll-images-0.4.4 + - process-1.6.8.0 + - text-1.2.4.0 + - filepath-1.4.2.1 # Override default flag values for local packages and extra-deps # flags: {} diff --git a/index.md b/index.md index 62e9a38..c6c2b74 100644 --- a/index.md +++ b/index.md @@ -93,7 +93,7 @@ La chaîne *mixer.com* de mes cours se trouve sur <https://www.mixer.com/omhepia ## Graphes, [HTML](prog_seq_c_tp/shortest_path/graphes.html), [PDF](prog_seq_c_tp/shortest_path/graphes.pdf), [xml_parser.h](prog_seq_c_tp/shortest_path/xml_parser.h), [xml_parser.c](prog_seq_c_tp/shortest_path/xml_parser.c), [main.c](prog_seq_c_tp/shortest_path/main.c), [villes.xml](prog_seq_c_tp/shortest_path/villes.xml), [suisse.txt](prog_seq_c_tp/shortest_path/suisse.txt) -## Floyd-Dijkstra, [HTML](prog_seq_c_tp/shortest_path/df.html), [PDF](prog_seq_c_tp/shortest_path/df.pdf), [squelette.c](prog_seq_c_tp/shortest_path/squelette/main.c), [Makefile](prog_seq_c_tp/shortest_path/squelette/Makefile) +## Floyd-Dijkstra, [HTML](prog_seq_c_tp/shortest_path/df.html), [PDF](prog_seq_c_tp/shortest_path/df.pdf), [squelette.c](prog_seq_c_tp/shortest_path/squelette/c/main.c), [Makefile](prog_seq_c_tp/shortest_path/squelette/c/Makefile) ::: -- GitLab