GERBELOTBARILLON.COM

Parce qu'il faut toujours un commencement...

Les threads en C

Généralités sur les threads


Les infrastructures informatiques d'aujourd'hui offrent des composantes multi coeurs et multi processeurs qu'il serait absurde de ne pas exploiter. Sans entrer dans un parallélisme extrême, il existe de nombreuses situations pour lesquelles les mécanismes asynchrones peuvent exploiter à leur avantage ces architectures.

Techniquement, un thread est défini par un ensemble d'instructions qui peut être exécuté indépendamment par le système d'exécution. Cela signifie simplement que, du point de vue du développeur, si le code est correctement structuré, certaines parties du programme pourraient tirer avantage d'être exécutées de manière asynchrone, déplacées sur un ou plusieurs processeurs sans forcer le programme principal à attendre un éventuel retour de ces dernières avant de continuer son exécution. Le résultat serait récupéré uniquement lorsque celui-ci serait disponible, ne bloquant donc pas le programme principal.

Avant tout, rappelons-nous ce qu'est un processus : c'est globalement un ensemble d'informations relatives à l'exécution d'un programme que le système d'exploitation réserve au démarrage de ce programme. L'exécution d'un processus impose une certaine charge en mémoire et processeur pour démarrer et mettre en place les éléments suivants :

Avant l'apparition des threads, il était d'usage de n'avoir que des programmes en version "monolithique" ou exploitant un mécanisme de forking pour dupliquer l'ensemble du code et n'exploiter qu'une petite partie de ce qui a été dupliqué. Le problème de cette méthode est de provoquer un overhead important lors de cette duplication, ainsi que de dupliquer l'ensemble des ressources, ne permettant pas toujours le partage des informations entre les différentes parties dupliquées.

Les threads, à contrario, n'ont pas besoin d'être créés car ils existent déjà au coeur des processus. Ils ne dupliquent que le minimum requis pour leur fonctionnement et existent tant que leur parent existe. Il n'y a pas de processus zombie (errant) dans le système pour lequel traiter spécialement leur recherche et suppression. Ils ne dupliquent que les ressources essentielles pour être planifiées indépendamment et peuvent partager des ressources du processus principal avec d'autres threads.

Dans cette partie nous traiterons des threads UNIX, qui sont devenus la norme POSIX 1003.1c en 1995. Actuellement tous les systèmes disposent d'une compatibilité avec les threads POSIX, en plus des threads qui sont nativement mis à disposition par le système d'exploitation. De par leur structure, les threads POSIX (que nous appellereons PThreads) ont une interface native avec le langage C (et C++ par la même occasion). Tous les exemples seront donc codés en langage C. Nous verrons probablement dans un autre paragraphe le fonctionnement des threads Windows à travers le langage C#.

Intérêt des PThreads


L'intérêt principal de l'usage des PThreads est d'augmenter les performances globales d'un programme. En comparant les différentes méthodes de création et de gestion des processus, un thread peut être créé en requérant beaucoup moins de ressources.

Tous les threads partagent le même espace d'adressage, rendant la communication inter-thread plus efficace que la communication inter-processus.

Différents tests menés par diverses sources montrent que, pour la création de 50 000 processus, et selon l'architecture utilisée, les gains de performances vont de 10% à 150% en utilisant des threads à la place des processus.

De même, si l'on compare les modes d'échange d'informations entre les processus (mémoire partagée impliquant au moins une opération de copie mémoire) et les threads (transfert direct depuis le même espace d'adressage que le processeur), nous obtenons des performances générale améliorées entre 300% et 5000% en faveur des threads.

Design de programmes multithreads


Nous avons déjà annoncé précédemment que les threads tiraient pleinement avantage des architectures nouvelles à base de multiprocesseurs et multicoeurs. Pour cela il faut tout de même que le programme soit pensé pour en tirer partie :

Il y a plusieurs modes de fonctionnement des threads : Tous les threads partagent le même espace mémoire et disposent également de leur environnement de données privées. Ce sont les développeurs qui sont responsables de la protection de l'accès à ces données partagées.

Lorsque l'on parle de données "thread safe", cela signifie que les threads ne vont pas aller modifier les données des autres threads ou créer des conditions de blocage (race conditions). Il est important de bien gérer la synchronisation entre tous les threads, notamment lors de l'usage de bibliothèques n'étant pas elles-mêmes "thread-safe".

L'API PThreads

L'API POSIX PThreads a été définie par l'IEEE en 1995 sous le terme 1003.1. Ce standard continue cependant à évoluer pour prendre en comptees les apports des nouvelles architectures. Les sous-routines sont groupées en quatre groupes :

Compilation


Sans entrer dans l'énumération de tous les compilateurs utilisables sur l'ensemble des systèmes d'exploitation, citons simplement :

Gestion des threads

Fonctions

Création de threads

Au démarrage du programme, il n'y a qu'un seul thread initié par le système d'exploitation. Si vous souhaitez mettre en place d'autres threads, ce sera à vous, développeur, de les implémenter.

La fonction pthread_create() créé un nouveau thread et le rend exécutable. Celle-ci peut être appelée autant de fois que vous le souhaitez. Les arguments de la fonction sont les suivants :

Une fois qu'un thread est créé, il est considéré comme un peer et peut donc créer d'autres threads à son tour. Il n'y a aucune hiérarchie de dépendance entre les threads.

Attributs des threads

Par défaut un thread est créé avec certains attributs, qui pourront être changés par programmation en utilisant les fonctions pthread_attr_init() et pthread_attr_destroy. Les attributs sont parmi :

Terminaison des threads

Il y a plusieurs façon de terminer l'exécution d'un thread :

La fonction pthread_exit() permet au programme de renvoyer une variable de retour aux threads rejoignant le thread qui se termine. Il n'est cependant pas obligatoire d'utiliser pthread_exit() en fin d'exécution de thread, à moins que vous ne vouliez renvoyer spécifiquement une valeur d'état de terminaison. Cela ne ferme toutefois pas les descripteurs de fichiers qui vont rester ouverts une fois le thread terminé. Il faut vous en occuper avant la fin du programme.

Exemple 1 : Création et terminaison de pthread

Dans cet exemple nous allons créer 5 threads qui ne vont faire qu'afficher "Hello world" avec un paramètre en mode console. La conversion 64 bits entre les int et les pointeurs doit s'effectuer avec un type d'entier qui supporte la notation 64 bits. Il est conseillé d'utiliser le type intptr_t prévu à cet effet de conversion de type sous peine d'avoir des messages d'erreur du genre [-Wpointer-to-int-cast] causés par le compilateur GCC.


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

#define NUM_THREADS 5

/*
 * Fonction mise en thread et disposant d'un paramètre sous la forme d'un pointeur sur void
 */
void *print_hello(void *thread_id)
{
  intptr_t tid;
  tid = (intptr_t) thread_id; // Récupère la valeur du paramètre
  printf("Hello world. Je suis le thread numero #%ld\n", tid);
  pthread_exit(NULL);
}


int main(int argc, char *argv[])
{
  pthread_t threads[NUM_THREADS]; // Tableau de 5 identifiants de threads
  int rc;
  intptr_t t; // Pour la conversion 64 bts entre pointeurs (8 octets) et int (4 octets)

  for (t = 0 ; t < NUM_THREADS ; t++)
    {
      printf("Dans main(): creation du thread %ld\n", t);
      /*
       * Création du thread avec la fonction pthread_create()
       * Le premier paramètre est un pointeur qui sera rempli lorsque pthread_create() aura
       * créé la structure du thread.
       * Le second paramètre représente les attributs du thread. Ici, avec NULL, nous utilisons
       * les paramètres par défaut de création d'un thread.
       * Le troisième paramètre est la start_routine, qui sera appelée par le thread. C'est toujours
       * une fonction de type (void *).
       * Le dernier paramètre est le paramètre passé à la fonction du thread. Il doit être forcé
       * du type (void *) également.
       */
      rc = pthread_create(&threads[t], NULL, print_hello, (void *) t);
      if (rc)
	{
	  printf("ERREUR: code de retour de pthread_create() = %d\n", rc);
	  exit(-1);
	}
    }

  /*
   * Pour faire propre, nous attendons que tous les threads aient terminé leur exécution avant
   * de quitter le programme.
   * Cela va éviter d'avoir des ressources non fermées
   */
  for (t = 0 ; t < NUM_THREADS ; t++)
    {
      pthread_join(threads[t], NULL);
    }
  
  pthread_exit(NULL);
}

La fonction pthread_create() permet de passer un seul paramètre à la fonction mise en thread. Pour outrepasser cette limitation, il suffit de passer l'ensemble sous forme d'un pointeur sur une structure C.

Détachement et jonction des threads

Fonctions

Joindre un thread

Joindre un thread correspond à une des façons de synchroniser des threads entre eux. La fonction pthread_join() bloque le thread appelant jusqu'à ce que le thread de numéro thread_id se termine. Il existe deux autres méthodes de synchronisation des threads qui seront discutées ultérieurement : les mutexes et les variables conditionnelles.

Lorsqu'un thread est créé, un de ses attributs définit si oui ou non le thread est synchronisable ou est détachable. Seuls les threads définis comme synchronisables (joignable) peuvent l'être. Si un thread est défini comme détachable, il ne pourra jamais être synchronisé. La norme POSIC précise que tous les threads devraient être définis comme joignables.

Exemple 2 : Synchronisation de pthreads

Dans cet exemple nous allons montrer comment "attendre" la terminaison d'un thread en utilisant la fonction de synchronisation pthread_join(). Puisque certaines implémentations de la bibliothèque PThread pourraient ne pas créer les threads joignables par défaut, nous allons les forcer en tant que telles.


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

#define NUM_THREADS 4

void *do_work(void *t)
{
  int i;
  intptr_t tid;
  double result = 0.0;
  tid = (intptr_t)t;

  printf("Le thread %ld est en cours de démarrage...\n", tid);
  for (i = 0; i < 100000 ; i++)
    {
      result = result + sin(i) * tan(i);
    }
  printf("Terminaison du thread %ld. Le résultat est %e\n", tid, result);

  pthread_exit((void *)t);
}


int main(int argc, char *argv[])
{
  pthread_t threads[NUM_THREADS];
  pthread_attr_t attr;
  int rc;
  intptr_t t;
  void *status;

  pthread_attr_init(&attr);
  pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE);

  for (t = 0; t < NUM_THREADS; t++)
    {
      printf("Main : création du thread %ld\n", t);
      rc = pthread_create(&threads[t], &attr, do_work, (void *)t);
      if (rc)
	{
	  printf("Erreur: code de retour de pthread_create() : %d\n", rc);
	  exit(-1);
	}
    }

  // Libère les attributs et attend les autres threads
  pthread_attr_destroy(&attr);
  for (t = 0; t < NUM_THREADS; t++)
    {
      rc = pthread_join(threads[t], &status);
      if (rc)
	{
	  printf("Erreur : code de retour de pthread_join() : %d\n", rc);
	  exit(-1);
	}
      printf("Main: join terminé avec le thread %ld avec un status de %ld\n", t, (intptr_t)status);
    }

  printf("Main: programme terminé.\n");
  pthread_exit(NULL);
}

Gestion de la pile pour les threads

Fonctions

Gestion de la pile

La norme POSIX n'impose pas de taille particulière pour la pile d'un thread. C'est chaque implémentation de chaque système qui en a la charge. Comme toujours, si l'on dépasse la taille de la pile allouée au thread, vous obtiendrez une terminaison anormale avec ou non corruption des données. Pour garantir le bon fonctionnement d'un thread, il est plus prudent de procéder à l'allocation mémoire requise par le thread via la routine pthread_attr_setstacksize(). Les fonctions de gestion de l'emplacement de la pile ne sont à utiliser que lorsque des systèmes requièrent une gestion particulière de l'adressage mémoire.

Comme toutes les gestions de piles n'offrent pas les mêmes caractéristiques, le tableau ci-après rappelle globalement les éléments pris en compte dans la mise en place des piles de threads.

Architecture# CPUsRAM (Go)Taille par défaut (en octets)
Intel Xeon E5-267016322 097 152
AMD Opteron8162 097 152
Intel IA644833 554 432
IBM Power 5832196 608

Exemple 3 : Gestion de la pile

Dans cet exemple nous allons montrer comment interroger et spécifier la taille de le pile pour un thread.