Page suivante Page précédente Table des matières

2. Programmation des threads sous Linux

Par Matteo Dell'Omodarme matt@martine2.difi.unipi.it

2.1 De la théorie ...

Introduction

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.

Qu'est-ce qu'un thread ?

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 :

Atomicité et volatilité

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.

Les verrous

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 :

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.

2.2 ... à la pratique

Le fichier d'entête 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 :

Analysons ces deux étapes en commençant par une brève description des primitives fondamentales du fichier d'entête pthread.h.

Initialisation des verrous

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.

Création des threads

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);

Terminaison dans les règles d'un thread

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.

Passage de données à un thread

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 :

  1. Des informations concernant les variables partagées et les verrous ;
  2. Des informations concernant toutes les données nécessaires au thread ;
  3. Un index d'identification permettant de distinguer les threads et de connaître le nombre de processeurs pour que le programme puisse les exploiter (ces informations seront plus faciles à fournir lors de l'exécution).

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.

Exemple de code parallèle

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&lt;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.


Page suivante Page précédente Table des matières