Outils personnels
Vous êtes ici : Accueil C & C++ Programmation système Les threads Les threads

Les threads

Par Eric Salice - Dernière modification 18/04/2008 22:40
Contributeurs : Benjamin Poulain

Dans certaines applications, il est utile que plusieurs fonctions s'exécutent en parallèle et partagent les mêmes données ou ressources (des variables globales ou statiques de fonctions, des fichiers, ...) pour les consulter et les modifier. On dit alors que l'application est constituée de plusieurs fils d'exécution (fil est aussi la traduction littérale de "thread").

Prenons par exemple, une application serveur de bases de données qui, à chaque requête d'un client peut lancer une fonction chargée d'exécuter la requête et de communiquer les résultats à l'application cliente. Plusieurs clients pouvant se connecter simultanément, plusieurs instances de la fonction seraient donc lancées "en parallèle" et celles-ci pourraient accéder à des données issues des bases de données et chargées en mémoire pour des raisons de performances.

L'intérêt de fonctions d'un même programme s'exécutant simultanément apparaît également dans des programmes tels que les navigateurs internet qui poursuivent le chargement d'une page alors que l'utilisateur peut en faire défiler le contenu déjà chargé. Ou encore dans un programme de messagerie qui permet à l'utilisateur de rédiger un mail alors qu'en arrière plan il télécharge les messages d'un serveur. Ou dans un traitement de texte qui imprime un document tandis que l'utilisateur continue à le modifier, ... Une application de simulation peut également tirer profit de cette fonctionnalité en implémentant les objets de la simulation par des fonctions, l'environnement commun dans lequel évoluent ces objets étant représenté par les ressources partagées ...

La décomposition d'une application en plusieurs processus n'est pas toujours chose aisée du fait que les différents processus créés par un processus père ne partagent pas leurs données mais disposent chacun d'une copie de celles-ci. Nous verrons plus loin différents mécanismes de communication entre processus dont le partage de zones de mémoire mais même celui-ci n'offre pas l'avantage de simplicité d'un partage direct de données telles que des variables globales entre des fonctions d'un même processus.

En programmation C ANSI, on peut simuler le parallélisme d'exécution en utilisant une fonction qui fait office d'ordonnanceur et lance à tour de rôle les différentes fonctions qui doivent s'exécuter en parallèle. Ces fonctions doivent être écrites de sorte à rendre régulièrement la main à la fonction d'ordonnancement pour permettre aux autres de s'exécuter. Pour mettre en oeuvre cette technique il faut prévoir des retours fréquents dans ces fonctions pour donner l'impression d'un parallélisme d'exécution et aussi un mécanisme de sauvegarde de l'état d'exécution des fonction avant qu'elles ne rendent la main à la fonction d'ordonnancement pour que chacune d'elles puisse reprendre son exécution là où elle l'avait abandonnée, quand c'est à nouveau à son tour de s'exécuter. Une autre façon de procéder est de découper les traitements à exécuter en parallèle en très courtes fonctions qui sont appelées par la fonction d'ordonnancement, dans le bon ordre, en alternant les fonctions des différents traitements. De telles techniques compliquent la programmation et rendent le code peu lisible.

Le concept de thread (qui signifie fil en anglais) offre une solution plus élégante à tous ces problèmes en permettant l'exécution concurrente de fonctions d'un même processus sans que le programmeur ne doivent se préoccuper de l'ordonnancement de ces fonctions.

Il existe différentes façons d'implémenter le concept de thread sur les systèmes de type UNIX dont Linux et de nombreuses bibliothèques sont disponibles. On distingue en particulier les implémentations des threads en mode utilisateur et en mode noyau.

Dans le mode utilisateur, l'ordonnancement des threads est géré par l'application (par une fonction de la bibliothèque de threading, le programmeur ne doit donc pas s'en soucier). Le noyau n'est alors pas conscient de l'existence des threads. Ces implémentations sont rendues possibles grâce, par exemple, aux fonctions getcontext(), makecontext(), swapcontext() des spécifications SUSv2 et dans ce cas sont portables sur les systèmes qui s'y conforment.

En mode noyau, les threads se présentent comme des processus qui après création partagent l'espace d'adressage de leur père (même table des pages). La pile de chaque thread est cependant placée à un endroit différent de l'espace d'adressage commun pour éviter l'écrasement de variables locales lors d'appels de fonctions par les différents threads d'un processus. Un thread occupe donc une entrée dans la table des processus. En fait, cette table est plutôt une table des threads car il est plus correct de voir un processus comme un ensemble de threads (un processus étant donc représenté par plusieurs entrées dans la table des threads du noyau) partageant un espace d'adressage que de dire qu'un thread est un processus, même si on appelle parfois les threads des processus légers par opposition aux processus usuels dénommés processus lourds. Un processus comporte toujours au moins un thread, son thread principal, qui est exécuté au moment de la création du processus. Sauf dans quelques situations particulières, le comportement du thread principal ne se distingue pas de celui des autres threads de l'application.

Les avantages de l'implémentation des threads en mode noyau sont:

  • La possibilité de définir des priorités d'exécution par thread indépendamment de celles du processus créateur.
  • L'exécution possible de threads d'un même processus sur des processeurs différents.

Les avantages des threads en mode utilisateur sont:

  • La portabilité de l'implémentation sur les systèmes conformes à SUSv3 si elle utilise des fonctions de ces spécifications.
  • Le faible coût en ressource de création et commutation des threads.

Pour plus de détails sur le sujet: http://www.gnu.org/software/pth/related.html et http://www.gnu.org/software/pth/pth-manual.html.

Les threads POSIX 1c

Les fonctions de manipulation de threads que nous allons décrire ici appartiennent aux spécifications SUSv3 qui a repris la norme POSIX. 1c définissant une interface de multithreading portable pour les systèmes UNIX. On utilise d'ailleurs le terme pthread (avec 'p' comme POSIX) pour désigner bibliothèques de thread qui se conforment à ces spécifications.

La Native Posix Threading Library (NTPL)

Sous Linux, la librairie de threads la plus utilisée est la NPTL (Native Posix Threading Library). Elle se conforme en grande partie à SUSv3. Elle utilise un appel système spécifique à Linux qui permet la création de threads en mode noyau.

L'option pthread permet d'indiquer au compilateur gcc que l'on souhaite utiliser cette librairie: gcc -pthread prog.c, néanmoins, pour des questions de portabilité, il est conseillé d'inclure la bibliothèque de thread comme une autre : gcc -lpthread.

Les types, macros, fonctions, ... dont il sera question ci-après sont définis dans le fichier d'en-tête <pthread.h>.

La création et l'utilisation des threads

La fonction permettant de créer un thread est pthread_create().

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


  • thread est l'adresse un objet pthread_t (un entier en général mais SUSv3 n'impose pas le type). La fonction y placera l'identificateur du thread créé. Cet objet servira à désigner le thread dans les opération de manipulation de threads.
  • attr est un objet pthread_attr_t qui permet de fixer certaines propriétés du thread. Pour utiliser les attributs par défaut, on peut utiliser un pointeur NULL.
  • start_routine est la fonction qu'exécutera le thread à sa création.
  • arg est l'argument qui sera passé à start_routine.

Si la fonction réussit elle retourne 0. En cas d'échec, une valeur non nulle est retournée qui décrit le type d'erreur. En particulier la valeur EAGAIN est retourné si le système manque de ressources pour créer un nouveau thread ou si la limite du nombre de threads autorisés pour un processus (PTHREAD_THREADS_MAX) est déjà atteinte.

La fonction pthread_create() peut être appelée par n'importe quel thread d'un processus et pas nécessairement par le thread principal.


Un thread se termine au retour de la fonction qu'il exécute à sa création (parce qu'on est arrivé à la fin ou suite à un return). On peut également terminer un thread par la fonction pthread_exit() qui peut-être appelée dans n'importe quelle fonction invoquée à partir de la fonction exécutée à la création du thread.

void pthread_exit(void *retval);

Le paramètre de la fonction pthread_exit() sera la valeur de retour du thread. La terminaison d'un thread par un return dans la fonction exécutée à sa création correspond à un appel implicite à pthread_exit() avec en argument la valeur indiquée après return.

Attention, l'invocation des fonctions exit() ou _exit() à partir d'un thread termine le processus entier, c'est-à-dire tous ses threads.

Si le thread principal invoque la fonction pthread_exit(), il se termine mais si le processus possède d'autres threads, ceux-ci poursuivent leur exécution et le processus se termine quand le dernier thread se termine ou si l'un des threads appelle exit() ou _exit().


De manière analogue aux processus, et sauf si on a fixé un attribut du thread qui modifie ce comportement (voir plus loin), les ressources utilisées par un thread ne sont libérées que lorsque sa valeur de retour est lue par un autre thread. La fonction qui permet de récupérer cette valeur de retour est la fonction pthread_join().

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

Cette fonction se met en attente du thread spécifié par le paramètre th si celui-ci n'est pas terminé. Quand celui-ci se termine ou s'il est déjà terminé au moment de l'invocation de la fonction, si l'appel réussit, la fonction retourne 0 et la valeur de retour du thread th est enregistrée à l'emplacement pointé par value_ptr. On peut utiliser un pointeur NULL si on ne souhaite pas récupérer cette valeur mais simplement attendre la terminaison du thread.

En cas d'erreur, pthread_join() retourne une valeur non nulle qui peut être

  • ESRCH: th n'est pas l'identificateur d'un thread du processus.
  • EINVAL: un autre thread est en train d'exécuter pthread_join() pour le thread th ou le thread th est détaché (voir plus loin).
  • EDEADLK: th est l'identificateur du thread qui effectue l'appel à pthread_join().

Comme on le voit, il ne peut y avoir qu'un thread en attente de la fin d'un thread donné mais n'importe quel thread d'un processus peut attendre la fin de n'importe quel autre thread du processus (y compris du thread principal) contrairement aux processus où seul le père peut attendre la fin d'un de ses fils.

La fonction pthread_join() permet de libérer les ressources du thread faisant l'objet de l'appel. Cette fonction dois donc toujours être appelée pour les threads qui ne sont pas détaché (voir plus loin).

Exemple

Le programme suivant crée 5 threads qui incrémentent un million de fois un compteur global initialisé à 0. Chaque thread affiche la valeur du compteur à la fin de son exécution et retourne cette valeur.

Le thread principal attend la terminaison des threads et affiche leur valeur de retour.

On a incrémenté le compteur en réalisant l'opération

compteur = compteur + (compteur + 3) % (compteur + 2);

L'expression (compteur + 3) % (compteur + 2) a toujours la valeur 1. Nous avons utilisé cette technique pour illustrer les problèmes qui peuvent survenir lorsqu'on modifie une donnée partagée à partir de différents threads.

On a également effectué un appel à la fonction usleep() pour permettre à un autre thread de s'exécuter et donc de modifier le compteur entre l'affichage de celui-ci et le renvoi de sa valeur via pthread_exit().

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

#define NB_THREAD 5

void *fn_thread(void *arg);

int compteur = 0;

int main()
{
unsigned int i, j;
pthread_t threads[NB_THREAD];
int threadnb[NB_THREAD];
void *res;

for ( i = 0; i < NB_THREAD; ++i ){
threadnb[i] = i;
if ( pthread_create(&threads[i], NULL, fn_thread, (void*)&threadnb[i]) ){
fprintf(stderr, "Erreur lors de pthread_create\n");
break;
}
}

for ( j = 0 ; j < i; ++j ){
pthread_join(threads[j], &res);
printf("Valeur retournée par le thread n° %d: %d\n", j, (int)res);
}
printf("Valeur finale du compteur: %d\n", compteur);
return EXIT_SUCCESS;
}

void *fn_thread(void *arg)
{
int i;
int no_thread = *(int*)arg;

for ( i = 0; i < 1000000; ++i ){
compteur = compteur + (compteur + 3) % (compteur + 2);
}

printf("Valeur du compteur à la fin du thread n° %d: %d\n",
no_thread, compteur);
usleep(1);
pthread_exit((void *)compteur);
}

Ce programme produit les résultats suivants qui peuvent varier d'une exécution à l'autre. Une exécution a, par exemple, donné le résultat suivant:

Valeur du compteur à la fin du thread n° 0: 1809490
Valeur du compteur à la fin du thread n° 2: 2809490
Valeur retournée par le thread n° 0: 3701083
Valeur du compteur à la fin du thread n° 3: 3809490
Valeur du compteur à la fin du thread n° 1: 5874615
Valeur retournée par le thread n° 1: 6386654
Valeur retournée par le thread n° 2: 3701083
Valeur retournée par le thread n° 3: 3809490
Valeur du compteur à la fin du thread n° 4: 6874615
Valeur retournée par le thread n° 4: 6874615
Valeur finale du compteur: 6874615

On observe que la variable compteur n'a pas été incrémentée 5000000 fois comme on pouvait s'y attendre mais 6874615 fois. Ceci résulte du fait que l'instruction compteur = compteur + (compteur + 3) % (compteur + 2); peut être interrompue par l'exécution d'un autre thread qui modifie la valeur du compteur.

Imaginons que le compteur possède la valeur v au moment de l'exécution de l'instruction, que celle-ci soit interrompue alors que compteur + 2 a déjà été évalué à la valeur v + 2, mémorisée dans un registre du processeur, par exemple, et qu'un autre thread incrémente la valeur du compteur à v + 1. Si le premier thread reprend à ce moment l'évaluation de l'expression, compteur + 3 prend la valeur v + 1 + 3 = v + 4 et (compteur + 3) % (compteur + 2) donne la valeur (v + 4) % v + 2 = 2 si v est différent de 0. Autrement dit, le compteur est incrémenté de deux unités et non d'une seule.

Cette situation pourrait aussi se produire, mais beaucoup plus rarement, si on avait incrémenté le compteur en utilisant une instruction compteur++. En effet, l'incrémentation d'une variable peut s'effectuer en chargeant la valeur de la variable dans un registre du processeur. Le registre est incrémenté puis sa valeur est écrite en mémoire à l'emplacement de la variable. Un thread peut très bien être interrompu pendant cette suite d'opérations et les modifications effectuées sur la variable par un autre thread pendant cette interruption seront écrasées par la valeur du registre quand le premier thread reprendra son exécution. Dans ces circonstances, on perdra des incrémentations et la valeur finale du compteur sera inférieure à celle attendue.

On observe aussi que la valeur du compteur affichée à la fin des threads peut différer de la valeur retournée. D'autres threads peuvent en effet avoir modifié cette valeur entre son affichage et son renvoi. L'appel à usleep() favorise la manifestation de ce phénomène.

Remarquons également le passage du numéro du thread, un entier, via le dernier paramètre de la fonction pthread_create et le retour de la valeur du compteur également de type entier, via pthread_exit() avec transtypage vers le type void *. Ceci est toléré dans la mesure où le type entier et pointeur void ont la même taille sur une architecture 32 bits.

Les threads détachés

Il est possible de récupérer les ressources d'un thread dès sa terminaison sans qu'il soit nécessaire qu'un autre thread effectue un appel à pthread_join() en plaçant le thread dans un état "détaché", ce qui peut se faire en invoquant la fonction pthread_detach().

int pthread_detach(pthread_t th);

Cette fonction détache le thread th, les ressources de celui-ci sont libérées dès sa terminaison et sa valeur de retour est perdue. Elle renvoie 0 si elle réussit ESRCH si th n'est pas l'identificateur d'un thread du processus et EINVAL si le thread th est déjà détaché.

Un appel à la fontion pthread_join() pour un thread détaché retourne la valeur EINVAL.

La fonction pthread_detach() peut être appelée par n'importe quel thread du processus.


On peut également créer un thread dans l'état détaché par une valeur appropriée du paramètre attr dans la fonction pthread_create().

Pour ce faire, il faut tout d'abord déclarer un objet de type pthread_attr_t, puis initialiser cet objet grâce à la fonction pthread_attr_init:

int pthread_attr_init(pthread_attr_t *attr);

Ensuite fixer la valeur de l'attribut correspondant au détachement du thread par l'appel à la fonction pthread_setdetachstate() suivant.

pthread_attr_setdetachstate(obj_attr, PTHREAD_CREATE_DETACHED);

Une fois qu'on a utilisé l'objet pthread_attr_t pour créer un ou plusieurs threads détachés, on effectue un appel à la fonction

int pthread_attr_destroy(pthread_attr_t *attr);

pour libérer les ressources utilisées par cet objet.

Les fonctions pthread_attr_setdetachstate() et pthread_attr_destroy() retournent 0 si elles réussissent et, entre autres, EINVAL, si l'objet pthread_attr_t qui leur est passé comme paramètre n'est pas initialisé.

Les mutex

Comme on l'a vu dans l'un des exemples précédents la modification par plusieurs threads d'une donnée partagée peut conduire à des valeurs finales différentes des valeur attendues selon l'ordre d'exécution, non prévisible, de ces modifications.

Il est donc nécessaire de disposer de mécanismes permettant de se prémunir contre ce "non-déterminisme" en empêchant l'exécution concurrente de portions de code qui modifient une même ressource partagée. De telles portions de code sont appelées "critiques".

Les mutex constituent un de ces mécanismes. Le terme mutex vient des mots "MUTal EXclusion" (exclusion mutuelle). Un mutex permet qu'un thread qui exécute une portion de code qui modifie des données partagées bloque l'accès d'autres threads à des portions de codes qui modifient ces mêmes données. On peut également les utiliser pour synchroniser des threads.

Un mutex est un objet du type pthread_mutex_t qui peut être vu comme un verrou possédant deux états: déverrouillé (ou disponible) et verrouillé.

Il existe une fonction permettant de verrouiller un mutex:

int pthread_mutex_lock(pthread_mutex_t *mutex);

et une autre permettant de déverouiller:

int pthread_mutex_unlock(pthread_mutex_t *mutex);

Lorsqu'un thread tente de verrouiller un mutex qui l'a déjà été par un autre thread, il est bloqué tant que ce mutex n'est pas déverouillé (en général par le thread qui l'a verrouillé). Plusieurs threads peuvent être bloqués en attente du déverrouillage d'un même mutex. Lorsque le mutex est déverrouillé, l'un des threads en attente est débloqué et verrouille le mutex à son tour. Le thread qui est débloqué n'est pas forcément le premier à avoir demandé à verrouiller le mutex, il peut en effet s'agir d'un thread plus prioritaire (voir plus tard l'ordonnancement des processus).

Lorsqu'un thread a verrouillé un mutex, on dit qu'il le détient, le possède ou qu'il l'a acquis. Lorsqu'un thread déverrouille un mutex on dit aussi qu'il le libère ou le relâche.


Avant de pouvoir utiliser un mutex, il faut l'initialiser. Ceci peut s'effectuer au moyen de la macro PTHREAD_MUTEX_INITIALIZER au moment de la définition du mutex:

pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;

Le mutex est alors initialisé avec une valeur qui lui confère un comportement "par défaut" qui convient pour la plupart des applications. Un mutex initialisé de cette façon est appelé "mutex rapide". Il est également possible d'initialiser un mutex et de définir certains de ses attributs via la fonction pthread_mutex_init(). Ceci ne sera pas détaillé ici.

Afin de libérer les ressources utilisées par un mutex, on utilise la fonction pthrad_mutex_destroy:

int pthread_mutex_destroy(pthread_mutex_t *mutex);

Pour les mutex rapides, la valeur de retour des fonctions précédentes est 0 en cas de succès, EINVAL si le mutex n'est pas initialisé et EBUSY pour pthread_mutex_destroy() si le mutex est verrouillé.

La fonction pthread_mutex_trylock() permet de verifier la disponibilité d'un mutex et de le verrouiller le cas échéant en renvoyant 0 ou la valeur EBUSY si le mutex est verrouillé. Cette fonction retourne EINVAL si le mutex n'est pas initialisé:

int pthread_mutex_trylock(pthread_mutex_t *mutex);

Remarques

  • Si un thread demande le verrouillage d'un mutex rapide qu'il détient déjà, ce thread est définitivement bloqué.
  • Un mutex rapide peut être déverrouillé par un thread autre que celui qui l'a verrouillé.

Pour éviter les situations décrites dans les remarques précédentes, on peut initialiser un mutex avec la macro PTHREAD_ERRORCHECK_MUTEX_INITIALIZER_NP définie par la glibc (NP indiquant une extension de SUSv3 Non Portable).

Un tel mutex s'appelle "à vérification d'erreur". Un appel à la fonction pthread_mutex_lock() pour un tel mutex retournera immédiatement la valeur EDEADLK si le thread appelant détient déjà ce mutex. Un appel à la fonction pthread_mutex_unlock() retournera immédiatement la valeur EPERM si le thread appelant ne détient pas ce mutex. On peut également définir un mutex "à vérification d'erreur" de façon portable en utilisant les fonctions pthread_mutex_init() et pthread_mutexattr_settype() définies par SUSv3 qui ne seront pas détaillées ici.

Exemple

Reprenons le programme de l'exemple précédent mais protégeons le corps de la boucle de la fonction de thread par un mutex.

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

#define NB_THREAD 5

pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;

void *fn_thread(void *arg);

int compteur = 0;

int main()
{
int i, j;
pthread_t threads[NB_THREAD];
int threadnb[NB_THREAD];
void *res;

for ( i = 0; i < NB_THREAD; ++i ){
threadnb[i] = i;
if ( pthread_create(&threads[i], NULL, fn_thread, (void*)&threadnb[i]) )
{
fprintf(stderr, "Erreur lors de pthread_create\n");
break;
}
}

for ( j = 0 ; j < i; ++j )
{
pthread_join(threads[j], &res);
printf("Valeur retournée par le thread n° %d: %d\n", j, (int)res);
}
printf("Valeur finale du compteur: %d\n", compteur);
return EXIT_SUCCESS;
}

void *fn_thread(void *arg)
{
int i;
int no_thread = *(int*)arg;

for ( i = 0; i < 10000; ++i )
{
pthread_mutex_lock(&mutex);
compteur = compteur + (compteur + 3) % (compteur + 2);
pthread_mutex_unlock(&mutex);
}

printf("Valeur du compteur à la fin du thread n° %d: %d\n", no_thread,
compteur);
usleep(1);
pthread_exit((void *)compteur);
}

On obtient le résultat suivant où l'on voit que le compteur a bien été incrémenté le nombre de fois escompté.

Valeur du compteur à la fin du thread n° 1: 1716831
Valeur du compteur à la fin du thread n° 4: 3554565
Valeur du compteur à la fin du thread n° 0: 3799577
Valeur retournée par le thread n° 0: 3837842
Valeur retournée par le thread n° 1: 1728824
Valeur du compteur à la fin du thread n° 3: 4149794
Valeur du compteur à la fin du thread n° 2: 5000000
Valeur retournée par le thread n° 2: 5000000
Valeur retournée par le thread n° 3: 4252397
Valeur retournée par le thread n° 4: 3574609
Valeur finale du compteur: 5000000

L'interblocage

La principale difficulté lors de l'utilisation des mutex est d'éviter les interblocages (deadlocks) qui peuvent se produire, par exemple, si un thread tente d'acquérir un mutex détenu par un autre thread lui-même en attente d'un mutex détenu par le premier thread. Dans cette situation, les deux threads sont définitivement bloqués.

Exemple

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

#define NB_THREAD 5


pthread_mutex_t mutex1=PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mutex2=PTHREAD_MUTEX_INITIALIZER;

void *fn_thread(void *arg);

int main()
{
pthread_t thread;

pthread_mutex_lock(&mutex1);
if ( pthread_create(&thread, NULL, fn_thread, NULL) )
{
fprintf(stderr, "Erreur lors de pthread_create\n");
return EXIT_FAILURE;
}
sleep(1); //Permet d'attendre le blocage de mutex2 par le thread
pthread_mutex_lock(&mutex2);
pthread_mutex_unlock(&mutex2);
pthread_mutex_unlock(&mutex1);
return EXIT_SUCCESS;
}

void *fn_thread(void *arg)
{
pthread_mutex_lock(&mutex2);
pthread_mutex_lock(&mutex1);
pthread_mutex_unlock(&mutex1);
pthread_mutex_unlock(&mutex2);
pthread_exit((void *)0);
}

Les sémaphores Posix. 1b

Les sémaphores Posix. 1b constituent un autre mécanisme permettant d'empêcher des threads d'exécuter simultanément des portions de code critiques. Leur fonctionnement est proche de celui des mutex mais ils proposent davantage de fonctionnalités.

Un sémaphore se présente comme un compteur possédant une valeur positive ou nulle qui peut être incrémenté et décrémenté de façon "atomique", c'est-à-dire sans que ces deux opérations ne puissent être interrompues par l'exécution d'un autre thread du processus.

De manière analogue au verrouillage d'un mutex, la décrémentation d'un sémaphore est bloquante si celui-ci n'a pas une valeur nulle.

Les objets sémaphores sont du type sem_t déclaré dans <semaphore.h>.

Les fonctions qui permettent de les manipuler sont:

#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int sval);
int sem_destroy(sem_t * sem);int sem_post(sem_t * sem);
int sem_wait(sem_t * sem);
int sem_trywait(sem_t * sem);
int sem_getvalue(sem_t * sem, int * sval);

Toutes ces fonctions retournent 0 en cas de succès et -1 en cas d'échec en fixant la valeur de errno.

La fonction sem_init() permet de créer un objet sémaphore et d'initialiser sa valeur (paramètre sval). Le paramètre pshared, s'il a une valeur non nulle permet le partage du sémaphore par plusieurs processus. Cette fonctionnalité des sémaphores ne sera pas décrite ici où nous utiliserons une valeur nulle pour créer des sémaphores locaux aux processus (uniquement partagés par les threads d'un processus). En cas d'échec, errno peut prendre les valeurs:

  • EINVAL: La valeur initiale est supérieure à la valeur maximale autorisée d'un sémaphore, SEM_VALUE_MAX définie dans <semaphore.h>.
  • ENOSPC: Le nombre de sémaphore maximum autorisé pour le processus est déjà atteint .

Les autres fonctions retournent EINVAL si le sémaphore qui leur est passé comme paramètre n'est pas initialisé.


La fonction sem_destroy() permet de libérer les ressources utilisées par un sémaphore. En cas d'échec, errno prendra la valeur EBUSY si un thread est bloqué dans une opération de décrémentation du sémaphore.

La fonction sem_post() incrémente le sémaphore. En cas d'échec, errno prendra la valeur ERANGE si la valeur du sémaphore avait dépassé SEM_VALUE_MAX après incrémentation.

La fonction sem_wait() décrémente le sémaphore. Elle est bloquante si celui-ci a une valeur nulle. En cas d'échec, errno prendra la valeur EDEADLK si un interblocage est détecté.

La fonction sem_trywait() décrémente le sémaphore en retournant 0 s'il a une valeur non nulle et retourne immédiatement -1 sans le décrémenter sinon, en fixant errno à la valeur EBUSY.

La fonction sem_getvalue() permet d'obtenir la valeur courante d'un sémaphore dans l'entier dont l'adresse est passée via le paramètre sval

Remarques

  • Contrairement aux mutex, les sémaphores permettent à plusieurs threads d'accéder en même temps à une portion de code critique. Si la valeur courante d'un sémaphore est n, jusqu'à n appels à sem_wait() pourront être effectués sur ce sémaphore sans être bloqués (si entre-temps aucun appel à sem_post() n'a eu lieu). Ceci peut s'avérer utile pour gérer l'accès à des ressources en nombre limité. Par exemple, un serveur vocal permettant de recevoir et envoyer des appels de manière automatisée et disposant d'un nombre fixé de lignes téléphoniques peut utiliser un sémaphore pour gérer l'accès de threads réalisant des appels automatiques à ces lignes.
  • Un thread peut réaliser un appel à sem_post() même s'il n'a pas effectué d'appel à sem_wait() sur le même sémaphore précédemment.
  • Suite à un appel à sem_post(), la valeur d'un sémaphore peut dépasser sa valeur initiale.

Exemple

Le programme suivant crée 10 threads qui exécutent une fonction dont le corps est protégée par un sémaphore initialisé à 3 dans le thread principal et qui en empêche donc l'exécution par plus de 3 threads en même temps. La portion protégée par le sémaphore affiche sa valeur, endort le thread pendant une seconde puis réaffiche la valeur du sémaphore. Les valeurs affichées pourront parfois sembler incohérentes car un thread peut être interrompu entre le moment où il décrémente un sémaphore et le moment où il affiche sa valeur par un autre thread qui effectue une incrémentation ou une décrémentation. Pour forcer la manifestation de ce phénomène, on a placé un appel à usleep() entre l'acquisition du sémaphore et l'affichage de sa valeur.

L'appel à sleep() au début de la fonction de thread laisse le temps au thread principal de créer tous les threads avant que ceux-ci ne tentent d'accéder à la portion critique.

On notera au passage l'utilisation de threads détachés et la terminaison du thread principal avant celles des autres threads.

Actions sur le document