L'art de gérer les signaux

Posted by David Demelier on Wed 02 March 2022

Je ne sais pas si vous êtes déjà au courant, mais les signaux UNIX font parti des plus vilaines erreurs informatiques. Source de confusion et d'erreurs c'est un casse tête habituel des néophytes.

Dans la plupart de mes applications je laissais les signaux par défaut car j'avais pas spécialement de traitement spécifique à faire. Dans mon travail actuel je développe une application qui utilise plusieurs threads et je dois aussi gérer des signaux pour arrêter certains périphériques physiques lorsque mon application s'arrête.

Mélanger signaux et threads est souvent aussi risqué qu'allumer une cigarette à la station service.

Le problème principal

Un signal est par définition appelé à n'importe quel moment et sur n'importe quel thread de l'application. Une approche simpliste qu'on pourrait se dire est : « et si je faisais une pile d'évènements dans mon handler ? ». Cependant, comme l'application est multithread, il va falloir protéger l'accès à la pile d'évènements dans mon signal.

En pseudo code imaginons :

static pthread_mutex_t mutex;
static EventList list;

static void add(int type)
{
    pthread_mutex_lock(&mutex);
    event_append(&list, type);
    pthread_mutex_unlock(&mutex);
}

static void handler(int signum)
{
    // Captation d'un signal USR1 pour recharger l'application.
    if (signum == SIGUSR1)
        add(EVENT_RELOAD);
}

Cette fonction add pourrait être appelé par d'autres threads et ensuite parcourue par la boucle principale du programme. Ainsi chaque thread protège l'accès à la liste global ainsi que le signal. Il s'agit là pourtant d'une très mauvaise idée. En effet, un signal peut arriver à n'importe quel moment, y compris dans une situation critique. Cela signifie que la fonction handler peut être appelée exactement au moment où le verrou est déjà verrouillé par le thread courant !

signal

Bloquer les signaux

Les fonctions de l'API POSIX nous permettent de bloquer des signaux afin de ne pas les recevoir de manière asynchrone pour le processus ou pour chaque thread. Il nous reste alors deux solutions plutôt communes :

  • Créer un thread qui capte tous les signaux.
  • Bloquer tous les signaux sauf dans le thread principal et les débloquer à un moment précis en utilisant pselect ou ppoll.

Mon application étant basée sur poll nous allons l'adapter pour que les signaux ne soient plus jamais présent dans les threads.

Première étape : initialisation

La fonction pthread_sigmask nous permet de bloquer des signaux pour chaque thread nouvellement créé. Cela s'applique aussi aux threads créés par les threads eux mêmes.

Nous utilisons donc sigaddset pour définir les signaux qui nous intéressent. Dans notre cas nous allons bloquer SIGINT, SIGTERM et SIGUSR1.

sigset_t sigs;

sigemptyset(&sigs);
sigaddset(&sigs, SIGINT);
sigaddset(&sigs, SIGTERM);
sigaddset(&sigs, SIGUSR1);

// Application des signaux bloqués pour les threads prochainement créés.
pthread_sigmask(SIG_BLOCK, &sigs, NULL);

Deuxième étape : création des handlers

Maintenant que nos threads sont libérés de ces trois signaux, nous pouvons définir des handlers qui vont bien avec.

struct sigaction sa = {0};

sigemptyset(&sa.sa_mask);

// Arrêt du programme sur ces deux signaux.
sa.sa_handler = stop;
sigaction(SIGINT, &sa, NULL);
sigaction(SIGTERM, &sa, NULL);

// Rechargement du programme sur ce signal utilisateur.
sa.sa_handler = reload;
sigaction(SIGUSR1, &sa, NULL);

Les fonctions stop et reload sont maintenant garanties de ne jamais s'exécuter dans un thread mais nous n'avons pas fini car pour le moment les signaux sont aussi bloqués pour le thread principal et il nous faut donc les débloquer. Pour ce faire, nous utilisons ppoll. Cette fonction est analogue à pselect, c'est à dire qu'elle surveille des descripteurs mais permet aussi à des signaux de s'exécuter précisément pendant l'appel de cette fonction.

Troisième étape : utilisation de ppoll

On doit donc donner une liste vide ou une liste ne contenant pas les trois signaux qui nous intéressent. Dans notre cas nous pouvons donc réinitialiser la liste des signaux à débloquer pendant l'appel à ppoll.

sigemptyset(&sigs);

for (;;) {
    static const struct timespec ts = { .tv_sec = 1 };

    // On admet fds comme un tableau pollfd définit ailleurs et fds_count son
    // nombre d'entrées.
    if (ppoll(fds, fds_count, &ts, &sigs) < 0 && errno != EINTR)
        exit(1);
}

Et ainsi notre application appelle les fonctions des signaux exactement quand nous le souhaitons.

Exemple complet

Voici un exemple de programme qui créé 4 threads qui ne font rien mais dont le thread principal capte SIGUSR1 pour indiquer un rechargement. Les signaux SIGINT et SIGTERM coupent le programme.

#include <errno.h>
#include <poll.h>
#include <pthread.h>
#include <signal.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>

// Déconseillé mais pour le moment c'est suffisant.
static atomic_int jesus_returns = 0;

static void *
routine(void *data)
{
    // Fonction du thread, ne fait rien qu'attendre.
    while (!jesus_returns) {
        puts("Thread is waiting...");
        poll(NULL, 0, 2000);
    }

    return NULL;
}

static void
stop(int n)
{
    printf("The signal %d was received!\n", n);
    jesus_returns = 1;
}

static void
reload(int n)
{
    printf("Reload signal %d received!\n", n);
}

int
main(void)
{
    pthread_t threads[4];
    sigset_t sigs;
    struct timespec ts = { .tv_sec = 1 };
    struct sigaction sa = {0};

    // On bloque SIGINT, SIGTERM et SIGUSR1 sur tous les threads.
    sigemptyset(&sigs);
    sigaddset(&sigs, SIGINT);
    sigaddset(&sigs, SIGTERM);
    sigaddset(&sigs, SIGUSR1);
    pthread_sigmask(SIG_BLOCK, &sigs, NULL);

    // On assigne des handlers des familles.
    sigemptyset(&sa.sa_mask);
    sa.sa_handler = stop;
    sigaction(SIGINT, &sa, NULL);
    sigaction(SIGTERM, &sa, NULL);
    sa.sa_handler = reload;
    sigaction(SIGUSR1, &sa, NULL);

    // Réinitialisation des signaux pour les autoriser dans ppoll.
    sigemptyset(&sigs);

    // Création de threads inutiles.
    for (size_t i = 0; i < 4; ++i)
        pthread_create(&threads[i], NULL, routine, NULL);

    while (!jesus_returns) {
        // Pas de descripteurs de fichiers, on fait juste un ppoll.
        if (ppoll(NULL, 0, &ts, &sigs) < 0 && errno != EINTR) {
            perror("ppoll");
            exit(1);
        }
    }

    for (size_t i = 0; i < 4; ++i)
        pthread_join(threads[i], NULL);
}

Note : pour le moment ppoll n'est pas encore dans la norme POSIX mais le sera dans la prochaine version. Il faut peut-être compiler avec -D_GNU_SOURCE sur glibc pour avoir la déclaration de la fonction.