Revenir aux actualités des développeurs

Signaux dans les environnements de production : dangers et pièges

Dans cet article de blog, Chris Down, ingénieur noyau à Meta, présente les dangers des signaux Linux dans les environnements de production Linux et explique pourquoi les développeurs et développeuses devraient les éviter le plus possible.

Présentation des signaux Linux

Un signal est un évènement généré par les systèmes Linux quand certaines conditions sont remplies. Les signaux peuvent être envoyés de différentes manières : du noyau à un processus, d’un processus à un autre processus ou d’un processus à lui-même. Après avoir reçu un signal, le processus peut effectuer des actions.

Les signaux ont toujours existé et sont un élément essentiel des environnements opérationnels basés sur Unix. Ils relient plusieurs composants indispensables du système d’exploitation (vidage mémoire, gestion du cycle de vie des processus, etc.) et sont globalement efficaces depuis plus de cinquante ans. C’est pourquoi les personnes qui suggèrent que leur utilisation pour la communication interprocessus pourrait comporter des risques sont regardées d’un mauvais œil. Pourtant, cet article recense certains problèmes de production causés par les signaux et propose des solutions et des alternatives.

Les signaux ont beaucoup d’avantages : ils sont standardisés, disponibles partout et ne nécessitent aucune dépendance supplémentaire en dehors de celles fournies par le système d’exploitation. Toutefois, leur sécurité est loin d’être garantie. Les signaux font un grand nombre de suppositions qu’il faut impérativement valider en fonction des critères recherchés, ou du moins, configurer correctement. Or, peu d’applications le font, même les plus connues. Elles s’exposent ainsi à de futurs incidents qui seront difficiles à corriger.

Nous allons étudier un incident qui s’est déroulé récemment dans l’environnement de production Meta pour souligner le danger des signaux. Nous reviendrons brièvement sur l’histoire de certains signaux et comment ils nous ont conduits à la situation actuelle, puis nous comparerons cela à nos besoins d’aujourd’hui, ainsi qu’aux problèmes que nous rencontrons en production.

L’incident

Revenons quelque temps en arrière. L’équipe de LogDevice nettoyait sa base de code en supprimant les fonctionnalités et les lignes obsolètes. L’une des fonctionnalités abandonnées était un journal qui documentait certaines opérations réalisées par le service. Elle était devenue redondante et n’avait pas trouvé d’utilisateurs, d’où la décision de la supprimer. Vous pouvez voir cette modification sur cette page GitHub. Jusque-là, rien d’anormal.

La période qui avait directement suivi la modification était calme, l’environnement de production fonctionnait correctement et continuait de générer un trafic stable. Quelques semaines plus tard, l’équipe recevait un rapport signalant une perte fulgurante de nœuds de service. Le problème était lié au déploiement de la nouvelle version, mais l’équipe ne parvenait pas à en trouver la cause exacte. Quel changement avait provoqué un tel chamboulement ?

Après avoir exploré différentes pistes, l’équipe a déduit que la modification apportée au code évoquée plus tôt, provoquant la suppression de ces journaux, était à l’origine du problème. Pour quelles raisons ? Qu’est-ce qui n’allait pas dans ce code ? Si vous n’avez pas encore trouvé le problème, étudiez attentivement cette révision différentielle, car l’erreur ne saute pas aux yeux et aurait pu être commise par n’importe qui.

Logrotate fait son entrée

Logrotate est, pour ainsi dire, l’outil standard de rotation des journaux sur Linux. Créé il y a près de 30 ans, son fonctionnement est simple : il gère le cycle de vie des journaux en procédant à leur rotation et à leur suppression.

Il n’envoie pas lui-même de signaux, donc vous ne trouverez pas d’informations sur eux dans la documentation ou sur la page principale de Logrotate. Cependant, il peut exécuter arbitrairement des commandes avant ou après ses rotations. Voici un exemple de base issu de la configuration par défaut de Logrotate dans CentOS :

/var/log/cron
/var/log/maillog
/var/log/messages
/var/log/secure
/var/log/spooler
{
    sharedscripts
    postrotate
        /bin/kill -HUP `cat /var/run/syslogd.pid 2> /dev/null` 2> /dev/null || true
    endscript
}

Bien qu’elle semble un peu sommaire, partons du principe qu’elle fonctionne comme prévu. Elle indique à Logrotate d’envoyer SIGHUP à l’identifiant de Page compris dans /var/run/syslogd.pid, qui devrait correspondre à l’identifiant de Page de l’instance syslogd en cours d’exécution, après chaque rotation des fichiers répertoriés.

Ce genre de configuration ne pose pas de problème pour une API publique stable comme syslog. Mais qu’en est-il pour un processus interne dans lequel la mise en œuvre de SIGHUP dépend d’un détail variable ?

Une histoire de raccrochages

L’un des problèmes réside dans l’interprétation sémantique et la programmation des signaux. Alors que certains signaux ne se retrouvent pas dans l’espace utilisateur et n’ont qu’une seule signification, d’autres comme SIGKILL et SIGSTOP peuvent être interprétés différemment en fonction des développeur·ses d’application et des utilisateur·ices. Dans certains cas, la différence est purement académique, comme pour SIGTERM, qui signifie généralement : « fermer proprement dès que possible ». Dans d’autres cas, comme pour SIGHUP, la signification est plus équivoque.

SIGHUP a été conçu pour les lignes série, afin d’indiquer que la connexion a été interrompue à l’autre bout de la ligne. Les habitudes ayant la vie dure, nous envoyons toujours SIGHUP quand son équivalent moderne se présente : quand un pseudo terminal ou un terminal virtuel est fermé (d’où l’utilisation d’outils tels que nohup pour le masquer).

Aux débuts d’Unix, les développeur·ses devaient mettre en place le rechargement d’un daemon. La procédure consistait généralement à rouvrir le fichier de configuration/journal sans redémarrer le système. C’est pourquoi les signaux semblaient un bon moyen d’y parvenir, sans créer de dépendances. Bien sûr, il n’existait aucun signal capable de réaliser cette tâche, mais ces daemons ne disposant d’aucun terminal de commande, ils n’avaient aucune raison de recevoir le signal SIGHUP. Celui-ci semblait donc parfaitement convenir comme solution alternative, sans aucun gros inconvénient.

Pourtant, ce plan avait un petit défaut. L’état par défaut des signaux n’est pas « ignored », mais dépend de chaque signal. Cela signifie, par exemple, que les programmeur·ses n’ont pas à configurer manuellement SIGTERM pour fermer leur application. Tant qu’ils et elles ne définissent pas d’autre gestionnaire de signaux, le noyau se charge de fermer leur programme, sans que du code ne soit ajouté dans l’espace utilisateur. Pratique !

Ce qui l’est moins, c’est que le comportement par défaut de SIGHUP est de fermer le programme immédiatement. Cela ne pose pas de problème dans le cas du raccrochage initial, car ces applications n’étaient probablement plus utiles, mais cela n’est pas idéal pour les nouveaux usages.

Une solution pourrait être de supprimer toutes les instances susceptibles d’envoyer un signal SIGHUP au programme. Malheureusement, c’est plus facile à dire qu’à faire dans les bases de code matures et volumineuses. SIGHUP n’est pas un appel de communication interprocessus (IPC) étroitement contrôlé que vous pouvez facilement retrouver dans la base à l’aide d’une commande grep. Les signaux peuvent provenir de n’importe où, n’importe quand, et leur fonctionnement est rarement contrôlé (à l’exception du simple « êtes-vous cet utilisateur ou CAP_KILL »). Au final, il est difficile de déterminer l’origine des signaux. Néanmoins, en explicitant davantage l’IPC, nous pouvons savoir si un signal est important ou s’il doit être ignoré.

Des raccrochages aux dangers

Je suppose qu’à présent, vous commencez à deviner ce qu’il s’est passé. L’après-midi fatidique est arrivé pour l’équipe LogDevice : elle a publié sa version contenant le nouveau code. Tout se passait bien au début, mais dès le lendemain à minuit, la situation s’est mystérieusement dégradée. La coupable ? La strophe ci-dessous dans la configuration Logrotate de la machine, qui envoie au daemon logdevice un signal SIGHUP qui n’est désormais plus pris en charge, et qui se révèle donc fatal :

/var/log/logdevice/audit.log {
  daily
  # [...]
  postrotate
    pkill -HUP logdeviced
  endscript
}

Il est très facile de passer à côté d’une courte strophe dans une configuration Logrotate, en particulier quand vous supprimez une grosse fonctionnalité. Malheureusement, il est aussi difficile d’être sûr d’avoir éradiqué en une fois toute trace de son existence. Même dans des cas plus simples à vérifier que celui-ci, il est fréquent d’oublier des parties de code lors du nettoyage. Pourtant, ces oublis n’ont généralement pas de conséquences majeures, car le code restant n’est souvent plus opérationnel.

En théorie, l’incident est simple, tout comme sa solution : il suffit de ne pas envoyer le signal SIGHUP et de répartir les actions LogDevice sur une plus longue période (autrement dit, ne pas l’exécuter à minuit pile). Toutefois, ce n’est pas pour ces nuances que nous avons choisi de parler de cet incident. Nous voulons nous en servir d’exemple afin d’encourager les développeur·ses à réserver l’utilisation des signaux en production aux cas les plus basiques et essentiels.

Les dangers des signaux

Avantages des signaux

Tout d’abord, l’utilisation des signaux comme mécanisme pour gérer les modifications de l’état des processus dans le système d’exploitation est justifiée. Le signal SIGKILL, par exemple, fonctionne exactement comme prévu et ne peut pas être contrôlé par un gestionnaire de signaux. Le comportement par défaut du noyau en réponse aux signaux SIGABRT, SIGTERM, SIGINT, SIGSEGV, SIGQUIT et autres signaux similaires est également bien compris par les utilisateur·ices et les programmeur·ses.

Ces signaux ont tous comme effet d’évoluer vers un état de fin terminal au sein même du noyau lorsqu’ils ont été reçus. En d’autres termes, à partir du moment où vous recevez un signal SIGKILL ou SIGTERM sans gestionnaire de signaux dans l’espace utilisateur, aucune autre instruction de l’espace utilisateur n’est exécutée.

Il est important d’obtenir un état de fin terminal, car il reflète souvent une diminution de la complexité de la pile et du code en cours d’exécution. Les autres états révèlent plutôt une complexité grandissante, et sont plus difficiles à justifier, à mesure que le flux de code interfère avec la simultanéité.

Dangers des comportements par défaut

Vous avez peut-être remarqué que nous n’avons pas parlé d’autres signaux qui génèrent un état terminal par défaut. Voici une liste de tous les signaux standard qui ont ce comportement par défaut (à l’exception des signaux de vidage mémoire tels que SIGABRT ou SIGSEGV, qui sont tous justifiés) :

  • SIGALRM
  • SIGEMT
  • SIGHUP
  • SIGINT
  • SIGIO
  • SIGKILL
  • SIGLOST
  • SIGPIPE
  • SIGPOLL
  • SIGPROF
  • SIGPWR
  • SIGSTKFLT
  • SIGTERM
  • SIGUSR1
  • SIGUSR2
  • SIGVTALRM

Au premier abord, ils peuvent paraître inoffensifs, mais voici quelques points problématiques :

  • SIGHUP : quand il est utilisé comme prévu initialement, il peut terminer le processus par défaut sans problème. Mais dans la mesure où il est aussi utilisé pour « rouvrir des fichiers », il peut être dangereux.
  • SIGPOLL et SIGPROF : ces signaux tombent dans la catégorie des signaux « qui devraient être gérés en interne par une fonction standard plutôt que par votre programme ». Même si le comportement par défaut de terminer le processus semble inoffensif, il reste néanmoins loin d’être idéal.
  • SIGUSR1 et SIGUSR2 : il s’agit de signaux « définis par l’utilisateur » que vous pouvez utiliser comme bon vous semble. Cependant, comme leur comportement par défaut est terminal, si vous utilisez USR1 pour un usage spécifique et qu’il ne vous est plus utile par la suite, vous ne pouvez pas vous contenter de supprimer le code. Pour plus de sécurité, vous devez consciemment penser à ignorer explicitement le signal. Or, ce raisonnement ne vient pas naturellement aux programmeur·ses, même les plus expérimenté·es.

Nous en sommes déjà à un tiers des signaux terminaux qui sont pour le moins douteux, voire dangereux quand un programme doit être modifié. Pire, même les signaux soi-disant « définis par l’utilisateur » peuvent entraîner de gros problèmes si vous oubliez d’ajouter un SIG_IGN explicite. Des signaux aussi inoffensifs que SIGUSR1 ou SIGPOLL peuvent créer des incidents.

Le problème ne vient pas d’un manque de familiarité. Peu importe votre maîtrise des signaux, il reste très difficile d’écrire correctement du code comportant des signaux du premier coup, car les signaux sont bien plus complexes qu’ils ne paraissent.

Flux du code, simultanéité et le mythe de SA_RESTART

Généralement, les programmeur·ses ne passent pas toute leur journée à réfléchir au fonctionnement interne des signaux. C’est pourquoi quand arrive le moment de gérer les signaux, ils et elles commettent souvent des erreurs d’implémentation.

Je ne parle même pas des cas « banals », comme les problèmes de sécurité d’une fonction de gestion des signaux, qui sont généralement réglés en envoyant une commande sig_atomic_t ou en utilisant un code C++ atomic_signal_fence. En effet, ces cas sont assez bien documentés et restent gravés dans la mémoire de quiconque les a déjà rencontrés. Ce qui est plus délicat, c’est de raisonner en matière de flux du code des portions nominales d’un programme complexe quand il reçoit un signal. Il faut ainsi réfléchir constamment et explicitement aux signaux tout au long du cycle de vie de l’application : est-ce que SA_RESTART est suffisant pour EINTR ? Quel flux appliquer si celui-ci se termine prématurément ? J’ai maintenant un programme en parallèle, qu’est-ce que ça implique ? Une alternative consiste à configurer un sigprocmask ou un pthread_setmask pour certaines parties du cycle de vie de l’application et espérer que le flux de code ne change jamais (ce qui est rarement le cas, compte tenu du rythme imposé dans le secteur). signalfd ou l’exécution de sigwaitinfo dans un thread dédié peuvent être d’une certaine aide, mais ces deux méthodes présentent suffisamment de cas problématiques et de préoccupations en matière de convivialité pour ne pas être recommandées.

Nous espérons que la plupart des programmeur·ses expérimenté·es ont aujourd’hui conscience qu’il est difficile de produire ne serait-ce qu’un exemple de code pouvant être exécuté sans risque. Or, gérer les signaux est en réalité bien plus difficile que d’écrire un code correct et sûr. Les gestionnaires de signaux doivent utiliser uniquement un code sans « lock » (exclusion mutuelle) avec des structures de données atomiques, respectivement, car le flux principal d’exécution est suspendu et nous ne savons pas quelles locks il contient, et car le flux principal d’exécution pourrait réaliser des opérations non atomiques. Ils doivent également être réentrants, c’est-à-dire qu’ils doivent être capables de s’imbriquer les uns dans les autres, car les gestionnaires de signaux peuvent se chevaucher si un signal est envoyé plusieurs fois (ou même au sein d’un seul signal, comme SA_NODEFER). C’est l’une des raisons pour lesquelles vous ne pouvez pas utiliser de fonctions comme printf ou malloc dans un même gestionnaire de signaux, car elles dépendent d’exclusions mutuelles globales pour la synchronisation. Si vous possédiez cette lock lors de la réception du signal, puis procédez à appeler une fonction qui nécessite de nouveau cette lock, votre application sera alors bloquée. C’est un raisonnement très, très complexe. C’est pourquoi beaucoup de personnes gèrent les signaux avec un code de ce genre :

static volatile sig_atomic_t received_sighup; 

static void sighup(int sig __attribute__((unused))) { received_sighup = 1; }

static int configure_signal_handlers(void) {
  return sigaction(
    SIGHUP,
    &(const struct sigaction){.sa_handler = sighup, .sa_flags = SA_RESTART},
    NULL);
}

int main(int argc, char *argv[]) {
  if (configure_signal_handlers()) {
       /* failed to set handlers */
  }

  /* usual program flow */

  if (received_sighup) {
    /* reload */
    received_sighup = 0;
  }

  /* usual program flow */
}

Bien que ce type de code, signalfd ou toute autre tentative de gestion asynchrone des signaux semblent simples et fiables, ils ignorent tous que le point d’interruption est tout aussi important que les actions réalisées après réception du signal. Supposons que le code de votre espace utilisateur réalise des opérations E/S ou modifie les métadonnées des objets provenant du noyau (comme les nœuds index ou les descripteurs de fichiers). Au moment de l’interruption, vous serez probablement dans la pile d’un espace du noyau. Voici à quoi peut ressembler un thread qui tente de fermer un descripteur de fichier :

# cat /proc/2965230/stack
 [<0>] schedule+0x43/0xd0
 [<0>] io_schedule+0x12/0x40
 [<0>] wait_on_page_bit+0x139/0x230
 [<0>] filemap_write_and_wait+0x5a/0x90
 [<0>] filp_close+0x32/0x70
 [<0>] __x64_sys_close+0x1e/0x50
 [<0>] do_syscall_64+0x4e/0x140
 [<0>] entry_SYSCALL_64_after_hwframe+0x44/0xa9

Ici, __x64_sys_close est le variant x86_64 de l’appel système close, qui ferme un descripteur de fichier. À ce moment de l’exécution, nous attendons la mise à jour du stockage de sauvegarde (wait_on_page_bit). Les E/S étant considérablement plus lentes que les autres opérations, schedule est un moyen d’indiquer au planificateur du processeur du noyau que nous allons réaliser une opération avec une forte latence (comme des E/S disque ou réseau) et qu’il devrait temporairement remplacer le processus actuel par un autre. Ainsi, nous signalons au noyau qu’il peut choisir un processus qui utilisera véritablement le processeur, plutôt que perdre du temps avec un processus qui doit être interrompu tant qu’il n’a pas reçu de réponse d’une opération susceptible de prendre beaucoup de temps.

Imaginez que nous envoyons un signal au processus qui était en cours d’exécution. Le signal que nous avons envoyé contient un gestionnaire d’espace utilisateur dans le thread de réception, donc nous reprendrons dans l’espace utilisateur. L’une des réactions du noyau pourrait être d’essayer de sortir de la programmation prévue dans schedule, de dérouler davantage la pile et de renvoyer une erreur ESYSRESTART ou EINTR dans l’espace utilisateur pour indiquer que nous avons été interrompus. Mais le descripteur de fichier est-il bientôt fermé ? Quel est son état actuel ?

À présent que nous sommes de retour dans l’espace utilisateur, nous allons exécuter le gestionnaire de signal. Quand le gestionnaire de signal est fermé, nous allons propager l’erreur dans le wrapper close de la bibliothèque libc de l’espace utilisateur, puis dans l’application qui, en théorie, devrait pouvoir gérer la situation rencontrée. Nous précisons « en théorie », car il est très difficile de prédire la réaction à ces signaux, et certains services en production ne gèrent pas très bien ces cas particuliers. Dans les applications où l’accent n’est pas mis sur l’intégrité des données, cela ne pose pas nécessairement de problème. En revanche, dans les applications de production qui accordent une importance à l’intégrité et à la cohérence des données, cela pose un problème majeur : le noyau ne donne pas de détails qui nous permettraient de comprendre jusqu’où il est allé, ce qu’il a accompli ou non, et ce que nous devrions faire pour remédier à la situation. Pire encore, si close renvoie EINTR, l’état du descripteur de fichier devient non spécifié :

“If close() is interrupted by a signal [...] the state of [the file descriptor] is unspecified.”

Bonne chance pour trouver le raisonnement qui permettra de gérer cette situation en toute sécurité dans votre application. En général, gérer EINTR est compliqué même dans les appels système qui se comportent normalement. Il existe beaucoup de petits problèmes qui, cumulés, expliquent pourquoi SA_RESTART est insuffisant. Les appels système ne peuvent pas tous être redémarrés, et vous ne pouvez pas demander aux développeur·ses de votre application de comprendre toutes les façons dont les signaux peuvent être reçus pour chaque appel système de chaque site d’appel. Extrait du manuel man 7 signal :

“The following interfaces are never restarted after being interrupted by a signal handler, regardless of the use of SA_RESTART; they always fail with the error EINTR [...]”

(Les interfaces suivantes ne sont jamais redémarrées après avoir été interrompues par un gestionnaire de signaux, même quand SA_RESTART est utilisé ; elles se soldent toujours par un échec avec une erreur EINTR [...].) De même, vous ne pouvez pas utiliser sigprocmask et vous attendre à ce que le flux du code reste statique. Les développeur·ses ne passent pas tout leur temps à réfléchir aux implications de la gestion des signaux ou à essayer de produire/conserver un code qui traite correctement les signaux. Il en va de même si vous utilisez un thread dédié avec sigwaitinfo : GDB et d’autres outils similaires ne parviendront peut-être pas à débuguer le processus. Des flux de code ou gestionnaires de signaux contenant de légers défauts peuvent vite provoquer des bugs, des plantages, des corruptions difficiles à débuguer, des blocages et bon nombre de problèmes qui vous mèneront directement dans les bras de votre outil de gestion des incidents préféré.

Complexité accrue dans les environnements multithreads

Si vous pensiez que ces paragraphes sur la simultanéité, la réentrance et l’atomicité étaient déjà complexes, je vous laisse imaginer le casse-tête quand on ajoute le multithreading dans l’histoire. Et pourtant, il est important d’y penser, car de nombreuses applications complexes exécutent implicitement plusieurs threads distincts, comme dans jemalloc ou GLib. Il existe même certaines bibliothèques qui installent elles-mêmes des gestionnaires de signaux, ajoutant de la complexité à un problème déjà complexe.

Le manuel man 7 signal contient les informations suivantes sur le sujet :

“A signal may be generated (and thus pending) for a process as a whole (e.g., when sent using kill(2)) or for a specific thread [...] If more than one of the threads has the signal unblocked, then the kernel chooses an arbitrary thread to which to deliver the signal.”

(Un signal peut être généré [et donc, mis en attente] pour l’ensemble d’un processus [par exemple, quand il est envoyé avec kill(2)] ou pour un thread spécifique [...]. Si le signal n’est pas bloqué pour plusieurs threads, le noyau choisit un thread au hasard pour délivrer le signal.) En résumé, pour la plupart des signaux, le noyau envoie le signal à n’importe quel thread qui ne bloque pas ce signal avec sigprocmask. SIGSEGV, SIGILL et les signaux du même genre ressemblent à des interruptions, et dirigent explicitement le signal vers le thread incriminé. Cependant, contrairement à la croyance populaire, la plupart des signaux ne peuvent pas être explicitement envoyés vers un seul thread d’un groupe de threads, même avec tgkill ou pthread_kill.

Cela signifie que vous ne pouvez pas changer arbitrairement les caractéristiques globales de la gestion des signaux quand vous avez un ensemble de threads. Si un service doit périodiquement bloquer un signal avec sigprocmask dans le thread principal, vous devez trouver un moyen externe de communiquer aux autres threads comment ils devraient réagir dans cette situation. Autrement, le signal pourrait être intercepté par un autre thread et disparaître à jamais. Bien entendu, vous pouvez éviter cela en bloquant les signaux des threads enfants, mais vous risqueriez alors de rendre la situation encore plus complexe s’ils ont besoin de gérer eux-mêmes leurs signaux, même pour des choses aussi basiques que waitpid.

Comme tout ce que nous décrivons ici, ces problèmes ne sont pas techniquement insurmontables. Toutefois, ignorer les contraintes et les potentielles conséquences néfastes (bugs, confusion, voire pire) de la synchronisation complexe requise pour que tout fonctionne correctement relèverait de la négligence.

Manque de définition et communication des échecs/réussites

Les signaux sont diffusés de manière asynchrone dans le noyau. L’appel système kill renvoie une réponse dès que le signal en attente est enregistré pour le processus ou l’instruction task_struct du thread en question. La diffusion du signal dans les délais souhaités n’est ainsi aucunement garantie, même en absence de blocage.

Même si le signal est effectivement diffusé dans les délais, il n’existe aucun moyen de prévenir l’expéditeur du signal de l’état de sa requête d’action. Pour cette raison, vous ne devez pas utiliser les signaux pour communiquer les actions importantes. Celles-ci seront simplement déclenchées, puis oubliées, en l’absence de mécanisme pour signaler l’échec ou la réussite de l’envoi et des actions correspondantes. Comme nous l’avons vu plus haut, même les signaux les plus inoffensifs peuvent être dangereux s’ils ne sont pas configurés dans l’espace utilisateur.

N’importe quelle personne utilisant Linux a déjà été confrontée à un processus qu’elle souhaite arrêter, mais qui ne répond à aucun signal, même à ceux réputés fatals comme SIGKILL. Ce problème s’explique en partie par une mauvaise compréhension de l’objectif de kill(1) : il ne sert pas à forcer la fermeture des processus, mais à mettre en attente une requête demandant la réalisation d’une action destinée au noyau (sans aucune indication sur le moment où elle sera traitée).

Le but de l’appel système kill est de mettre le signal en attente dans les métadonnées des tâches du noyau, ce qu’il fait même quand une tâche SIGKILL ne ferme pas le processus. Dans le cas de SIGKILL en particulier, le noyau garantit qu’aucune instruction supplémentaire ne sera exécutée en mode utilisateur ; en revanche, vous pouvez toujours être amené·e à exécuter des instructions pour réaliser des actions en mode noyau afin d’éviter une corruption des données ou la libération de ressources. C’est pourquoi l’envoi est considéré comme réussi, même quand l’état est « D » (veille sans interruption). L’appel « kill » n’échoue que si vous avez fourni un signal non valide, si vous n’avez pas l’autorisation d’envoyer ce signal ou si le pid destinataire du signal n’existe pas et ne peut donc pas être utilisé pour propager de manière fiable les états non terminaux aux applications.

Conclusion

  • Les signaux peuvent être utilisés pour l’état terminal quand celui-ci est traité seulement par le noyau, sans gestionnaire d’espace utilisateur. Pour les signaux qui ont vocation à fermer immédiatement votre programme, mieux vaut laisser le noyau les gérer seul. Cela implique que le noyau pourrait terminer ses actions plus tôt et libérer les ressources de votre programme plus rapidement, là où une requête IPC de l’espace utilisateur devrait attendre que la partie de l’espace utilisateur redémarre.
  • L’un des meilleurs moyens d’éviter tout problème de gestion des signaux est de ne pas les gérer du tout. Toutefois, pour les applications qui doivent traiter les états de signaux tels que SIGTERM, nous conseillons des API de haut niveau telles que folly::AsyncSignalHandler, qui corrigent de manière intuitive un certain nombre de défauts.

  • Évitez de communiquer les requêtes applicatives avec des signaux. Au lieu de miser sur l’interruption de l’application, privilégiez plutôt les notifications autogérées (comme inotify) ou les RPC de l’espace utilisateur pour gérer ces requêtes lors d’une partie dédiée du cycle de vie de l’application.
  • Quand cela est possible, limitez l’étendue des signaux à une sous-partie de votre programme ou des threads avec sigprocmask, afin de réduire la quantité de code à vérifier lors des contrôles réguliers des signaux. N’oubliez pas qu’en cas de changement des flux de code ou des stratégies de threading, le masque peut réagir de manière inattendue.
  • Au démarrage du daemon, masquez les signaux terminaux qui ne sont pas compris de manière uniforme et qui pourraient être réaffectés à une autre partie de votre programme pour éviter de revenir au comportement par défaut du noyau. Voici ma suggestion :
signal(SIGHUP, SIG_IGN);
signal(SIGQUIT, SIG_IGN);
signal(SIGUSR1, SIG_IGN);
signal(SIGUSR2, SIG_IGN);

Même dans les programmes bien conçus, il est extrêmement compliqué de comprendre le comportement des signaux. D’autre part, leur utilisation présente un risque inutile dans les applications qui ont accès à d’autres alternatives. En règle générale, n’utilisez pas les signaux pour communiquer avec la partie de votre programme dédiée à l’espace utilisateur. À la place, configurez le programme de sorte qu’il gère lui-même les évènements de manière transparente (avec inotify, par exemple) ou utilisez une méthode de communication avec l’espace utilisateur capable de signaler les erreurs à l’expéditeur, qui peut être énumérée et démontrée au moment de la compilation, comme Thrift, gRPC ou autre outil similaire.

J’espère vous avoir démontré que sous leur apparence simple et banale, les signaux sont significativement plus complexes. Leur prétendue simplicité en a fait l’API idéale pour les espaces utilisateur des logiciels. Or, leur utilisation nécessite une multitude de décisions implicites de conception qui ne sont pas adaptées à la plupart des cas d’utilisation de notre époque.

Ne vous méprenez pas : l’utilisation des signaux est justifiée dans certains cas. En l’absence d’espace utilisateur, les signaux sont pratiques pour communiquer au noyau l’état souhaité pour un processus, par exemple, l’arrêt d’un processus. Cependant, il est difficile de rédiger du premier coup un code correct quand les signaux risquent d’être piégés dans l’espace utilisateur.

Les signaux ont beaucoup d’atouts : ils sont standardisés, disponibles partout et ne nécessitent pas de dépendances. Toutefois, ils comportent leur lot d’inconvénients qui ne font que s’accumuler proportionnellement à l’ampleur des projets. J’espère que cet article vous aura donné des pistes pour contourner ce problème et atteindre vos objectifs d’une manière plus sûre, plus simple et plus intuitive.

Pour en savoir plus sur Meta Open Source, consultez notre site Open Source, abonnez-vous à notre chaîne YouTube, ou suivez-nous sur Twitter, Facebook et LinkedIn.