Partie 5 - Threads

Support présentation

Introduction

Un thread (fil d’exécution), ou processus léger, est une unité d’exécution à l’intérieur d’un processus. Contrairement aux processus, les threads d’un même programme partagent la même mémoire (code, données, heap), mais disposent de leur propre stack et de leur propre program counter (compteur ordinal : registre du processeur qui contient l’adresse mémoire de la prochaine instruction à exécuter).

Quand on lance un programme, le processus créé n’a qu’un seul thread, le thread principal, qui exécute main. Avec pthread_create, main démarre un nouveau thread dans le même processus : ce thread exécute une fonction choisie par le programme (travail dans le schéma ci-dessous), pendant que main continue d’exécuter la suite de son propre code. Il y a alors deux exécutions en cours, dans le même programme et la même mémoire.

C’est la différence avec fork (partie Processus) :

  • fork crée un deuxième processus, avec sa propre copie de la mémoire : une variable modifiée par l’un ne change pas chez l’autre ;

  • pthread_create crée un deuxième thread dans le même processus : il n’y a qu’une mémoire, et une variable globale modifiée par un thread est modifiée pour tous.

Dans le schéma, main crée deux threads qui exécutent tous les deux la fonction travail. Chaque thread a son propre stack, donc sa propre variable locale x, et son propre program counter (la ligne qu’il exécute) ; la variable globale compteur, la heap et le code n’existent qu’en un exemplaire, partagé.

À gauche, un programme où main crée deux threads qui exécutent la fonction travail ; une flèche par thread indique la ligne qu'il exécute. À droite, la mémoire du processus : un stack par thread (main, thread 1 et thread 2, chacun avec sa propre variable x), puis la heap, les globales (une seule variable compteur) et le code, partagés.

Conséquences :

  • une variable globale ou un bloc alloué avec malloc est visible par tous les threads : c’est ce qui rend le partage de données facile, mais aussi dangereux (voir Synchronisation) ;

  • une variable locale vit dans le stack d’un seul thread, et disparaît quand la fonction qui la déclare se termine ;

  • tous les threads d’un processus ont le même PID (getpid()), mais chacun a son propre identifiant de thread, le TID (gettid()) ; pour le thread principal, TID = PID.

Avantages :

  • Partage facile de données (même mémoire)

  • Création et destruction plus rapides que celles d’un processus

  • Communication plus simple (pas besoin de mécanismes IPC lourds, voir Communication inter-processus)

Inconvénients :

  • Risques de data race (accès concurrent non synchronisé)

  • Synchronisation nécessaire (mutex, atomiques, variables de condition)

  • Un crash d’un thread peut faire planter tout le processus

Voici la différence entre des applications avec ou sans multithreading ou multiprocessing :

  • 1 processus - 1 thread (monoprocessing et monothreading) : cas par défaut, il y a un stack pour le thread principal (main thread)

  • 1 processus - 2 threads (multithreading) : on crée un thread en plus du thread principal

  • 2 processus - 1 thread par processus (multiprocessing) : on a deux espaces mémoire avec un thread principal chacun

  • 2 processus - 2 threads par processus (multithreading et multiprocessing) : chaque processus a deux threads (le thread principal et un second)

Schéma présentant la différence entre des programmes en multiprocessing et multithreading.

Cas d’utilisation de threads :

  • Serveurs web multi-clients : chaque requête HTTP peut être gérée par un thread, avec partage du cache et des données globales

  • Applications interactives : séparer l’interface utilisateur (UI) et le traitement lourd pour éviter le blocage (ex. un éditeur ou un jeu vidéo)

  • Calcul parallèle : diviser un gros calcul en plusieurs tâches parallèles sur des cœurs différents

  • Producteur / consommateur : un thread lit des données (entrée clavier, socket réseau, fichier) pendant qu’un autre les traite

  • Simulations : gestion de plusieurs entités indépendantes (ex. particules, joueurs dans un jeu en réseau)

Cas d’utilisation de processus (fork, mémoire isolée) :

  • Isolation forte : le crash d’un processus n’impacte pas les autres

  • Sécurité : exécution de code non fiable à part (ex. plugins, sandbox)

  • Services indépendants : exécution d’outils externes

  • Multi-utilisateurs : chaque utilisateur dispose de son propre espace mémoire

POSIX threads

L’API standard sous Linux est Pthreads (POSIX threads), disponible avec l’en-tête <pthread.h> (voir man 7 pthreads). Il existe aussi une API de threads dans la norme C11 (<threads.h>), non traitée dans ce cours.

Le principe :

  • pthread_create crée un thread, qui commence aussitôt à exécuter la fonction qu’on lui donne ;

  • pthread_join attend la fin d’un thread (et libère ses ressources). Sans cette attente, lorsque main se termine (return ou exit), tous les threads sont interrompus ;

  • la fonction exécutée par le thread a toujours la signature void *ma_fonction(void *arg) : elle reçoit un seul argument de type void * (pour en passer plusieurs, on utilise une structure) et retourne un void *.

Rappel : pointeur de fonction et void *

L’API pthread utilise deux notions de la partie Bases de C :

  • le nom d’une fonction, sans parenthèses, est son adresse (voir Pointeurs de fonctions) : on écrit pthread_create(&tid, NULL, ma_fonction, &valeur). Avec ma_fonction(), on appellerait la fonction au lieu de la donner au thread ;

  • un void * est une adresse sans type : on peut y mettre l’adresse de n’importe quelle variable (&valeur, &params). Dans le thread, on range arg dans un pointeur du bon type, int *val = arg; (conversion implicite en C, ou explicite avec (int *)arg : les deux sont valides). Rien ne vérifie que c’est le bon type : c’est à vous de rester cohérent entre pthread_create et la fonction du thread.

pthread_join a besoin d’une troisième notion, l’adresse d’un pointeur (void **), détaillée plus bas.

Pour la compilation, il ne faut pas oublier d’ajouter -pthread aux flags :

gcc -std=c2x -Wall -Wextra -pedantic -g -pthread mon_prog.c

La création se fait avec pthread_create :

int pthread_create(pthread_t *thread,
                    const pthread_attr_t *attr,
                    void *(*start_routine)(void *),
                    void *arg);
  • thread : pointeur où sera stocké l’identifiant du thread

  • attr : attributs du thread (souvent NULL)

  • start_routine : fonction exécutée par le thread

  • arg : argument passé à cette fonction

Retourne 0 en cas de succès, sinon un code d’erreur (errno n’est pas modifié).

L’appel de pthread_create crée un thread en lançant la fonction start_routine en concurrence (potentiellement en parallèle selon le nombre de cœurs disponibles) et en lui donnant arg comme paramètre. Il modifiera la variable thread de type pthread_t pour contenir l’identifiant du thread lancé, afin de pouvoir l’attendre ensuite avec pthread_join.

Attention : comme les fonctions pthread retournent le code d’erreur au lieu de le placer dans errno, perror afficherait un message sans rapport : on utilise strerror(err) (<string.h>) sur la valeur retournée, comme dans l’exemple ci-dessous.

Deux conventions d’erreur (voir Gestion des erreurs)

Fonctions

Retour en cas d’erreur

Cause de l’erreur

Affichage

appels système et libc (open, read, fork, malloc…)

-1 ou NULL

dans errno

perror("open") (voir perror)

fonctions pthread

un nombre différent de 0

c’est la valeur retournée

fprintf(stderr, "...: %s\n", strerror(err)) (voir strerror)

L’attente du thread se fait avec pthread_join :

int pthread_join(pthread_t thread, void **retval);
  • thread : l’identifiant du thread

  • retval : adresse d’un void * qui recevra la valeur retournée par le thread (NULL pour l’ignorer)

Retourne 0 en cas de succès, sinon un code d’erreur (errno n’est pas modifié).

Le thread appelant (souvent le thread principal, mais n’importe quel thread peut faire le join) attend au niveau de cette fonction tant que le thread n’a pas terminé son calcul. Si retval n’est pas NULL, *retval contiendra la valeur retournée par le thread.

Si un thread veut retourner une valeur, il peut utiliser un return « classique » ou utiliser pthread_exit à la fin de sa fonction :

void pthread_exit(void *retval);
  • retval : la valeur retournée par le thread (NULL si aucune valeur)

Ne retourne jamais : le thread appelant se termine.

Elle permet de retourner un pointeur sur n’importe quel type (void *) et a le même effet que return retval; dans la fonction du thread. Le programme principal récupère la valeur avec pthread_join.

L’exemple ci-dessous affiche aussi l’identifiant noyau du thread, le TID, obtenu avec gettid :

pid_t gettid(void);

Retourne le TID (Thread ID) du thread appelant, attribué par le noyau. Cet appel n’échoue jamais.

gettid est propre à Linux (non portable). Elle n’est déclarée dans <unistd.h> qu’avec #define _GNU_SOURCE placé avant le premier #include : sinon, gcc signale implicit declaration of function 'gettid'.

Attention à ne pas confondre le TID (identifiant noyau, du même type qu’un PID, voir getpid) avec le pthread_t rempli par pthread_create (identifiant de la bibliothèque pthread, utilisé pour le pthread_join).

Exemple sans retour de valeur :

 1 #define _GNU_SOURCE // pour gettid
 2 #include <pthread.h>
 3 #include <stdio.h>
 4 #include <stdlib.h>
 5 #include <string.h>
 6 #include <unistd.h>
 7
 8 void *ma_fonction(void *arg) {
 9     int *val = (int *)arg;
10     // gettid() donne le Thread ID (TID) attribué par le noyau
11     printf("Hello depuis le thread %d, arg = %d\n", gettid(), *val);
12     return NULL;
13 }
14
15 int main(void) {
16     pthread_t tid;
17     int valeur = 42;
18     // attention si la variable locale est détruite avant le join
19     // dans ce cas allouer la variable dans la heap (voir plus bas)
20
21     int err = pthread_create(&tid, NULL, ma_fonction, &valeur);
22     if (err != 0) {
23         fprintf(stderr, "pthread_create: %s\n", strerror(err));
24         exit(EXIT_FAILURE);
25     }
26
27
28     // attendre la fin du thread
29     err = pthread_join(tid, NULL);
30     if (err != 0) {
31         fprintf(stderr, "pthread_join: %s\n", strerror(err));
32         exit(EXIT_FAILURE);
33     }
34
35     printf("Thread terminé !\n");
36     return EXIT_SUCCESS;
37 }

Dans la suite, pour raccourcir, la plupart des exemples ne testent pas le retour de pthread_create et pthread_join : dans vos programmes, testez-le comme ci-dessus.

Que se passe-t-il à la création d’un thread ?

pthread_create ne fait pas qu’appeler la fonction : il crée un nouveau thread, puis retourne aussitôt. À partir de là, deux threads avancent chacun de leur côté : main continue après pthread_create, et le nouveau thread exécute ma_fonction. pthread_join est un point de rendez-vous : main y reste bloqué jusqu’à la fin du thread.

Chronologie de main, thread 1 et thread 2 : sur un cœur, ils s'exécutent chacun leur tour ; sur deux cœurs, thread 1 et thread 2 s'exécutent en même temps pendant que main attend dans pthread_join ; sans pthread_join, main se termine et les threads sont interrompus.
  • Sur un seul cœur, l’ordonnanceur (voir Ordonnanceur et priorités) donne le processeur à chaque thread à tour de rôle, par petites tranches de temps.

  • Sur plusieurs cœurs, plusieurs threads s’exécutent vraiment en même temps : c’est ce qui permet d’accélérer un calcul en le répartissant entre des threads.

  • Sans pthread_join, main peut se terminer avant les threads : le processus s’arrête et les threads sont interrompus en plein travail.

Comme après un fork, c’est l’ordonnanceur qui décide quel thread s’exécute et à quel moment : l’ordre des affichages de différents threads change d’une exécution à l’autre. Chaque printf est protégé par un verrou interne, donc une ligne écrite en un seul appel n’est pas coupée au milieu, mais l’ordre des lignes n’est pas prévisible.

Retourner une valeur

Exemple partiel avec une valeur retournée :

void *ma_fonction(void *arg) {
    (void)arg; // évite le warning « paramètre inutilisé »
    // on ne doit pas retourner l'adresse d'une variable locale (dans le stack) :
    // elle est détruite à la fin de la fonction !
    int *res = malloc(sizeof(int));
    if (res == NULL) {
        return NULL;
    }
    *res = 1234;
    return res; // ou pthread_exit(res);
}

// main ...
void *r;
pthread_join(tid, &r); // pthread_join écrit un void * dans r
int *resultat = r;
if (resultat != NULL) {
    printf("Résultat : %d\n", *resultat);
    free(resultat); // main libère ce que le thread a alloué
}

Le résultat est alloué dans la heap : il survit à la fin du thread, contrairement au stack du thread. pthread_join doit ensuite écrire l’adresse de ce résultat dans votre variable r : il lui faut donc l’adresse de r, d’où &r (de type void **), comme scanf("%d", &x) a besoin de &x pour écrire dans x (voir Passage par valeur, passage par adresse).

En trois temps : le thread alloue un int dans la heap et garde son adresse dans res ; à la fin du thread son stack est détruit mais le bloc reste ; pthread_join écrit l'adresse du bloc dans la variable r de main.

Passer plusieurs arguments

La signature de la fonction sur laquelle lancer le thread est void *ma_fonction(void *), donc elle prend un seul argument et ne retourne qu’un élément. Dans ce cas, comment faire si l’on veut passer plusieurs arguments à la fonction ?

La solution est d’utiliser une structure !

typedef struct {
    double valeur;
    char *message;
} params_t;

void *ma_fonction(void *arg) {
    params_t *p = arg;
    printf("%f, %s\n", p->valeur, p->message);
    return NULL;
}

int main(void) {
    pthread_t t;
    params_t params = {3.14, "Hello"};
    pthread_create(&t, NULL, ma_fonction, &params);
    pthread_join(t, NULL);
    return 0;
}

Ainsi, on crée une structure pour notre thread qui nous permettra de passer plusieurs éléments à la fonction (ici un double et un char *).

Durée de vie de l’argument

Le thread reçoit une adresse : la variable pointée doit exister, et ne plus être modifiée, tant que le thread l’utilise (voir Durée de vie (lifetime)). Dans l’exemple ci-dessus, params est une variable locale de main, qui ne se termine qu’après le pthread_join : pas de problème.

En revanche, si la fonction qui crée le thread se termine avant lui, sa variable locale est détruite alors que le thread peut encore la lire :

void lancer_thread(pthread_t *tid) {
    int valeur = 42;
    pthread_create(tid, NULL, ma_fonction, &valeur); // FAUX
} // valeur est détruite ici, le thread lit peut-être une case réutilisée

On alloue alors l’argument dans la heap, et on décide qui le libère (voir Propriété de la mémoire) : ici, le thread devient propriétaire de son argument et le libère quand il n’en a plus besoin.

void *ma_fonction(void *arg) {
    int *val = arg;
    printf("valeur = %d\n", *val);
    free(val); // le thread libère son argument
    return NULL;
}

void lancer_thread(pthread_t *tid) {
    int *valeur = malloc(sizeof(int));
    if (valeur == NULL) {
        perror("malloc");
        exit(EXIT_FAILURE);
    }
    *valeur = 42;
    pthread_create(tid, NULL, ma_fonction, valeur); // le thread devient propriétaire
}

Créer N threads dans une boucle

Pour créer plusieurs threads, on utilise un tableau de pthread_t, une boucle de création, puis une boucle de pthread_join. Chaque thread doit recevoir sa propre donnée.

Avertissement

Piège classique : créer des threads dans une boucle en leur passant l’adresse de la variable de boucle.

for (int i = 0; i < 4; i++) {
    pthread_create(&t[i], NULL, ma_fonction, &i); // FAUX
}

Tous les threads reçoivent la même adresse &i : quand un thread lit *arg, i a peut-être déjà changé (plusieurs threads voient la même valeur), voire n’existe plus (fin de la boucle). Il faut donner à chaque thread sa propre donnée : un tableau (d’entiers ou de structures) dont on passe &args[i], ou une structure allouée avec malloc pour chaque thread (libérée par le thread).

À gauche, quatre threads pointent vers la même variable i, dont la valeur change ; à droite, chaque thread pointe vers sa propre case d'un tableau args.

Exemple : chaque thread calcule la factorielle d’un nombre. La structure contient à la fois l’entrée (n) et la sortie (resultat) : le thread écrit son résultat dans sa propre case du tableau, ce qui évite un malloc pour le retour.

 1 #include <pthread.h>
 2 #include <stdio.h>
 3 #include <stdlib.h>
 4 #include <string.h>
 5
 6 #define NB_THREADS 4
 7
 8 // une seule structure pour l'entrée (n) et la sortie (resultat)
 9 typedef struct {
10     int n;
11     unsigned long long resultat;
12 } factorielle_t;
13
14 void *factorielle(void *arg) {
15     factorielle_t *donnees = arg;
16     unsigned long long produit = 1;
17     for (int i = 2; i <= donnees->n; i++) {
18         produit *= i;
19     }
20     donnees->resultat = produit; // écrit dans sa propre case du tableau
21     return NULL;
22 }
23
24 int main(void) {
25     pthread_t threads[NB_THREADS];
26     factorielle_t donnees[NB_THREADS]; // une case par thread
27     int valeurs[NB_THREADS] = {5, 10, 15, 20};
28
29     // 1. créer les threads : chacun reçoit l'adresse de SA case
30     for (int i = 0; i < NB_THREADS; i++) {
31         donnees[i].n = valeurs[i];
32         int err = pthread_create(&threads[i], NULL, factorielle, &donnees[i]);
33         if (err != 0) {
34             fprintf(stderr, "pthread_create: %s\n", strerror(err));
35             exit(EXIT_FAILURE);
36         }
37     }
38
39     // 2. attendre chaque thread, puis lire son résultat
40     for (int i = 0; i < NB_THREADS; i++) {
41         int err = pthread_join(threads[i], NULL);
42         if (err != 0) {
43             fprintf(stderr, "pthread_join: %s\n", strerror(err));
44             exit(EXIT_FAILURE);
45         }
46         printf("%d! = %llu\n", donnees[i].n, donnees[i].resultat);
47     }
48     return EXIT_SUCCESS;
49 }

Résultat :

5! = 120
10! = 3628800
15! = 1307674368000
20! = 2432902008176640000

Le tableau donnees est une variable locale de main, qui ne se termine qu’après les pthread_join : il existe pendant toute la vie des threads, pas besoin de malloc. main ne lit donnees[i].resultat qu”après le pthread_join du thread i : à ce moment, le thread a fini d’écrire.

Threads détachés

Par défaut, un thread créé avec pthread_create est joinable, c’est-à-dire qu’un autre thread (en général le thread principal) peut (et doit) appeler pthread_join dessus pour :

  • récupérer sa valeur de retour

  • libérer les ressources associées au thread

Si un thread joinable n’est jamais attendu avec pthread_join, ses ressources (son stack et son descripteur) ne sont jamais libérées : il crée une fuite mémoire interne (zombie thread), comme un processus zombie qu’aucun wait ne récupère.

Pour éviter cela, un thread peut être détaché (detached) avec pthread_detach :

int pthread_detach(pthread_t thread);
  • thread : l’identifiant du thread à détacher

Retourne 0 en cas de succès, sinon un code d’erreur (errno n’est pas modifié).

Un thread détaché :

  • libère automatiquement ses ressources lorsqu’il se termine

  • ne peut plus être attendu avec pthread_join (sa valeur de retour est perdue)

  • est typiquement utilisé dans les serveurs (un thread par client)

pthread_t tid;
pthread_create(&tid, NULL, ma_fonction, NULL);

pthread_detach(tid); // le thread devient détaché

Ici, il est ensuite interdit d’appeler pthread_join(tid, ...).

Synchronisation

Comme plusieurs threads partagent les mêmes variables globales ou les données de la heap grâce aux pointeurs, il faut éviter les accès concurrents non contrôlés. Sinon, on obtient des résultats incohérents. On parle de data race (course aux données) lorsque plusieurs threads accèdent à la même variable sans synchronisation, avec au moins une écriture : c’est un comportement indéfini (UB) en C. C’est un cas particulier des race conditions (situations de compétition), où le résultat dépend de l’ordre d’exécution des threads.

Exemple classique : incrémenter une variable globale depuis deux threads.

 1#include <pthread.h>
 2#include <stdio.h>
 3
 4int compteur = 0;
 5
 6void *incremente(void *arg) {
 7    (void)arg;
 8    for (int i = 0; i < 1000000; i++) {
 9        compteur++;
10    }
11    return NULL;
12}
13
14int main(void) {
15    pthread_t t1, t2;
16    pthread_create(&t1, NULL, incremente, NULL);
17    pthread_create(&t2, NULL, incremente, NULL);
18
19    pthread_join(t1, NULL);
20    pthread_join(t2, NULL);
21
22    printf("Compteur final = %d\n", compteur);
23    return 0;
24}

Résultats observés (programme compilé sans optimisation, -O0, le défaut de gcc), qui changent d’une exécution à l’autre :

Compteur final = 1048495
Compteur final = 1102100
Compteur final = 1069216
Compteur final = 1087636

alors que le résultat attendu est 2 000 000 (chaque thread fait 1 000 000 incréments).

Pourquoi ce bug ?

L’opération compteur++ n’est pas atomique, elle se décompose en plusieurs instructions machine :

  1. Lire la valeur actuelle de compteur

  2. Ajouter 1

  3. Réécrire la nouvelle valeur dans compteur

Ces étapes passent par un registre du processeur : chaque cœur a les siens, la mémoire est commune. Un thread peut être interrompu entre deux de ces instructions, ou s’exécuter au même moment qu’un autre thread sur un autre cœur. Si deux threads exécutent cette séquence en même temps, ils peuvent interférer, par exemple avec compteur = 50 :

  1. thread 1 et thread 2 lisent tous les deux 50 dans leur registre ;

  2. chacun calcule 50 + 1 = 51 ;

  3. thread 1 écrit 51 dans compteur, puis thread 2 écrit aussi 51.

Résultat final, compteur = 51, alors qu’on aurait dû avoir compteur = 52 : l’incrément de thread 1 est perdu.

Diagramme de séquence : thread 1 et thread 2, sur deux cœurs, lisent 50, calculent 51 dans leur registre et écrivent 51 : un incrément est perdu.

Ce phénomène se produit de très nombreuses fois dans la boucle, ce qui explique pourquoi le résultat final varie et est presque toujours inférieur à 2 000 000.

Avertissement

Avec -O2, gcc transforme la boucle en un seul compteur += 1000000 et le programme affiche en pratique 2 000 000 à chaque exécution. La data race est pourtant toujours là (le comportement reste indéfini). Un résultat correct ne prouve pas l’absence de data race : pour la détecter, on utilise l’outil helgrind de Valgrind (voir la page Outils) :

gcc -std=c2x -Wall -Wextra -pedantic -g -pthread mon_prog.c
valgrind --tool=helgrind ./a.out

helgrind signale Possible data race avec le numéro de la ligne concernée, et Locks held: none (aucun verrou n’était pris).

Solution : utiliser des mécanismes de synchronisation (mutex, opérations atomiques, etc.) pour rendre l’incrément atomique. Déclarer compteur en volatile (voir Autres mots-clés) ne suffit pas : volatile ne protège pas d’une data race et ne rend pas compteur++ atomique.

Mutex

Une section critique est une portion de code qui accède à une donnée partagée et que deux threads ne doivent pas exécuter en même temps (ici, compteur++). Un mutex (mutual exclusion, verrou d’exclusion mutuelle) est un verrou qui garantit qu’un seul thread exécute la section critique à la fois : comme une clé unique, celui qui la détient entre, les autres attendent qu’elle soit rendue.

Avec les pthreads, le mutex est un pthread_mutex_t qui peut être initialisé statiquement ou dynamiquement. L’initialisation dynamique et la libération se font avec pthread_mutex_init et pthread_mutex_destroy :

int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
  • mutex : le mutex

  • attr : attributs du mutex (NULL pour les attributs par défaut)

Retourne 0 en cas de succès, sinon un code d’erreur (errno n’est pas modifié).

Initialisation statique, sans appel de fonction : pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;.

pthread_mutex_destroy est nécessaire après une initialisation dynamique, facultative après une initialisation statique.

Il peut ensuite être utilisé en le verrouillant avec pthread_mutex_lock avant la section critique puis en le déverrouillant avec pthread_mutex_unlock après la section critique :

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
  • mutex : le mutex à verrouiller ou à déverrouiller

Retourne 0 en cas de succès, sinon un code d’erreur (errno n’est pas modifié).

Si un thread essaie d’appeler pthread_mutex_lock sur un verrou déjà verrouillé, il est mis en attente jusqu’à ce que le mutex soit déverrouillé.

Exemple :

 1 #include <pthread.h>
 2 #include <stdio.h>
 3
 4 pthread_mutex_t verrou = PTHREAD_MUTEX_INITIALIZER;
 5 int compteur = 0;
 6
 7 void *incremente(void *arg) {
 8     (void)arg;
 9     for (int i = 0; i < 1000000; i++) {
10         pthread_mutex_lock(&verrou);
11         compteur++;
12         pthread_mutex_unlock(&verrou);
13     }
14     return NULL;
15 }
16
17 int main(void) {
18     pthread_t t1, t2;
19     pthread_create(&t1, NULL, incremente, NULL);
20     pthread_create(&t2, NULL, incremente, NULL);
21
22     pthread_join(t1, NULL);
23     pthread_join(t2, NULL);
24
25     printf("Compteur final = %d\n", compteur);
26     return 0;
27 }

Grâce au mutex, il n’y a plus de problèmes de data race (accès concurrent non synchronisé) et le compteur est bien à 2 000 000 en fin de calcul.

Il est aussi possible de mettre le mutex dans une structure qui accompagnera la donnée critique.

Fonctionnement avec le mutex verrouillé avant la modification de la variable :

Diagramme de séquence : le thread 2 est bloqué sur lock tant que le thread 1 détient le verrou, puis c'est l'inverse ; les deux incrémentations sont prises en compte.

Il est important de réduire au maximum la section critique pour que les autres threads attendent le moins longtemps possible le mutex.

Avertissement

Chaque lock doit être suivi d’un unlock, sur tous les chemins : un return (ou un break) au milieu de la section critique laisse le mutex verrouillé, et tous les threads qui le demandent ensuite restent bloqués pour toujours.

pthread_mutex_lock(&verrou);
if (compteur >= MAX) {
    pthread_mutex_unlock(&verrou); // à ne pas oublier avant le return
    return NULL;
}
compteur++;
pthread_mutex_unlock(&verrou);

Atomic (C11)

Pour des compteurs simples, on peut simplifier la synchronisation en utilisant des opérations atomiques.

Une opération est dite atomique (dans le cadre de la programmation concurrente) si toutes les étapes de l’opération s’exécutent sans pouvoir être interrompues avant leur fin.

La norme C11 introduit les opérations atomiques avec l’en-tête <stdatomic.h> qui apporte des types atomiques (atomic_int, atomic_bool…) et un ensemble de fonctions pour travailler avec ces types.

Exemple avec un atomic_int :

#include <stdatomic.h>

atomic_int compteur = 0;

void *incremente(void *arg) {
    (void)arg; // évite le warning « paramètre inutilisé »
    for (int i = 0; i < 1000000; i++) {
        atomic_fetch_add(&compteur, 1);
    }
    return NULL;
}

Avec cet atomic_int, nous sommes sûrs que la lecture, la modification et l’écriture dans la variable compteur se feront sans être interrompues par un autre thread, et ce, sans avoir à utiliser de mutex.

Sur un atomic_int, l’écriture compteur++ est elle aussi atomique (elle équivaut à atomic_fetch_add(&compteur, 1)) ; sur un int ordinaire, elle ne l’est pas.

Les fonctions courantes de <stdatomic.h> :

int atomic_load(atomic_int *obj);
void atomic_store(atomic_int *obj, int value);
int atomic_fetch_add(atomic_int *obj, int value);
int atomic_fetch_sub(atomic_int *obj, int value);
  • obj : adresse de la variable atomique (par exemple &compteur)

  • value : la valeur à écrire (atomic_store), à ajouter (atomic_fetch_add) ou à retirer (atomic_fetch_sub)

atomic_load retourne la valeur lue ; atomic_fetch_add et atomic_fetch_sub retournent la valeur avant l’opération ; atomic_store ne retourne rien. Ces fonctions n’échouent pas.

Ces fonctions sont génériques : les signatures ci-dessus sont celles pour un atomic_int, elles existent de la même façon pour tous les types atomiques (atomic_bool, atomic_long…).

atomic_load sert à lire la valeur pendant que d’autres threads la modifient (après les pthread_join, compteur peut aussi être lu directement).

Ces fonctions et types sont donc utiles pour de petites variables ; pour de plus gros blocs de code, il faudra utiliser des mutex.

Variables de condition

Une variable de condition avec Pthreads (pthread_cond_t) permet de faire attendre un thread jusqu’à ce qu’une condition devienne vraie.

Elle est toujours utilisée avec un mutex, et est essentielle pour les modèles :

  • producteur / consommateur

  • files de messages

  • tâches à réveil conditionnel

  • travail en lot (batch)

Fonctions principales :

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
  • cond : la variable de condition

  • mutex : le mutex associé à la condition, verrouillé par le thread qui appelle pthread_cond_wait

Retourne 0 en cas de succès, sinon un code d’erreur (errno n’est pas modifié).

pthread_cond_wait libère le mutex et met le thread en attente (atomiquement), puis reprend le mutex avant de retourner.

pthread_cond_signal réveille au moins un thread en attente (aucun effet si aucun thread n’attend : le signal n’est pas mémorisé).

pthread_cond_broadcast réveille tous les threads en attente.

Il faut toujours faire les wait dans une boucle while qui reteste la condition, car :

  • POSIX autorise les réveils intempestifs (spurious wakeups) : pthread_cond_wait peut retourner sans qu’aucun signal n’ait été envoyé

  • un autre thread peut avoir modifié la condition entre le signal et la reprise du mutex

C’est le fait de tester la condition avant d’attendre, en détenant le mutex, qui évite de rater un signal envoyé avant le wait.

Le point le plus difficile est ce que fait pthread_cond_wait avec le mutex : comme dans une salle d’attente, le thread rend la clé en entrant (sinon, aucun autre thread ne pourrait prendre le mutex pour modifier la condition) et la reprend en sortant, avant de retester la condition. Le schéma suivant déroule l’exemple ci-dessous, quand le consommateur arrive avant que la donnée soit disponible :

Diagramme de séquence : le consommateur prend le mutex, lit disponible à false, appelle cond_wait qui rend le mutex et l'endort ; le producteur prend le mutex, écrit true et signale ; le consommateur réveillé attend le mutex, le reprend après le unlock du producteur, relit disponible à true et sort de la boucle.

Comme un mutex, une variable de condition peut être initialisée statiquement ou dynamiquement. L’initialisation dynamique et la libération se font avec pthread_cond_init et pthread_cond_destroy :

int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
int pthread_cond_destroy(pthread_cond_t *cond);
  • cond : la variable de condition

  • attr : attributs de la variable de condition (NULL pour les attributs par défaut)

Retourne 0 en cas de succès, sinon un code d’erreur (errno n’est pas modifié).

Initialisation statique, sans appel de fonction : pthread_cond_t cond = PTHREAD_COND_INITIALIZER;.

pthread_cond_destroy est nécessaire après une initialisation dynamique, facultative après une initialisation statique. Aucun thread ne doit être en attente sur la condition au moment de sa destruction.

Exemple :

 1#include <pthread.h>
 2#include <stdbool.h>
 3#include <stdio.h>
 4#include <stdlib.h>
 5
 6int donnee = 0;
 7bool disponible = false;
 8
 9pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
10pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
11
12void *producteur(void *arg) {
13    (void)arg;
14
15    // attend d'avoir la main pour modifier disponible
16    pthread_mutex_lock(&mutex);
17
18    donnee = 42;       // réalise son calcul
19    disponible = true; // passe disponible à vrai
20
21    // réveille le consommateur
22    pthread_cond_signal(&cond);
23
24    pthread_mutex_unlock(&mutex);
25    return NULL;
26}
27
28void *consommateur(void *arg) {
29    (void)arg;
30
31    // verrouille le mutex pour être sûr que disponible ne soit pas modifié
32    pthread_mutex_lock(&mutex);
33
34    // attendre que disponible passe à true
35    while (!disponible) {
36        // relâche le mutex et attend, puis reprend le mutex au réveil
37        pthread_cond_wait(&cond, &mutex);
38    }
39    disponible = false;
40
41    // une fois réveillé par le producteur
42    // la donnée est modifiée, il peut la lire
43    printf("Consommateur : donnée reçue = %d\n", donnee);
44
45    pthread_mutex_unlock(&mutex);
46    return NULL;
47}
48
49int main(void) {
50    pthread_t prod, cons;
51
52    pthread_create(&cons, NULL, consommateur, NULL);
53    pthread_create(&prod, NULL, producteur, NULL);
54
55    pthread_join(prod, NULL);
56    pthread_join(cons, NULL);
57
58    return 0;
59}

Erreurs fréquentes avec les threads

Interblocages (deadlocks)

Un deadlock (ou interblocage) survient lorsque deux threads (ou plus) attendent chacun un verrou détenu par l’autre : ils restent bloqués pour toujours. Par exemple, le thread 1 prend mutex_A puis demande mutex_B, pendant que le thread 2 prend mutex_B puis demande mutex_A.

Pour l’éviter, tous les threads doivent prendre les verrous dans le même ordre (ici, toujours mutex_A avant mutex_B).

Autres problèmes

  • Livelock : les threads ne sont pas bloqués, mais passent leur temps à réagir les uns aux autres sans progresser.

  • Famine (starvation) : un thread n’obtient jamais la ressource qu’il attend parce que d’autres threads la prennent toujours avant lui.