Par Matteo Dell'Omodarme matt@martine2.difi.unipi.it
Linux dispose d'une bibliothèque destinée à la programmation de
threads (on parle aussi d'activité de contrôle) désignée sous le nom
de LinuxThread. Cette bibliothèque propose une implémentation
des threads au niveau du noyau : les threads sont créés à l'aide
de l'appel système clone()
, l'ordonnancement étant réalisé par
le noyau. La bibliothèque est l'implémentation de l'API (Interface de
Programme d'Applications) POSIX 1003.1c qui fournit les
spécifications des threads. La bibliothèque LinuxThreads est
disponible sur tous les systèmes Linux ayant un noyau 2.0.0 ou plus
récent et la bibliothèque C correspondante.
Un thread est une unité d'exécution d'un programme. La programmation à l'aide de threads est donc une forme de programmation parallèle où plusieurs activités de contrôle s'exécutent simultanément dans un programme.
La programmation à l'aide de threads se différencie de la programmation multi-tâches classique sous UNIX par le fait que tous les threads partagent le même espace mémoire (ainsi que d'autres ressources systèmes tels que les descripteurs de fichiers), tandis que les processus UNIX classiques s'exécutent avec leur propre espace-mémoire. Ainsi, le changement de contexte entre deux threads dans un seul processus nécessite beaucoup moins de ressources qu'entre deux processus.
Deux raisons principales incitent à l'utilisation des threads :
L'accès à la mémoire partagée par les threads nécessite une certaine attention. En effet, votre programme parallèle ne peut pas accéder à des objets en mémoire partagée de la même manière que ceux se trouvant dans une "vulgaire" mémoire locale.
Le concept d'atomicité renvoie à l'idée qu'une opération sur un objet est un séquence indivisible, ne pouvant être interrompue. Les opérations sur les données en mémoire partagée peuvent ne pas être atomiques. De plus, le compilateur GCC effectue souvent des optimisations, plaçant les valeurs de variables partagées dans des registres, évitant ainsi que les opérations sur la mémoire aient besoin de s'assurer que tous les processeurs puissent voir les modifications sur la mémoire partagée.
Pour empêcher GCC d'effectuer des optimisations en ce qui concerne l'utilisation de tampons pour les valeurs des variables partagées, le type de tous les objets en mémoire partagée doit être déclaré avec le qualificatif volatile. Les lectures/écritures d'objets volatiles, pour lesquels les accès sont de la taille d'un mot, sont des opérations atomiques.
Le chargement et la sauvegarde de résultats sont deux opérations
séparées sur la mémoire. L'opération ++i
ne consiste pas
toujours à ajouter la valeur 1 à une variable en mémoire partagée.
Plusieurs processeurs peuvent accéder à la variable i entre
les deux opérations d'addition et d'affectation. Ainsi, si deux
processus effectuent l'opération ++i
en même temps, la
variable i pourra n'être incrémentée que de 1, au lieu de 2.
Un appel système est donc nécessaire afin d'empêcher un thread d'accéder à une variable alors qu'un autre est en train de modifier sa valeur. Ce mécanisme utilise le principe des verrous. Supposons que vous ayez deux threads exécutant une procédure qui change la valeur d'une variable partagée. Pour obtenir un résultat correct, la procédure doit :
i
;
Quand un verrou est appliqué sur une variable, seul le thread qui l'a posé sur la variable peut modifier sa valeur. L'exécution d'un autre thread peut être bloqué sur la pose d'un verrou, puisqu'un seul verrou à la fois est autorisé sur une variable. C'est au moment où le premier thread retire le verrou que le second thread peut continuer son exécution et poser son propre verrou.
En conséquence l'utilisation de variables partagées peut retarder l'exécution d'autres processeurs au niveau de l'accès mémoire, alors que les accès ordinaires peuvent utiliser les mémoires-caches locales.
pthread.h
Les primitives fournis par la bibliothèque LinuxThreads sont
disponibles dans le fichiers d'entêtes /usr/include/pthread.h
.
Il contient la déclaration des prototypes des fonctions permettant de
manipuler les threads.
L'écriture d'un programme utilisant les threads se décompose en deux étapes :
pthread
pour poser des
verrous sur les variables partagées et créer les threads ;
Analysons ces deux étapes en commençant par une brève description des
primitives fondamentales du fichier d'entête pthread.h
.
Une des premières actions que vous devez effectuer est
l'initialisation de tous les verrous. Les verrous POSIX sont déclarés
comme des variables de type pthread_mutex_t
. Pour initialiser
chaque verrou, vous devez appeler la primitive :
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *mutexattr);
Voici un exemple d'utilisation :
#include <pthread.h> ... pthread_mutex_t lock; pthread_mutex_init(&lock,NULL); ...
La fonction pthread_mutex_init
initialise un objet mutex
pointé par mutex
en fonction des attributs définis dans
mutexattr
. Si mutexattr
est NULL
, les
attributs par défaut sont utilisés.
Dans la suite, nous verrons comment utiliser ces verrous.
La spécification POSIX demande à l'utilisateur la déclaration d'une
variable de type pthread_t
qui identifie chaque thread. Un
thread est généré par un appel à la fonction :
int pthread_create(pthread_t *thread, pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);
En cas de succès, l'identifiant du thread venant d'être créé est stocké à
l'adresse pointée par l'argument thread
et la valeur 0 est retournée. En
cas d'erreur, un code d'erreur différent de 0 est retourné.
Voici un exemple de création d'un thread exécutant la fonction
f()
et passant à f()
un pointeur sur la variable
arg
:
#include <pthread.h> ... pthread_t thread; pthread_create(&thread, NULL, f, &arg). ...
La fonction f()
a comme prototype :
void *f(void *arg);
Pour finir, vous devez attendre la terminaison de tous
les threads créés avant d'avoir le résultat de la fonction
f()
. L'appel à la fonction :
int pthread_join(pthread_t th, void **thread_return);
suspendra l'exécution du thread appelant jusqu'à la terminaison du
thread identifié par th
. Si thread_return
n'est pas
NULL
, la valeur de retour de th
est stockée dans la
structure pointée par thread_return
.
Il existe deux manières de passer des informations depuis la fonction appelante vers un thread :
La deuxième possibilité, qui est le meilleur choix, préserve la modularité du code. Les structures doivent contenir trois niveaux d'informations :
Regardons maintenant le premier niveau de la structure. Les
informations passées en argument doivent être partagées par chaque
thread. Vous devez donc utiliser des pointeurs sur les variables et
les verrous nécessaires. Pour passer une variable partagée
var
de type double
ainsi que son verrou, la
structure doit contenir deux membres :
double volatile *var; pthread_mutex_t *var_lock;
Vous noterez l'utilisation de l'attribut volatile
qui
spécifie que la zone-mémoire pointée par var
est volatile et
non le pointeur.
Le calcul du produit scalaire de deux vecteur est un exemple de programme facile à paralléliser lorsqu'on utilise des threads.
Le code commenté est donné ci-dessous.
/* Pour compiler, utiliser gcc <progname> -D_REENTRANT -lpthread */ #include<stdio.h> #include<pthread.h> /* definition d'une structure convenable */ typedef struct { double volatile *p_s; /* la valeur partagee pour le produit scalaire */ pthread_mutex_t *p_s_lock; /* le verrou de la variable s */ int n; /* le numero du thread */ int nproc; /* le nombre de processeurs a utiliser */ double *x; /* les valeurs pour le premier vecteur */ double *y; /* les valeurs pour le second vecteur */ int l; /* la longueur des vecteurs */ } DATA; void *SMP_scalprod(void *arg) { register double localsum; long i; DATA D = *(DATA *)arg; localsum = 0.0; /* Chaque thread commence par calculer le produit scalaire a partir de i = D.n avec D.n = 1, 2, ... , D.nproc. Puisqu'il y a exactement D.nproc threads, on incremente i de D.nproc */ for(i=D.n;i<D.l;i+=D.nproc) localsum += D.x[i]*D.y[i]; /* le thread pose le verrou sur s ... */ pthread_mutex_lock(D.p_s_lock); /* ... change la valeur de s ... */ *(D.p_s) += localsum; /* ... et retire le verrou */ pthread_mutex_unlock(D.p_s_lock); return NULL; } #define L 9 /* dimension des vecteurs */ int main(int argc, char **argv) { pthread_t *thread; void *retval; int cpu, i; DATA *A; volatile double s=0; /* la variable partagee */ pthread_mutex_t s_lock; double x[L], y[L]; if(argc != 2) { printf("usage: %s <nombre de processeurs>\n", argv[0]); exit(1); } cpu = atoi(argv[1]); thread = (pthread_t *) calloc(cpu, sizeof(pthread_t)); A = (DATA *)calloc(cpu, sizeof(DATA)); for(i=0;i<L;i++) x[i] = y[i] = i; /* initialisation de la variable correspondant aux verrous */ pthread_mutex_init(&s_lock, NULL); for(i=0;i<cpu;i++) { /* initialisation de la structure */ A[i].n = i; /* le numero du thread */ A[i].x = x; A[i].y = y; A[i].l = L; A[i].nproc = cpu; /* le nombre de processeurs */ A[i].p_s = &s; A[i].p_s_lock = &s_lock; if(pthread_create(&thread[i], NULL, SMP_scalprod, &A[i] )) { fprintf(stderr, "%s: creation du thread impossible\n", argv[0]); exit(1); } } for(i=0;i<cpu;i++) { if(pthread_join(thread[i], &retval)) { fprintf(stderr, "%s: synchronisation sur le thread impossible\n", argv[0]); exit(1);x } } printf("s = %f\n", s); exit(0); }
Copyright 1999, Matteo Dell'Omodarme. Paru dans le numéro 48 de la Linux Gazette de décembre 1999.
Traduction française de Thierry Hamon.