Tutoriel Linux : programmer avec les POSIX Threads

Nous avons vu dans l’épisode consacré aux processus que le noyau Linux offre deux appels systèmes pour lui demander de créer des processus fils : fork et exec.

C’est une mécanique parfaitement fonctionnelle, mais elle a le défaut d’être lourde à gérer pour le noyau puisqu’à chaque création de processus fils celui-ci doit réaliser des tâches très lourdes qui vont avec, comme faire hériter ce nouveau processus de l’environnement du père, de la table des descripteurs de fichiers du père, des variables du père, etc.

C’est pourquoi on appelle ces processus normaux des processus lourds.

Dans les serveurs notamment, pour lesquels chaque nouvelle connexion d’un client amène à la création d’un processus fils, la montée en charge est synonyme de multiplication de processus lourds qui peuvent mettre à genou la machine.

Pour palier à ces défauts, le monde Unix a spécifié des processus légers, appelés threads, sous une norme POSIX : les POSIX threads (ou pthreads).

Les pthreads sont disponibles sous tous les Unices (pluriel d’Unix) qui les ont implémentés : BSD, MacOS, Solaris, et bien sûr Linux.

Les POSIX threads

Contrairement à un processus lourd fils, un pthread tourne au sein du processus père (qui ne s’appelle plus père en fait, puisqu’il n’a pas de fils).

La mécanique est assez simple : dans le main d’un processus on va lancer autant de pthreads qu’on le souhaite (via l’appel système pthread_create), chacun sur une fonction de traitement qui peut lui être propre. Tous ces pthreads vont tourner en parallèle dans l’unique processus courant : ils vont donc tous avoir accès aux variables globales du processus, ce qui va poser exactement le même problème d’incertitude lié aux accès concurrents que nous avions vus pour lors de l’article sur les sémaphores.

C’est pourquoi l’accès en lecture ou en écriture aux variables globales du processus doit être considéré comme une section critique à protéger par un sémaphore. Ce problème étant un classique, les pthreads viennent avec leur propres sémaphores (les pthreads_mutex_t), beaucoup plus simples à utiliser mais tout aussi efficaces (la prise de jeton se demande avec pthread_mutex_lock, et l’abandon de jeton se fait avec pthread_mutex_unlock). La suppression d’un sémaphore se fait avec pthread_mutex_destroy.

Pour en revenir aux pthreads eux-mêmes, ils font leur travail en parallèle comme on l’a dit, puis ils meurent, soit en faisant un return à la fin de leur fonction de traitement (cas général), soit en appelant pthread_exit (cas particulier quand on veut faire mourir le thread en cours de fonction de traitement et non à la fin, par exemple si l’on rencontre une erreur).

Exemple pratique

Dans notre exercice, le main va lancer 5 threads, avec une initialisation différente passée en argument. Chacun des threads va incrémenter la variable compteur n fois, n valant 3 pour le premier thread, 4 pour le deuxième, 5 pour le troisième, 6 pour le quatrième, et 7 pour le 5 ème. La variable compteur étant initialisée à 0, elle vaudra donc 25 à la fin du programme (25 = 3+4+5+6+7). Chaque incrémentation de cette même variable par un thread donné consistera à demander le sémaphore (qu’on appelle mutex quand le nombre de jetons gérés est 1, ce qui est le cas ici) pour entrer en section critique, puis à faire l’incrémentation, puis à rendre le jeton du sémaphore pour sortir de section critique.

Dis autrement, les incrémentations entre les différents threads se feront dans un ordre aléatoire.

Voici le code source commenté de pthreads_exemple.c :

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

#define NB_THREADS 5

/* Compteur partagé entre tous les threads */
static int compteur = 0;
static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

/* Structure passée à chaque thread */
typedef struct {
    int id;
    int iterations;
} thread_arg_t;

/* Fonction exécutée par chaque thread */
void *travailleur(void *arg)
{
    thread_arg_t *t = (thread_arg_t *)arg;

    printf("[Thread %d] démarrage (itération=%d)\n", t->id, t->iterations);

    for (int i = 0; i < t->iterations; i++) {
        /* Section critique : on protège l'accès au compteur */
        pthread_mutex_lock(&mutex);
        compteur++;
        pthread_mutex_unlock(&mutex);

        /* Petite pause pour simuler un travail */
        usleep(10000);
    }

    printf("[Thread %d] terminé\n", t->id);

    /* On peut retourner une valeur au thread appelant */
    return (void *)(intptr_t)(t->id * 10);
}

int main(void)
{
    pthread_t threads[NB_THREADS];
    thread_arg_t args[NB_THREADS];
    void *ret;

    printf("=== Démarrage du programme principal ===\n");

    /* 1) Création des threads */
    for (int i = 0; i < NB_THREADS; i++) {
        args[i].id = i;
        args[i].iterations = 3 + i;  /* chaque thread a un travail différent */

        int err = pthread_create(&threads[i], NULL, travailleur, &args[i]);
        if (err != 0) {
            perror("pthread_create");
            exit(EXIT_FAILURE);
        }
    }

    /* 2) Attente de la fin de chaque thread (join) */
    for (int i = 0; i < NB_THREADS; i++) {
        pthread_join(threads[i], &ret);
        printf("Thread %d a retourné %ld\n", i, (long)ret);
    }

    /* 3) Bilan */
    printf("\nValeur finale du compteur : %d\n", compteur);
    printf("Valeur attendue          : %d\n", 3 + 4 + 5 + 6 + 7);

    pthread_mutex_destroy(&mutex);
    return 0;
}

On compile ce programme par la commande :

gcc pthreads_exemple.c -o pthreads_exemple

Et une fois de plus la magie se produit 😎

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *