Introduction à l’API des pthreads

Orestis Malaspinas, Steven Liatti

API des pthreads

Le contenu de ce chapitre est basé sur l’excellent livre de R. H. Arpaci-Dusseau et A. C. Arpaci-Dusseau1.

Dans ce chapitre, nous allons brièvement introduire l’api de base de la gestion de threads en C via la librairie POSIX, pthreads.

Toutes les fonctions introduites ici sont déclarées dans le fichier pthread.h. Il est donc sous-entendu que nous inclurons toujours ce fichier dans nos codes de cette manière :

#include <pthread.h>

Par ailleurs, il faut également faire l’édition des liens avec la librairie pthread, option -lpthread.

Je vous recommande d’utiliser les options suivantes pour compiler votre code :

gcc -g -Wall -Wextra -std=gnu11 -fsanitize=address -fsanitize=leak
    -fsanitize=undefined -o main main.c -lpthread

Il faut noter qu’on utilise l’option -std=gnu11 afin que certaines fonctionnalités comme les barrières de synchronisation soient reconnues. L’autre solution est d’ajouter la ligne préprocesseur suivante avant l’inclusion des headers

#define __GNU_SOURCE
#include <pthread.h>

Finalement, il se peut que toute la documentation ne soit pas installée par défaut sur votre système (concernant les barrières par exemple). Pour les installer, il faut le package manpages-posix-dev pour les distribution dérivées de “Debian”.

Création et terminaison de threads

Création de threads

Afin de créer un thread il faut appeler la fonction pthread_create() dont l’entête est reproduite ci-dessous :

int pthread_create(pthread_t *thread,
                   const pthread_attr_t *attr,
                   void *(*start_routine)(void *),
                   void *arg);

Cette fonction aura pour effet de créer un thread et exécuter la fonction start_routine() à l’intérieur de celui-ci.

Elle prend en paramètres :

  1. Un pointeur vers un type pthread_t, qui est un identifiant du thread, qui va permettre d’interagir avec ledit thread.
  2. Une structure pthread_attr_t qui contient un certain nombre d’attributs d’un thread. Si ce pointeur vaut NULL, alors les attributs ont une valeur par défaut (cela fera presque toujours l’affaire dans ce cours).
  3. Un pointeur vers une fonction qui a une signature un peu compliquée :
  4. Un pointeur void * qui représente les paramètres de la fonction start_routine().

Le but étant de pouvoir passer n’importe quelle fonction, retournant une valeur quelconque et prenant des paramètres d’un type quelconque.

Par ailleurs, la fonction pthread_create() retourne un entier qui donne une information sur la réussite ou non de la création du thread. Si le retour de la fonction est 0 tout s’est bien passé, sinon il y a eu une erreur.

Pensez à toujours bien vérifier le retour de pthread_create() !


Exemple 1 (Création de thread)

Dans cet exemple2, nous créons un thread, qui appelle la fonction func() qui a pour argument. Nous souhaitons que cette fonction ait un argument char * et devons donc explicitement caster le pointeur void *arg.

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

void *func(void *arg) {
    char *msg = (char *) arg; // type casting of the arg
    printf("Message = %s\n", msg);
    return NULL;
}

int main(int argc, char *argv[]) {
    pthread_t t;
    char *msg = "My first thread!";
    if (pthread_create(&t, NULL, func, msg) != 0) {
        perror("Thread creation error."); // affiche le message sur le canal d'erreur
        return EXIT_FAILURE;
    }
    return EXIT_SUCCESS;
}

On note aussi que nous testons explicitement que la création du thread s’est bien passée. Si une erreur se produit (pthread_create() retourne autre chose que 0), nous afficherons le message Thread creation error.



Question 1

Que va afficher ce code?


Terminaison de threads

Maintenant que nous avons créé un thread, nous devons aussi pouvoir en gérer la terminaison. Cette gestion se fait avec l’appel à la fonction pthread_join() dont l’entête est reproduite ci-dessous :

int pthread_join(pthread_t thread, void **value_ptr);

Lors de l’appel de cette fonction, le thread principal attend la terminaison du thread thread créé précédemment. Elle prend en paramètre l’identifiant du thread dont on attend la fin, et un pointeur vers un pointeur void. Ce pointeur est en fait un pointeur vers le type de retour de la fonction start_routine(). Il est très important que value_ptr soit un pointeur de pointeur, car on change la valeur passée en argument. Ainsi si cette valeur est un pointeur, la seule façon de la modifier est d’avoir une indirection supplémentaire et donc d’avoir un double pointeur. De plus, le type de ces pointeurs est void, afin de pouvoir retourner n’importe quel type. Si nous ne souhaitons rien retourner, nous pouvons simplement appeler cette fonction avec NULL en second paramètre.

Il faut également noter que comme dans le cas de la création d’un thread, la fonction pthread_join() retourne un entier qui nous dira si la terminaison du thread s’est bien passée.

Pensez à toujours bien vérifier le retour de pthread_join() !


Exemple 2 (Jointure de thread)

Dans l’exemple précédent, nous constatons que très souvent (presque toujours) le thread créé n’avait jamais le temps de s’exécuter avant la fin du programme. Nous corrigeons ce problème ici, en appelant la fonction pthread_join() depuis le thread principal de notre programme.

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

void *func(void *arg) {
    char *msg = (char *) arg; // type casting of the arg
    printf("Message = %s\n", msg);
    return NULL;
}

int main(int argc, char *argv[]) {
    pthread_t t;
    char *msg = "My first thread!";

    if (pthread_create(&t, NULL, func, msg) != 0) {
        perror("Thread creation error."); // affiche le message sur le canal d'erreur
        return EXIT_FAILURE;
    }

    if (pthread_join(t, NULL) != 0) { // attente que le thread se termine
        perror("Thread join error");
        return EXIT_FAILURE;
    }

    return EXIT_SUCCESS;
}

Exercices : création/terminaison

  1. Écrire une librairie de création et terminaison des threads, vérifiant le retour des fonctions pthread_create() et pthread_join() : ce seront des wrappers que vous réutiliserez un peu tout le temps dans le reste du cours, faites les consciencieusement !

  2. Écrire un petit programme utilisant votre librairie, créant 10 threads et affichant :

    Hello World from thread %d.

    %d est le numéro de votre thread. Puis le thread principal affichera le message :

    Hello from main thread.
  3. Finalement, avant que le thread principal n’affiche son message, garantissez que tous les autres threads soient terminés. Que constatez-vous comme différence?

  4. Écrivez un petit programme qui crée 10 threads. Chaque thread va incrémenter 10’000 fois une variable entière n définie dans le thread principal. Quelle devrait être la valeur de n à la fin de notre programme s’il s’exécutait de façon séquentielle ? Que constatez-vous ici ? Quelle solution proposeriez-vous pour résoudre ce problème ?

  5. Écrivez un petit programme qui crée 10 threads. Chaque thread va incrémenter 10’000 fois une variable entière n définie à l’intérieur de lui-même, puis retournera cette valeur. Quelle devrait être la valeur de n à la fin de notre programme s’il s’exécutait de façon séquentielle ? Que constatez-vous ici ?

Attributs de threads

Comme nous l’avons vu plus haut, lorsque nous créons un thread, il faut passer en argument un pthread_attr_t * à la fonction pthread_create().

Cette structure est initialisée/détuite à l’aide des fonctions

int pthread_attr_init(pthread_attr_t *attr);
int pthread_attr_destroy(pthread_attr_t *attr);

Il existe plusieurs attributs configurables (voir man pthread_attr_init pour une liste exhaustive). A titre d’exemple, nous pouvons configurer l’attribut PTHREAD_CREATE_DETACHED à l’aide de la fonction

int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate);

Cet attribut permet d’éviter à avoir à joindre le thread créé (cela devient impossible de joindre un thread détaché). Le système libère automatiquement les ressources une fois le thread terminé.


Exemple 3 (Thread détaché)

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

void *func() {
    printf("I am a detached thread, although I don't know it.\n");
    return NULL;
}

int main() {
    pthread_attr_t attr;
    pthread_attr_init(&attr);
    pthread_attr_setdetachstate(&attr,PTHREAD_CREATE_DETACHED);
    pthread_t t;
    pthread_create(&t, &attr, func, NULL);
    pthread_join(t, NULL); // cela ne sert à rien...
    pthread_attr_destroy(&attr);
    return EXIT_SUCCESS;
} 

Une autre façon de détacher un thread est d’appeler la fonction

int pthread_detach(pthread_t thread);

Terminaison de threads alternatives

Nous avons vu qu’un thread peut se terminer quand la fonction qu’il exécute retourne. Il existe deux autres façon pour un thread de se terminer.

  1. Il se termine lui même (auto-terminaison) avec la fonction

    void pthread_exit(void *retval);
  2. Un autre thread le termine (annulation) avec la fonction

    int pthread_cancel(pthread_t thread);

L’auto-terminaison

Lors de l’appel à pthread_exit() le thread se termine et retourne la valeur retval (cette donction est appelée implicitement lorsqu’un thread se termine avec return) au thread effectuant la jointure.

Un cas particulier estlorsque le thread principal appelle pthread_exit(). Dans ce cas-là, le programme bloque et attend que tous les threads seterminent avant de terminer le processus.

L’annulation

L’annulation d’un thread est un processus un peu plus complexe. En effet, la fonction

int pthread_cancel(pthread_t thread);

annule le thread thread depuis un autre thread. Cette fonction retourne 0 en cas de succès. Si le thread est déjà terminé, un code d’erreur sera retourné. Bien que cela soit le comportement par défaut, tout thread n’est pas “annulable”. Pour fixer la politique d’annulation d’un thread, il faut qu’il appelle la fonction

int pthread_setcancelstate(int state, int *oldstate);

sate est PTHREAD_CANCEL_ENABLE (valeur par défaut, autorise l’annulation) et PTHREAD_CANCEL_DISABLE (interdit l’annulation).

Comme l’annulation d’un thread peut-être particulièrement dangereuse (le thread appelant pthread_cancel() n’a aucune idée de l’état dans lequel le thread à terminer se trouve), on définit aussi un type d’annulation avec la fonction

int pthread_setcanceltype(int type, int *oldtype);

La valeur de type peut avoir deux valeur différentes

Finalement, un point d’annulation se définit avec la fonction

void pthread_testcancel(void);

Lorsqu’un thread appelle cette fonction, son annulation sera permise aux points où elle est appelée.


Remarque 1

Évidemment, comme rien ne peutetre simple, certains appels système (fopen, write, …) agissent comme des points d’annulation. La liste complète se trouve sur man pthreads.


Autres fonctions

Il existe encore d’autres fonctions potentiellement utiles, mais nous n’allons pas toutes lesvoir en détail. Vous trouverez une liste non-exhaustive ci-dessous.

Les verrous

Le verrou permet de résoudre le problème de non atomicité rencontré ci-dessus. Dans le cas de l’incrémentation de notre entier n, nous avons une section critique : l’opération n = n + 1 peut être interrompue à tout moment par l’exécution d’un autre thread.

Pour protéger les sections critiques de nos codes, le plus simple des mécanismes est l’exclusion mutuelle. Ce mécanisme est représenté à l’aide de mutex-es (mutual exclusion) dans la librairie POSIX. De façon simplifiée, la syntaxe pour protéger notre section critique serait :

pthread_mutex_t lock;
pthread_mutex_lock(&lock);   // acquisition du verrou
n = n + 1;                   // section critique
pthread_mutex_unlock(&lock); // libération du verrou

On définit d’abord notre verrou lock, de type pthread_mutex_t (typiquement c’est une variable globale), puis on acquiert le verrou. À ce moment-là, aucun autre thread ne peut l’acquérir, la section critique est effectuée sur le thread ayant acquis le verrou pendant que les autres attendent. Dès que le verrou est libéré, un autre thread peut l’acquérir, et ainsi de suite.

Afin que votre code marche et soit correct, il manque cependant deux ou trois choses :

  1. Le mutex doit toujours être initialisé :

  2. La vérification du retour lors de l’acquisition du mutex.

  3. Vous devez détruire votre mutex et le mettre dans un état non-initialisé, à l’aide de la fonction

    int rc = pthread_mutex_destroy(&lock);

Exercices : verrous

  1. Ajouter à votre librairie de wrappers une fonction acquérant un verrou et testant son retour.
  2. Ajouter à votre librairie de wrappers une fonction initialisant un verrou et testant son retour.
  3. Ajouter à votre librairie de wrappers une fonction détruisant un verrou et testant son retour.
  4. Réécrire votre code incrémentant une variable n 10’000 fois dans 10 threads en utilisant cette fois un verrou pour protéger la section critique.
  5. Réfléchissez à une expérience vous permettant de mesurer le coût de calcul lié à au verrouillage-déverrouillage d’un verrou dans le cas avec un seul thread, dans le cas où on a plusieurs fils d’exécution. Á présent, mettez-la en œuvre!

Variables de condition

Les variables de condition sont utilisées lorsque nous voulons faire en sorte qu’un signal soit envoyé entre différents fils d’exécution. Il existe deux fonctions principales qui sont utilisées dans ce cas:

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
int pthread_cond_signal(pthread_cond_t *cond);

La fonction pthread_cond_wait() met le thread qui l’appelle dans un état de sommeil: il attend qu’un autre fil d’exécution lui envoie un signal pour le réveiller. On constate que cette fonction prend un pthread_mutex_t en argument. On doit donc avoir acquis un verrou afin de pouvoir l’appeler et on veut modifier l’état du verrou (d’où le pointeur): il est en fait libéré lors de l’appel à pthread_cond_wait() afin de pouvoir être acquis par d’autres threads. De plus lorsque le thread est réveillé, il réacquiert le verrou, et s’assure ainsi être toujours dans la section où l’exclusion mutuelle est respectée.

La fonction pthread_cond_signal() va permettre au thread qui l’appelle de signaler qu’il a effectué une opération qui est digne d’intérêt et que la sieste est (peut-être) terminée. Il est en général important d’acquérir le verrou lorsque vous entrez dans la section critique d’où vous allez effectuer votre signal.

Comme pour le cas du pthread_mutex_t la variable de condition doit être initialisée3:

p_thread_cond_t cond = PTHREAD_COND_INITIALIZER; // initialisation statique
p_thread_cond_t cond;
pthread_cond_init(&cond, NULL); // initialisation dynamique
                                // (ici les attributs sont ceux par défaut)
pthread_cond_destroy(&cond);    // destruction dynamique

L’initialisation dynamique a l’avantage qu’elle effectue la vérification que tout s’est bien passé (ou pas).

L’utilisation de pthread_cond_wait() et pthread_cond_signal() peut être résumée comme suit:

  1. Initialisation de la variable de condition et du verrou.
  2. Thread 1 tentative d’acquisition du verrou:
  3. Thread 2 tentative d’acquisition du verrou:

Exercice: variables de conditions

  1. Ajoutez à votre librairie de wrappers, un wrapper de la fonction pthread_cond_wait() et testant son retour.
  2. Ajoutez à votre librairie de wrappers, un wrapper de la fonction pthread_cond_init() et testant son retour.
  3. Ajoutez à votre librairie de wrappers, un wrapper de la fonction pthread_cond_destroy() et testant son retour.
  4. Ajoutez à votre librairie de wrappers, un wrapper de la fonction pthread_cond_signal() et testant son retour.
  5. Créer un petit programme effectuant les tâches suivantes.
  6. Réécrire le programme ci-dessus, mais en créant 10 threads qui attendent un signal.

Les barrières de synchronisation

Nous avons déjà vu que la fonction pthread_join() permettait d’attendre la fin de l’exécution d’un thread et donc de synchroniser l’exécution d’un code multi-threadé. Il se peut qu’on ne veuille synchroniser qu’un certain nombre des threads sans qu’ils soient pour autant terminés. Pour ce faire, on utilise les barrières de synchronisation. Une barrière de synchronisation fonctionne de la manière suivante:

  1. Le nombre, \(n\), de threads à synchroniser est spécifié lors de la création de la barrière.
  2. Chaque thread notifie son arrivée à la barrière.
  3. Tant que \(n\) threads n’ont pas notifié la barrière, ceux déjà arrivés sont bloqués.
  4. Une fois que tous les threads ont notifié la barrière, celle-ci débloque tous les threads en attente (un par un, dans un ordre indéterminé).
  5. La barrière est ensuite réinitialisée à la valeur spécifiée lors de sa création: la barrière est réutilisable.

La syntaxe des barrières est la suivante:

int pthread_barrier_init(pthread_barrier_t *barrier,
    const pthread_barrierattr_t *attr, unsigned count);  // initialisation
int pthread_barrier_wait(pthread_barrier_t *barrier);    // mise en attente
int pthread_barrier_destroy(pthread_barrier_t *barrier); // destruction des ressources

La fonction pthread_barrier_init() initialise une barrière barrier pour count threads. Le deuxième argument, attr, est un ensemble d’attributs, nous pouvons le laisser à NULL. La notification de l’arrivée de chaque thread se fait à l’arrivée de la fonction pthread_barrier_wait(). Finalement, les ressources de la barrière sont libérées lors de l’appel à la fonction pthread_barrier_destroy(). Cette fonction est bloquante, il faut donc s’assurer qu’aucun thread n’est bloqué à son appel, car le programme pourrait fort bien se retrouver bloqué jusqu’à la fin des temps.

Comme d’habitude ces fonctions renvoient 0 en cas de succès. Il est donc nécessaire de bien vérifier que tout se passe bien lors de l’appel à ces fonctions.

Exercice: barrière de synchronisation

  1. Ajoutez à votre librairie de wrappers, un wrapper de la fonction pthread_barrier_init() et testant son retour.

  2. Ajoutez à votre librairie de wrappers, un wrapper de la fonction pthread_barrier_wait() et testant son retour.

  3. Ajoutez à votre librairie de wrappers, un wrapper de la fonction pthread_barrier_destroy() et testant son retour.

  4. Écrire un petit programme prenant en argument un nombre de threads. Chaque thread fera les actions suivantes:

  5. A l’aide d’un mutex et d’une variable de condition écrivez votre propre barrière rien qu’à vous.


  1. R. H. Arpaci-Dusseau et A. C. Arpaci-Dusseau, Operating Systems: Three Easy Pieces, Arpaci-Dusseau Books, ed. 0.91, (2015).↩︎

  2. Ce code se trouve dans le fichier https://githepia.hesge.ch/orestis.malaspin/cours_prog_conc/blob/master/exemples/intro_api/pthread_create.c .↩︎

  3. Puis détruite dans le cas de l’initialisation dynamique.↩︎