From 9c587214519383a2f7d18a95c30026ba74ab4c88 Mon Sep 17 00:00:00 2001 From: Yorick Barbanneau Date: Wed, 10 Nov 2021 22:25:00 +0100 Subject: [PATCH] Add synchronisation section --- .../3-synchronisation/index.md | 564 ++++++++++++++++++ 1 file changed, 564 insertions(+) create mode 100644 content/systemes_exploitation/3-synchronisation/index.md diff --git a/content/systemes_exploitation/3-synchronisation/index.md b/content/systemes_exploitation/3-synchronisation/index.md new file mode 100644 index 0000000..e999f31 --- /dev/null +++ b/content/systemes_exploitation/3-synchronisation/index.md @@ -0,0 +1,564 @@ +--- +title: "Systèmes d'exploitation : Les outils de synchronisation" +date: 2021-09-24 +tags: ["système", "sémaphore", "synchronisation", "thread"] +categories: ["Systèmes d'exploitation", "Cours"] +--- + +Dans ce chapitre, nous allons étudier différents problèmes (et solutions) +relatives à la synchronisation : + + * l'exclusion mutuelle des sections critiques + * le problème des lecteurs - écrivains + * le problème des producteurs consommateurs + * le problème des points de rendez-vous + +## L'exclusion mutuelle + +Les processeurs savent juste faire des opérations de lecture / écriture de façon +atomique. Mais dans le cadre de l'incrémentation d'une variable comme nous +l'avions vu dans le chapitre traitant des processus légers +[en lpro]({{< ref"../../progsys/3-processus/index.md">}} "Les thread") ce n'est +pas suffisant. Il nous faut donc utiliser **l'exclusion mutuelle**. + +L'algorithme de [Peterson][l_peterson] datant de 1981 utilise l'attente active +pour arriver à ses fins. Un problème se pose alors: le temps processeur est +gaspillé! De plus, cet algorithme est valable pour deux threads seulement. + +```c +//Gary L. Peterson, 1981. Works with two processes : #0 and #1 +bool flag [2] = { FALSE, FALSE }; +unsigned turn = 0; +void enter_sc () +{ + flag[me] = TRUE; + turn = me; + wait (flag[1 – me] == FALSE or turn != me); +} +void exit_sc () +{ + flag[me] = FALSE; +} +``` + +Nous avons donc besoin d'une solution non seulement valable pour un nombre +indéfinis de fils d'exécutions, mais aussi plus efficace. + +## Une solution matérielle + +Vu du processeur, une opération atomique ne peut être interompue par une autre. +Les processeurs modernes en comportent un certain nombres. La solution viens +donc des fabricant de processeurs. + +Pour forcer l'exclusion mutuelle, nous en avons besoin d'une seule: +`Test_and_Set`. C'est donc une instruction matérielle, voici une pseudo +implémentation en C: + +```c +int test_and_set (int *verrou){ + // -- Début de la partie atomique -- + // Ceci est du pseudocode, montré ici juste pour illustrer le principe. + // S'il était utilisé normalement dans un compilateur, + // les garanties nécessaires d'atomicité, non-usage de cache, + // non-réorganisation par l'optimiseur etc. + // ne seraient pas fournies. + int old = *verrou; + + // positionne le verrou à 1 pour dire qu'on veut occuper la SC (nb: ne + // change pas sa valeur si *verrou vaut déjà 1 car un autre processus + // est en SC) + *verrou = 1; + // -- Fin de la partie atomique -- + + // renvoie l'ancienne valeur (c'est-à-dire 1 (vrai) si et seulement si il + // y avait déjà un processus en SC à l'entrée dans test_and_set) + return old; +} +``` +Ce code source est issu de la [page Wikipedia][l_testandset] sur Test and Set, +voyons maintenant cpmment l'utiliser: + +```c +int lock = 0; +void enter_sc () +{ + while (test_and_set(&lock) == 1) + while (lock) + /* wait */ ; +} +void exit_sc () +{ + lock = 0; +} +``` + +Le problème d'accès à la section critique est réglé. Nous avons tout de même +toujours un problème d'attente active... Car le noyau ne peut savoir qui a poser +le verrou. Les threads attendant la libération de la section critique **seront +toujours "promus"** sur le processeurs et consomerons du temps. Il faut donc +trouver une solutions pour **endormir les threads attendant d'entrée en section +critique**. + +La solution est donc un appel système permettant d'endormir (et réveiller) les +threads en attente. Car seul le noyau peut endormir les processus. + +## Les sémaphores + +On en a déjà parlé [en lpro]({{< ref"../../progsys/8_IPC">}} "Les IPC"). Ce sont +des outils de haut-niveau, implémentés dans le noyau. Il se compose d'une +structures contenant un entier positif et une liste de processus en attente et +de deux méthodes `P()` et `V()`. Il ont été inventés en 1962 par E. Dijsktra. + +Les méthodes permettent : + * `P()`: attendre un jeton, le prendre lorqu'il devient disponible et continuer + son exécution. + * `V()`: remettre un jeton + +Lorqu'un thread appelle `V()`, le sémaphore va reveiller les threads référencés +dans la liste d'attente. + +Comme seul le noyau peut arrêter et reveiller des processus, les sémaphores sont +donc implémentés sur le noyau. + +### Implementer un rendez-vous entre plusieurs processus. + +Il faut faire en sorte que plusieurs processus s'attendent avant de continuer +leurs exécutions. + +Voici le code implementant notre fonction barrière. Celle ci permet à plusieurs +processus de se donner un rendez-vous. On utilise des **sémaphore** pour +implementer une sorte de mutex. + +```c {linenos=table} +// point 1 +Semaphore wait[2](0), mutex[2](1); +int count[2] = { 0, 0 }; +void barrier (int i) +{ + // point 2 + P(mutex[i]); + count[i]++; + + if (count[i] < N) { + V(mutex[i]); + P(wait[i]); + } else { + count[i] = 0; + V(mutex[i]); + + // point + for (int k=0; k < N-1; k++) + V(wait[i]); + } +} +``` + +Explication de code : + * **point 1**: Utiliser des tableaux de 2 éléments permet de réutiliser notre + fonction `barrier()` pour fixer plusieurs rendez-vous. Il suffit alors + d'apeller à tous de rôle `barrier(0);` et `barrier(1);`. Sans ça un + processus un peu trop rapide pourrait prendre le sémaphore de la seconde + barrière et bloquer alors pa première. + * **point 2**: l'incrementation de notre compteur doit se faire dans un + *"mutex"* afin d'éviter que plusieurs processus y accèdent en même temps. + On relache notre sémaphore *"mutex"* ligne 11. + +Dans la vraie vie, on n'utilise pas de barrière, on utilisera plutôt `signal()` +et `wait()`. Mais nous verrons ça plus tard. + +### Le problème des producteurs / consommateurs avec des sémaphore + +Le principe ici est d'implémenter une une structure de type FIFO partagée entre +plusiers processus. Cette structure a une capacité de `MAX` élements. Nous +allons utiliser deux sémaphores: un pour le producteur et un pour le +consommateurs. + +Un producteur appelle la fonction `put(element)` et un consommateurs +`element get()`. + +```c +#define MAX 8 +semaphore cons(0), prod(MAX) + +// Pour le consommateur +P(cons); +elemet = get(); +V(prod); + +// Pour le producteur +P(prod); +put(element); +V(cons); +``` + +Le fonctionnement est ici assez simple : + + * **Pour le consomateur**: + 1. il prend un jeton sur le sémaphore `cons` s'il est disponible (ou attends + qu'il le soit) + 2. consomme l'élement + 3. on relache celui sur `prod`. Cette dernière action permet de relancer + la production si la file était pleine (`prod` en attente). + * **pour le producteur**: + 1. on décrémente la sur la production (`prod`). Cette + action permet d'arrêter la production s'il n'y a plus de jeton + disponible. + 2. On produit ensuite l'élément + 3. puis on relache le sémaphore `cons`. On réveille ansi notre consommateur + s'il dormait en attendant la disponibilité d'un élement + +#### Multiple producteurs et consomateur + +Dans la vraie vie, il y souvent plusieurs consomateurs / producteurs. Le +problème devient alors plus complexe... + +Le code ressemble à celui ci-dessus, saut qu'il faut faire attention à ce qu'il +y ai un seul consmateir à la fois sur `get()` et un seul producteur sur +`put(element)`. nous allons donc rajouter deux *"mutex"* : + +```c {linenos=table,hl_lines=[6,8,13,15]} +#define MAX 8 +semaphore cons(0), prod(MAX), mutex_c(1), mutex_p(1); + +// Pour le consomateir +P(cons) +P(mutex_v); +element = get(); +V(mutex_v); +V(prod) + +// Pour le producteur +P(prod); +P(mutex_c); +put(element); +V(mutex_p); +V(cons) +``` + +### Problème des lecteurs rédacteurs + +Ce problème ressemble à celui des producteurs / consommateurs. + + * *lecteur*: processus qui lit des données sans les modifier. Il n'y a pas de + problème d'exclusion mutuelle : plusieurs lecteur peuvent lire la même + donnée. + * *rédacteur*: processus qui modifie des données. Exclusion avec non seulement + les autres rédacteurs mais aussi les lecteurs. + +Voici le code pour les lecteurs: + +```c {linenos=table,hl_lines=[8,12]}} +Semaphore write_token(1); +int n_reader = 0; +Semaphore mutex_r(1); +//Waiting Room help us to make our implememtatoion Fair +Semaphore wait_room(1); + +// Reader +P(wait_room); +P(mutex_r); +// Pathfinder +// Equivalent to nbr++; if nbr == 1 +if (++nbr == 1) +{ + P(write_token); +} +V(mutex_r); +V(wait_room); + +read(); + +P(mutex_r) +if(--nbr == 0) // last to leave +V(write_token); +V(mutex_r); +``` +Pour les lecteur, il faut envoyer **un éclaireur** afin de savoir si un lecteur +est actif. Le problème est similaire à celui des producteur consommateurs sauf +qu'il faut savoir si notre lecteur est le premier *(ligne 12)* + +De plus. afin que notre algorithme ne privilegie pas les lecteurs au +détriment des rédacteur; ils peuvent même ne jamais rendre la main aux +rédacteurs. Pour pallier au problème, nous devons mettre en place une **salle +d'attente** *(ligne 8)*. + +```c {linenos=table,hl_lines=[8,12]} +P(wait_room); +P(write_token); +V(wait_room); +write(); +V(write_token); +``` + +Ce code est plutôt clair et ne comporte rien de particulier. + +## Les moniteurs d'Hoare + +Les moniteurs sont des primitives de synchronisation initialement proposées dans +les langages objet. Il est utilisé actuellemt dans des langages tel que ADA ou +Java et implemente au sein de systèmes d'exploitation. + +Le moniteur se positionne sur une classe et les mutexes sur ses methodes et sont +basés sur des variables condition. elle sont forcement privée et inaccessible à +l'exterieur de la classe. + +```java +Monitor class m { +private int i = 1; +private condition c; +public method f() { // cette accolade signifie P(mutex) + code_before(); + wait(c); + code_after(); +} // et celle-ci V(mutex) + +public method g() { + signal(c); +} +``` + +Il sont différent des *sémaphores*: il n'y a pas de jeton à prendre. Leur +implémentation dans les systèmes d'exploitation est différente, elle se fait par +les les types `mutex_t` et `cond_t`: + +```c +mutex_t m; +mutex_lock(mutex_t *m); +mutex_unlock(mutex_t *m); + +cond_t c; +cond_wait(cond_t *c, mutex_t *m); +cond_signal(cond_t *c); +cond_bcast(cond_t *c); +``` + +Et voici un exemple d'utilisation: + +```c +mutex_t m; +cond_t c; + +void c(){ + mutex_lock(&m) + if (...){ + cond_wait(&c, &m); + } + mutex_unlock(&m); +} + +void g(){ + mutex_lock(&m); + ... + cond_signal(&c); + ... + mutex_unlock(&m); +} +``` + +### bonne pratiques + + * Pas de variable condition à l'extérieur d'un moniteur, c'est aussi conseillé + pour les `cont_signal` et `cond_bcast`. + * Les mutex ont un propriétaire. + +### Retour sur le problème du rendez-vous + +L'idée est de bloquer les N-1 processus, le dernier (N) reveillera tous les +autres. + +```c +mutex_t m; +cond_t wait; +int nb = 0; + +void barrier () +{ + // notre variable condition et bien "protégée" par un mutex + mutex_lock(&m); + nb++; + + // on stoppe les n-1 processus + If (nb < N) + cond_wait (&wait, &m); + else { + // le dernier réveille tous les autrespar un Bcast + cond_bcast (&wait); + // et on positionne nb à 0 permettant ainsi de réutiliser cette fonction + // barrière. + nb = 0; + } + mutex_unlock(&m); +} +``` + +En plus d'être plus simple, cette fonction est réutilisable de multiples fois +sans le petit *"hack* des tableaux utilisé pour les sémaphore. + +### Producteur / consomateurs avec un moniteur + +tout comme les barrières, le code est ici bien plus simple. Et bien entendu il +n'y a pas d'attente active... + +Dans les codes ci-dessous, il est préféfable d'utiliser `while (nbe ...)` plutôt +qu'un `if (nbe ...)`. (Mais je ne sais plus pourquoi...) + +Voici les variables nécessaires aux producteurs et au consomate + +```c +#define MAX 8 +mutex_t m; +cond_t cons, prod; +int nbe = 0; +``` + +#### pour le producteur + +```c +mutex_lock (&m); + +/* si notre file est pleine, alors le thread passe en sommeil + et attend le reveil par un cond_signal */ +while (nbe == MAX) { + cond_wait(&prod); +} +put(element); +nbe++; +cond_signal(&cons) +mutex_unlock (&m); +``` + +#### pour les consomateurs + +```c +mutex_lock (&m); + +/* Et inversement si notre file est vide, rien à consommer, on + attend un reveil par un cond_signal dès qu'il y a production */ +while (nbe == 0) { + cond_wait(&cons) +} +element = get(); +nbe--; +cond_signal(&prod); +mutex_unlock (&m); +``` + +### Les lecteurs / rédateurs + +Nous commençons ici par créer une structure et l'affecter à une variable +`my_lock`: + +```c +typedef struct{ + mutex_t m; + cond_t c_read, c_write; + unsigned nb_reader = 0; + unsigned nb_writer = 0; +} rwlock_t; + +rwlock_t mylock; +``` + +#### Du côté des lecteur + +```c +void rwl_readlock(rwlock_t *l) +{ + mutex_lock(&l->m); + + /* tout comme le problème des produteurs / consomateurs, un + while est préférable ici */ + while(l->nbw > 0) { + cond_wait(&l->cr, &l->m); + } + l->nbr++; + mutex_unlock(&l->m); +} + +void rwl_readunlock(rwlock_t *l) +{ + mutex_lock(&l->m); + l->nbr--; + if(l->nbr == 0) { + cond_signal(&l->cw); + } + mutex_unlock(&l->m); +} + +Et voici son l'utilisation : + +```c +rwl_readlock(mylock); +read(); +rwl_readunlock(mylock); +``` + +#### du côté des rédacteurs + +```c +void rwl_writelock(rwlock_t *l) +{ + mutex_lock (l->m); + /* Comme ces deux nombres sont des entiers strictement positifs + on peut utiliser l'adition afin d'attendre en cas de présence + de lecteurs ou de rédacteur. + On évite ainsi une double condition : + while ( l->nb_reader > 0 || l->nb_writer > 0) */ + + while( l->nb_reader + l->nb_writer > 0 ) { + cond_wait (&l->cw, &l->m); + } + l->nbw++; + mutex_unlock (&l->m); +} + +void rwl_writeunlock(rwlock_t *l) +{ + mutex_lock (l->m); + l->nbw--; + cond_signal (&l->cw); + rwl_writeunlock (&cool_lock); + + /* On utilise le bcast pour les lecteur car on peut *tous* + les réveiller (+ieurs lecteurs possibles en même temps */ + cond_bcast(&l->c_read); + /* mais on ne reveille qu'un seul écrivain */ + cond_signal(&l->c_write); + mutex_unlock (&l->m); +} +``` + +Et voici son utilisation: + +```c +rwl_writelock(mylock); +write(); +rwl_writeunlock(mylock); +``` + +## conclusion + +Les sémaphores et moniyeurs sont implémentés au niveau du système d'exploitation +et utilient le matériel sous-jacent (`test_and_set`). Ces appels sont +**coûteux**, nécessitent des **changements de contexte**. Dans les systèmes +modernes, on leur préfèrera un **mix de primitive de synchronisation et de +polling (attente de variable)**. Ainsi un thread attendra une valeur / variable +pendant quelques cycles et se bloquera si elle n'est pas disponible. + +Les moniteurs de Hoare sont en général préférés par les programmeurs cas comme +on l'a vu, ils sont plus simple à implémenter. + +Il eest intéressant aussi de parler des **FUTEX**, apparus en 2003 sur Linux et +plus tard sous Windows 8 (sous l'appelation `wait_on_address` et brevete en +2013). + +Ils utilisent des opétations atomiques sur des variables entières de 32bits *en +espace utilisateur* et deux operation bloquante si nécessaires: + +```c +sys_futex(&var, FUTEX_WAIT); +sys_futex (&var, FUTEX_WAKE); +``` + +[l_peterson]:https://fr.wikipedia.org/wiki/Algorithme_de_Peterson +[l_testandset]:https://fr.wikipedia.org/wiki/Test-and-set