---
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éfini 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 nombre. La solution vient
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, d'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 2
        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 conso(0), prod(MAX)

// Pour le consommateur
P(conso);
elemet = get();
V(prod);

// Pour le producteur
P(prod);
put(element);
V(conso);
```

Le fonctionnement est ici assez simple :

 * **Pour le consomateur**: 
     1. il prend un jeton sur le sémaphore `conso` s'il est disponible (ou
          attend 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 `conso`. 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 consomateur à 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 conso(0), prod(MAX), mutex_c(1), mutex_p(1);

// Pour le consommateur
P(conso)
P(mutex_v);
element = get();
V(mutex_v);
V(prod)

// Pour le producteur
P(prod);
P(mutex_c);
put(element);
V(mutex_p);
V(conso)
```

### 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 lecteurs, 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 pourraient même ne jamais rendre la main aux
rédacteurs, nous devons mettre en place une **salle d'attente** *(ligne 8)*.

Voici le code pour les rédacteurs:

```c {linenos=table}
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 implementé 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érents 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 ...)`. Si le processus se reveille, il faut s'arrurer que les
autres n'on pas déjà pris toutes les places disponibles.

Voici les variables nécessaires aux producteurs et au consommateurs.

```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 moniteurs 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 est 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 breveté en
2013).

Ils utilisent des opérations 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
