Ce TP accompagne le chapitre Concurrence et parallélisme.
Remarque
On compile les programmes de ce TP avec l'option -pthread :
gcc -Wall -Wextra -g -pthread -o prog prog.c
Si vous avez un problème à l'édition de liens, il faut rajouter
-lpthread. L'option -fsanitize=thread (à la place de
-fsanitize=address) détecte à l'exécution les accès concurrents non
protégés à une même variable (data races).
Attention, sous macOS, les sémaphores anonymes de semaphore.h ne
sont pas implémentés : sem_init renvoie -1 et les programmes de la
seconde partie ne fonctionnent pas. Il faut alors travailler sous Linux
(machine virtuelle, serveur de l'établissement...).
Type universel de pointeurs¶
En C, le type void * joue un rôle particulier :
- toute valeur de type
T *apeut être convertie en unvoid *; - toute valeur de type
void *vpeut être convertie en unT *.
Ces conversions sont implicites en C, il n'y a rien à écrire.
Ainsi, le code suivant est correct :
#include <stdio.h>
void incremente(void *a)
{
int *t = a;
*t = *t + 1;
}
int main(void)
{
int n = 3;
incremente(&n);
printf("n = %d\n", n);
}
Ainsi, il est possible de faire des fonctions génériques en utilisant le type
void * comme une sorte de 'a en OCaml. Mais contrairement à OCaml,
aucune vérification n'est faite : c'est au programmeur de savoir vers quel
type pointe réellement un void *.
Remarque
Comme le type int est, sur les architectures usuelles, plus petit que le
type void * en termes de taille, il est possible de faire des
conversions explicites pour ne pas avoir à définir une variable comme n
ici.
Création de threads¶
Pour créer des threads, on utilise la bibliothèque pthread
dont l'en-tête est pthread.h.
Pour créer un thread :
- on définit une valeur
pthread_t tqui va contenir les données du thread ; - on appelle
pthread_create(&t, NULL, f, arg);oùfest une fonction de prototypevoid *f(void *arg);etargest une valeur de typevoid *.
Par exemple, on définit la fonction incremente qui va prendre un entier en
argument par pointeur et l'incrémenter.
void *incremente(void *arg)
{
int *n = arg;
*n = *n + 1;
return NULL; // pas de retour
}
On peut alors lancer cette fonction dans un thread :
pthread_t t;
int v = 3;
pthread_create(&t, NULL, incremente, &v);
Pour attendre qu'un thread t termine, on appelle pthread_join(t, NULL) :
pthread_join(t, NULL);
printf("%d\n", v); // affiche 4
Remarque
Il est également possible d'avoir une valeur de retour mais c'est hors programme.
Algorithme de Peterson¶
On considère le programme à deux threads suivant :
#include <pthread.h>
#include <stdio.h>
int compteur = 0;
int N = 100000;
void *incremente(void *arg)
{
int *id = arg; // le numéro du thread, inutilisé pour l'instant
(void)id;
for(int i = 0; i < N; i++) {
compteur = compteur + 1;
}
return NULL;
}
int main(void)
{
pthread_t t0, t1;
int i0 = 0;
int i1 = 1;
pthread_create(&t0, NULL, incremente, &i0);
pthread_create(&t1, NULL, incremente, &i1);
pthread_join(t0, NULL);
pthread_join(t1, NULL);
printf("compteur = %d (attendu %d)\n",
compteur, 2 * N);
}
Question 1
Observez le comportement en changeant la valeur de N.
Corrigé
Pour de petites valeurs de N, on obtient souvent 2 * N : le premier
thread a fini avant que le second ne démarre. Pour de grandes valeurs, le
résultat est inférieur à 2 * N et varie d'une exécution à l'autre : des
incrémentations sont perdues quand les deux threads lisent la même
valeur de compteur avant d'écrire (voir Exemple de section
critique).
Avec l'option -O2, on peut même obtenir exactement N : le
compilateur remplace la boucle par une unique instruction
compteur = compteur + N, et les deux threads lisent 0 en même temps.
En C, ces accès concurrents non protégés sont des data races, dont le
comportement n'est pas défini, et -fsanitize=thread les signale.
On va essayer de réaliser des fonctions de prototypes :
void verrouille(int id);
void deverrouille(int id);
où id est le numéro du thread, 0 ou 1 ici, et telles
qu'entre les appels à verrouille(id); et
deverrouille(id); on puisse placer une section critique et
garantir :
- l'exclusion mutuelle : un seul thread peut exécuter la section critique à un moment donné ;
- l'absence d'interblocage : si un thread effectue un appel à
verrouille, alors au moins un thread, peut-être un autre, va rentrer en section critique ensuite ; - l'absence de famine : si un thread effectue un appel à
verrouille, il ne va pas attendre indéfiniment.
Remarque
L'absence de famine implique l'absence d'interblocage (voir Propriétés d'un verrou).
On va alors, dans l'exemple précédent, remplacer incremente par :
void *incremente(void *arg)
{
int *id = arg; // le numéro du thread
for(int i = 0; i < N; i++) {
verrouille(*id);
compteur = compteur + 1;
deverrouille(*id);
}
return NULL;
}
Premier essai¶
On propose le pseudo-code suivant :
flag[0] = false
flag[1] = false
verrouille(i):
flag[i] = true
tant que flag[j] == true où j est le numéro de l'autre thread
attendre
deverrouille(i):
flag[i] = false
Question 2
Implémentez et testez cet algorithme.
Que se passe-t-il ? Est-ce prévisible ?
Corrigé
bool flags[2] = { false, false };
void verrouille(int id)
{
flags[id] = true;
while(flags[1-id]) { // attente active
}
}
void deverrouille(int id)
{
flags[id] = false;
}
Le programme se bloque : les deux threads finissent par lever leur drapeau en même temps, puis chacun attend indéfiniment que l'autre baisse le sien. C'est un interblocage, prévisible : voir Première idée.
Question 3
Montrez que ce verrou satisfait l'exclusion mutuelle.
Corrigé
Voir la preuve du cours dans Première idée : si les deux threads étaient en section critique, chacun aurait écrit son drapeau avant que l'autre n'écrive le sien, ce qui est impossible.
Second essai¶
On propose maintenant le pseudo-code suivant :
victime = 0
verrouille(i):
victime = i
tant que victime == i
attendre
deverrouille(i):
ne fait rien
Question 4
Implémentez et testez cet algorithme.
Que se passe-t-il ? Est-ce prévisible ?
Corrigé
int victime = 0;
void verrouille(int id)
{
victime = id;
while(victime == id) { // attente active
}
}
void deverrouille(int id)
{
}
Un thread n'est débloqué que lorsque l'autre verrouille à son tour. Dès
qu'un thread a fini ses N incrémentations, l'autre reste bloqué
indéfiniment : le programme ne termine pas. C'est prévisible, voir
Deuxième idée.
Question 5
Montrez que ce verrou satisfait l'exclusion mutuelle.
Corrigé
Voir la preuve dans Deuxième
idée : le dernier
thread à avoir écrit dans victime ne peut pas en être sorti.
Algorithme de Peterson¶
On va combiner les deux mauvaises idées précédentes pour en déduire une bonne idée.
On va utiliser flags et victime, mais cette fois, on
attend tant que flags[j] == true et victime == i.
Question 6
Implémentez et testez cet algorithme.
Que se passe-t-il ? Est-ce prévisible ?
Corrigé
bool flags[2] = { false, false };
int victime = 0;
void verrouille(int id)
{
int autre = 1 - id;
flags[id] = true;
victime = id;
while(flags[autre] && victime == id) { // attente active
}
}
void deverrouille(int id)
{
flags[id] = false;
}
Le programme termine, mais le compteur peut être légèrement inférieur à
2 * N (par exemple 199999), alors que l'algorithme est prouvé
correct : les hypothèses du modèle
mémoire ne sont pas satisfaites par
les processeurs modernes, qui peuvent réordonner l'écriture de
flags[id] et la lecture de flags[autre]. Avec -O2, le programme peut
même se bloquer : le compilateur, qui ne sait pas que d'autres threads
modifient ces variables, lit flags[autre] et victime une seule fois
avant la boucle.
Remarque
Il est possible que ce verrou marche presque en pratique ; la raison est liée aux optimisations de la gestion de la mémoire, où les écritures ne sont pas réalisées au bon moment.
Sur un processeur x86, on peut rajouter l'instruction :
asm volatile ("mfence" ::: "memory");
avant le while pour forcer l'écriture avant la lecture. Cette
instruction n'existe pas sur d'autres processeurs (ARM, comme les Mac
récents) ; on peut utiliser à la place la fonction __sync_synchronize();
de gcc et clang, qui place la barrière mémoire adaptée au processeur.
Normalement, ça suffit ici à réparer le verrou sans optimisations
(-O0). Pour un programme correct au sens de la norme, il faut déclarer
les variables partagées comme atomiques avec stdatomic.h
(atomic_bool flags[2]; et atomic_int victime;) : leurs accès sont
alors réalisés dans l'ordre du programme et ne sont plus optimisés.
C'est hors programme.
Question 7
Prouvez que ce verrou satisfait toutes les propriétés voulues.
Corrigé
Voir les preuves de l'exclusion mutuelle et de l'absence de famine dans Algorithme de Peterson ; l'absence d'interblocage découle de l'absence de famine.
Mutex¶
Il y a dans pthread un mécanisme de verrou fiable : les mutex.
On déclare un mutex m avec une variable globale :
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
On peut également l'initialiser avec
pthread_mutex_init(&m, NULL);
On peut alors verrouiller et déverrouiller m avec :
pthread_mutex_lock(&m); // verrouille
pthread_mutex_unlock(&m); // déverrouille
Même si ici cela ne va rien faire, c'est une bonne pratique d'appeler
pthread_mutex_destroy(&m);
quand on a fini d'utiliser le mutex.
Question 8
Reprenez le code d'incrémentation avec un mutex.
Corrigé
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
void *incremente(void *arg)
{
(void)arg;
for(int i = 0; i < N; i++) {
pthread_mutex_lock(&m);
compteur = compteur + 1;
pthread_mutex_unlock(&m);
}
return NULL;
}
On obtient toujours 2 * N, y compris avec -O2, et
-fsanitize=thread ne signale plus rien. On appelle
pthread_mutex_destroy(&m); à la fin de main, après les join.
Incrémentation atomique¶
Une autre manière de réparer ce programme est d'utiliser l'instruction atomique non portable suivante :
__sync_add_and_fetch(&compteur, 1);
Ici, on va réaliser l'incrémentation d'un seul coup sans avoir de possibilité d'accès concurrent (voir Instructions atomiques).
Question 9
Vérifiez que cela fonctionne.
Corrigé
Il suffit de remplacer, dans la boucle de incremente, l'instruction
compteur = compteur + 1; (et les appels de verrouillage) par
__sync_add_and_fetch(&compteur, 1);. On obtient bien 2 * N. C'est
l'instruction fetch and add du processeur ; elle est plus rapide qu'un
mutex mais ne protège qu'une seule opération sur une seule variable.
Sémaphores¶
pthread définit également des sémaphores, dans l'en-tête
semaphore.h. Ce sont des compteurs de ressources
qui permettent :
- d'être initialisés avec un nombre de ressources ;
- d'être acquis en attendant que le compteur soit \(> 0\) et en le décrémentant ;
- d'être rendus en incrémentant le compteur.
Ce mécanisme permet de transmettre des signaux, puisque un thread peut attendre un sémaphore qui est nul et qui va passer à 1 quand un autre thread veut lui envoyer un signal.
Avec pthread, on utilise :
sem_t s; // définit un sémaphore
sem_init(&s, 0, v); // l'initialise avec la valeur
// initiale v
sem_wait(&s); // attend pour décrémenter (acquisition)
sem_post(&s); // incrémente (rendu)
sem_destroy(&s); // quand on a fini de l'utiliser
Rendez-vous¶
Question 10
Réalisez deux threads effectuant chacun l'affichage AB
de deux caractères, mais en faisant en sorte que la trace soit toujours
AABB.
Indication
On pourra utiliser deux sémaphores initialement à 0,
intitulés, c'est un indice, t_est_la[0] et t_est_la[1].
Corrigé
Chaque thread signale qu'il est arrivé, puis attend l'autre (voir aussi Rendez-vous à deux threads).
#include <pthread.h>
#include <semaphore.h>
#include <stdio.h>
sem_t t_est_la[2];
void *f(void *arg)
{
int *id = arg;
putchar('A');
sem_post(&t_est_la[*id]); // je signale que je suis là
sem_wait(&t_est_la[1 - *id]); // j'attends l'autre
putchar('B');
return NULL;
}
int main(void)
{
pthread_t t0, t1;
int i0 = 0;
int i1 = 1;
sem_init(&t_est_la[0], 0, 0);
sem_init(&t_est_la[1], 0, 0);
pthread_create(&t0, NULL, f, &i0);
pthread_create(&t1, NULL, f, &i1);
pthread_join(t0, NULL);
pthread_join(t1, NULL);
putchar('\n');
sem_destroy(&t_est_la[0]);
sem_destroy(&t_est_la[1]);
return 0;
}
Il faut faire le sem_post avant le sem_wait : dans l'autre ordre,
les deux threads s'attendraient mutuellement (interblocage).
Rendez-vous généralisé : la barrière¶
Question 11
Même question que la précédente mais pour \(10\) threads.
Il ne faut pas que le nombre de primitives de synchronisation dépende du nombre de threads.
Indication
Une variable compte, un mutex pour la gérer et
un sémaphore nul pour signaler qu'on peut passer à la suite.
Corrigé
Le dernier thread arrivé ouvre la barrière, puis chaque thread qui passe laisse passer le suivant (voir Rendez-vous à n threads : barrière).
#include <pthread.h>
#include <semaphore.h>
#include <stdio.h>
#include <stdbool.h>
#define NB_THREADS 10
int compte = 0;
pthread_mutex_t verrou_compte = PTHREAD_MUTEX_INITIALIZER;
sem_t barriere; // initialement à 0
void *f(void *arg)
{
(void)arg;
putchar('A');
pthread_mutex_lock(&verrou_compte);
compte = compte + 1;
bool dernier = (compte == NB_THREADS); // lu dans la section critique
pthread_mutex_unlock(&verrou_compte);
if (dernier)
sem_post(&barriere);
// tourniquet : chaque thread qui passe laisse passer le suivant
sem_wait(&barriere);
sem_post(&barriere);
putchar('B');
return NULL;
}
int main(void)
{
pthread_t t[NB_THREADS];
sem_init(&barriere, 0, 0);
for(int i = 0; i < NB_THREADS; i++)
pthread_create(&t[i], NULL, f, NULL);
for(int i = 0; i < NB_THREADS; i++)
pthread_join(t[i], NULL);
putchar('\n');
sem_destroy(&barriere);
return 0;
}
On teste compte == NB_THREADS dans la section critique : lire compte
après l'avoir déverrouillé serait une data race.
Barrière réutilisable¶
Question 12
On souhaite créer un type barriere et des fonctions :
// initialise la barrière pour synchroniser n threads
void init_barriere(barriere *b, int n);
// attend que les n threads aient atteint la barrière
void attends_barriere(barriere *b);
Contrairement au cas précédent, on souhaite que la barrière soit réutilisable.
Il ne faut pas que le nombre de primitives de synchronisation dépende du nombre de threads.
Corrigé
Avec un seul tourniquet, un thread rapide pourrait repasser la barrière avant que les autres ne l'aient franchie. On utilise donc deux tourniquets, l'un fermé quand l'autre est ouvert, comme dans Barrière réutilisable.
struct barriere {
int n; // nombre de threads à synchroniser
int compte; // nombre de threads arrivés
pthread_mutex_t verrou;
sem_t entree; // tourniquet d'entrée, initialement fermé (0)
sem_t sortie; // tourniquet de sortie, initialement ouvert (1)
};
typedef struct barriere barriere;
void init_barriere(barriere *b, int n)
{
b->n = n;
b->compte = 0;
pthread_mutex_init(&b->verrou, NULL);
sem_init(&b->entree, 0, 0);
sem_init(&b->sortie, 0, 1);
}
void detruit_barriere(barriere *b)
{
pthread_mutex_destroy(&b->verrou);
sem_destroy(&b->entree);
sem_destroy(&b->sortie);
}
void attends_barriere(barriere *b)
{
// phase 1 : on attend que les n threads soient arrivés
pthread_mutex_lock(&b->verrou);
b->compte = b->compte + 1;
if (b->compte == b->n) {
sem_wait(&b->sortie); // on ferme la sortie
sem_post(&b->entree); // on ouvre l'entrée
}
pthread_mutex_unlock(&b->verrou);
sem_wait(&b->entree);
sem_post(&b->entree);
// phase 2 : on attend que les n threads soient passés
pthread_mutex_lock(&b->verrou);
b->compte = b->compte - 1;
if (b->compte == 0) {
sem_wait(&b->entree); // on referme l'entrée
sem_post(&b->sortie); // on ouvre la sortie
}
pthread_mutex_unlock(&b->verrou);
sem_wait(&b->sortie);
sem_post(&b->sortie);
}
Avec 10 threads exécutant 5 fois putchar('A'); attends_barriere(&b);
putchar('B'); attends_barriere(&b);, la trace est toujours formée de
10 A, 10 B, 10 A, etc.
Problèmes¶
Dans tous les problèmes suivants, on demande d'implémenter une solution qui se comporte bien.
Producteurs-consommateurs¶
On considère des tâches qui sont gérées dans un tampon de \(n\) cases.
On a :
- \(p\) threads producteurs qui veulent écrire une nouvelle tâche dans le tampon, quitte à attendre s'il est plein ;
- \(c\) threads consommateurs qui veulent récupérer une tâche et libérer la place dans le tampon, quitte à attendre s'il est vide.
Corrigé
On gère le tampon comme une file circulaire, avec un sémaphore comptant
les places libres, un autre comptant les tâches présentes et un mutex
protégeant les indices (voir
Producteurs-consommateurs).
#define N 4 // taille du tampon
int tampon[N];
int debut = 0; // indice de la prochaine tâche à retirer
int fin = 0; // indice de la prochaine case libre
sem_t places; // places libres, initialisé à N
sem_t taches; // tâches présentes, initialisé à 0
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
void depose(int tache) // appelé par les producteurs
{
sem_wait(&places);
pthread_mutex_lock(&m);
tampon[fin] = tache;
fin = (fin + 1) % N;
pthread_mutex_unlock(&m);
sem_post(&taches);
}
int retire(void) // appelé par les consommateurs
{
sem_wait(&taches);
pthread_mutex_lock(&m);
int tache = tampon[debut];
debut = (debut + 1) % N;
pthread_mutex_unlock(&m);
sem_post(&places);
return tache;
}
L'ordre des opérations compte : si un producteur prenait le mutex avant
d'attendre une place libre, il bloquerait les consommateurs qui
pourraient la libérer.
Lecteurs-écrivains¶
On considère maintenant une ressource comme une base de données dont l'accès est une section critique, et deux types de threads :
- autant de lecteurs qu'on le souhaite peuvent être dans la section critique ;
- les écrivains ont un accès exclusif à la section critique (ce qui bloque également les lecteurs).
Corrigé
Un sémaphore salle_vide, initialisé à 1, est pris par chaque écrivain et
par le premier lecteur qui entre ; il est rendu par chaque écrivain
et par le dernier lecteur qui sort. Le nombre de lecteurs présents
est protégé par un mutex.
int nb_lecteurs = 0; // lecteurs en section critique
pthread_mutex_t verrou_lecteurs = PTHREAD_MUTEX_INITIALIZER;
sem_t salle_vide; // initialisé à 1
void debut_lecture(void)
{
pthread_mutex_lock(&verrou_lecteurs);
nb_lecteurs = nb_lecteurs + 1;
if (nb_lecteurs == 1) // le premier lecteur ferme la salle aux écrivains
sem_wait(&salle_vide);
pthread_mutex_unlock(&verrou_lecteurs);
}
void fin_lecture(void)
{
pthread_mutex_lock(&verrou_lecteurs);
nb_lecteurs = nb_lecteurs - 1;
if (nb_lecteurs == 0) // le dernier lecteur la rouvre
sem_post(&salle_vide);
pthread_mutex_unlock(&verrou_lecteurs);
}
void debut_ecriture(void)
{
sem_wait(&salle_vide);
}
void fin_ecriture(void)
{
sem_post(&salle_vide);
}
Cette solution présente un risque de famine pour les écrivains : si des
lecteurs se relaient sans cesse, nb_lecteurs ne revient jamais à 0. On
peut y remédier en ajoutant un sémaphore « tourniquet » que les
écrivains ferment en arrivant et que chaque lecteur doit franchir avant
debut_lecture.
Dîner des philosophes¶
\(n\) philosophes sont autour d'une table avec une baguette entre deux d'entre eux. Ils ont besoin de deux baguettes pour manger.
On cherche une solution garantissant les bonnes propriétés et maximisant le parallélisme, c'est-à-dire le nombre de philosophes mangeant en même temps.
Corrigé
Voir l'étude détaillée dans Le dîner des philosophes : la solution naïve (un sémaphore par baguette) peut provoquer un interblocage, et la solution par états évite l'interblocage tout en permettant à des philosophes non voisins de manger simultanément, mais présente un risque de famine.
Dîner convivial¶
\(n\) personnes sont autour d'une marmite pouvant servir \(m\) rations avant d'être vide. Quand elle est vide, elles vont réveiller le cuisinier pour qu'il remplisse la marmite et elles attendent que la marmite soit remplie pour se resservir.
Le cuisinier ne doit remplir la marmite que lorsqu'elle est vide.
Corrigé
Le nombre de rations est protégé par un mutex. Le convive qui trouve la
marmite vide réveille le cuisinier avec un premier sémaphore, puis
attend, en gardant le verrou, le signal du second sémaphore : les
autres convives attendent ainsi sur le verrou et le cuisinier n'est
réveillé qu'une fois par marmite vide.
#define M 5 // rations par marmite
int rations = 0; // la marmite est vide au départ
pthread_mutex_t verrou_marmite = PTHREAD_MUTEX_INITIALIZER;
sem_t marmite_vide; // signal vers le cuisinier, initialisé à 0
sem_t marmite_pleine; // signal vers le convive, initialisé à 0
void se_servir(void) // appelé par les convives avant de manger
{
pthread_mutex_lock(&verrou_marmite);
if (rations == 0) {
sem_post(&marmite_vide); // on réveille le cuisinier
sem_wait(&marmite_pleine); // et on attend qu'il ait rempli
}
rations = rations - 1;
pthread_mutex_unlock(&verrou_marmite);
}
void *cuisinier(void *arg)
{
(void)arg;
while (true) {
sem_wait(&marmite_vide);
rations = M; // la marmite est vide ici
sem_post(&marmite_pleine);
}
return NULL;
}
Le cuisinier modifie rations sans prendre le verrou, mais sans
concurrence : le seul convive qui pourrait y accéder attend
marmite_pleine et les autres attendent le verrou qu'il détient. Les
sémaphores assurent que l'écriture du cuisinier est visible du convive.