569 lines
15 KiB
Markdown
569 lines
15 KiB
Markdown
---
|
||
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
|