Les signaux et la programmation asynchrone sous Linux

Cet article a été initialemùent publié dans Programmez! 124

Les sources complets sont disponibles en fin d'articles

Les signaux sous Linux sont en vaste sujet. C'est même en grande partie l'âme de la programmation UNIX. Jetons un regard simple sur ce vaste domaine et découvrons une fonctionnalité des noyaux Linux récents: signalfd.

 

La manipulation des signaux est un des aspects les plus passionnants de la programmation UNIX et donc Linux. Qu'est-ce qu'un signal ? Disons que c'est sollicitation faite à un processus, et auquel celui-ci doit réagir immédiatement, en modifiant le cours normal de son flux d'exécution. Sur le principe, les signaux Linux c'est très simple. Dans la pratique, plein de subtilités se présentent. De plus, les sollicitations en question étant, dans l'immense majorité des cas, externes au processus, nous entrons dans le domaine de la programmation asynchrone, et du cortège d'ennuis et de bugs imprévisibles qui la suivent. Dans cet article nous allons voir quelques bases de la manipulation des signaux, voir que ces manipulations peuvent vite devenir problématiques et enfin voir comment la nouvelle fonctionnalité signalfd, propre au noyau Linux, facilite le travail.

 

Les bases

Les signaux sont émis par des processus, mais le plus souvent par le noyau lui-même pour signaler, c'est la cas où jamais de le dire, des changements de conditions matérielles ou logicielles. Ainsi lorsqu'un timer arrive à terme le noyau émet un signal, lorsque des combinaisons de touches telles que Ctrl-C ou Ctrl-Z sont activés, le noyau émet un signal. Lorsqu'un terminal est arrêté, ses processus fils sont signalés. Lorsqu'un processus tente d'accéder à une zone mémoire illégale, il est signalé, etc. Concrètement un signal n'est rien d'autre qu'un nombre, représenté pour le programmeur par une constante symbolique. Sous Linux il en existe 64 qu'il serait stérile de passer en revue ici. Nous renvoyons le lecteur aux nombreuses documentations existantes. Le processus destinataire d'un signal peut, avec les exceptions qui confirment la règle, soit capturer soit ignorer un signal émis à son intention. Si le signal est capturé, le flot d'exécution du processus saute dans une routine dite gestionnaire du signal, pour ensuite reprendre l'exécution au point où elle en était lors de la réception du signal. Là encore on rencontre des subtilités et cette règle comporte aussi des exceptions. Si le signal est ignoré, un traitement par défaut est appliqué.

 

Traitement classique

Ecrivons un programme (BasicSignalClassique.cpp) qui capture la combinaison de touches ctrl-Z et donc qui capture le signal SIGTSTP, signal à ne pas confondre avec SIGSTOP qui lui ne peut être capturé.

#include using namespace std; #include #include void gestionnaire(int numero) { cout << "Signal recu: " << strsignal(numero) << endl; } int main() { // Mise en place du gestionnaire if(signal(SIGTSTP, gestionnaire) == SIG_ERR) { cerr << "Impossible d'installer le gestionnaire" << endl; return 1; } cout << "Gestionnaire en place" << endl; cout << "Ctrl-C pour arreter" << endl; // Mettre le processus en sommeil while(1) pause(); return 0; }

Sous Linux le gestionnaire de signal est réinstallé par le système dès la fin de son exécution. Ce comportement est particulier à Linux. Avec d'autres UNIX il faut réinstaller le signal dans le gestionnaire. Avec Linux toujours, un signal est bloqué dans son gestionnaire, ce qui fait que ce gestionnaire ne peut être appelé lors de sa propre exécution. Il n'en est pas forcément de même avec d'autres UNIX. Ceci dit pour pointer du doigt qu'écrire du code portable Linux/Unix est un vrai défi, le comportement des API variant sensiblement d'un système à un autre.

 

Signaux et appels systèmes lents

Que peut-on faire à l'intérieur d'un gestionnaire de signal, hormis, comme dans l'exemple ci-dessus, imprimer sur la console l'identité du signal reçu ? Sans doute plein de choses, mais aussi avec de sérieuses limitations. Déjà un gestionnaire ne reçoit d'autres paramètres que le numéro du signal émis. On ne peut lui passer une structure de données dont le contenu serait préalablement initialisé. Un gestionnaire est donc contraint de travailler avec des variables globales et donc avec tous les problèmes de conflit que cela peut induire. Il y a d'autres subtilités. D'abord un signal peut faire échouer un appel système lents. Soit ce code:

#include using namespace std; #include #include void gestionnaire(int numero) { cout << "Signal recu: " << strsignal(numero) << endl; } int main() { // Mise en place du gestionnaire if(signal(SIGTSTP, gestionnaire) == SIG_ERR) { cerr << "Impossible d'installer le gestionnaire" << endl; return 1; } //siginterrupt(SIGTSTP, 0); cout << "Gestionnaire en place" << endl; cout << "Ctrl-C pour arreter" << endl; char c; int fd = 0 ; // stdin while(1) { read(fd, &c, sizeof(c)); if(c == 'q') { cout << "Fin du programme" << endl; return 0; } else { cout << "Echo: " << c << endl; } } return 0; }

On appelle appel système lent un appel dont l'exécution est "longue". cet appel est symbolisé dans le code ci-dessus par la fonction read qui lit un caractère sur le terminal. Sans doute cet exemple ne présentera pas de dysfonctionnement, mais si le même code lisait dans un socket on rencontrerait les problèmes à coup sûr. En effet un signal peut faire échouer un appel système lent. Si un tel appel échoue, il est hors de question de le relancer dans le gestionnaire du signal. En effet la plupart des appels systèmes Linux ne sont pas ré-entrants ce qui signifie qu'on ne peut pas les appeler si un appel précédent n'est pas correctement terminé. Il faut donc que ses appels soient relancés dans le corps principal du programme. Ceci est obtenu via un appel à la fonction siginterrupt immédiatement derrière l'installation d'un gestionnaire. Voir la ligne de code en commentaire ci-dessus. Cette approche classique du travail avec les signaux n'est donc pas pleinement satisfaisante: à l'évidence tout cela oblige a tester très méticuleusement les résultats des opérations d'entrées/sorties, ce qui ne simplifie pas la logique du code et son écriture, et nous commençons à voir qu'il peut s'avérer difficile de faire cohabiter signaux et entrées/sorties. Encore un mot avant de continuer: tout gestionnaire qui emploie un appel système, fusse dans de bonnes conditions doit sauvegarder la variable errno et la restituer en sortie. En effet un signal peut très bien se produire au moment où le programme s'apprête à analyser errno au retour d'un appel système.

 

Sigaction, les signaux à la manière Posix

Jusqu'ici notre travail avec les signaux est assez brut de décoffrage. Les API conformes à la norme Posix permettent de travailler plus finement. Posix est un standard d'API pour système UNIX que Linux a le bon goût d'implémenter. Ce n'est pas le cas de tous les UNIXES malheureusement :( Si avec les API Posix les problèmes concernant ce qui se passe dans les gestionnaires restent entier, du moins est-il possible de mettre ces gestionnaires en place plus finement en spécifiant l'ensemble des signaux devant être bloqués pendant l'exécution du gestionnaire. Il est aussi possible de spécifier explicitement si les appels systèmes doivent être relancés  (quoi que ce point précis ne soit pas standardisé Posix en fait) et si un gestionnaire doit être invoqué une seule fois ou réinstallé automatiquement. Voici à titre d'exemple un programme (DemoSigaction.cpp) qui installe des gestionnaires pour les signaux SIGSTP et SIGQUIT correspondant aux combinaisons de touches Ctrl-Z et Ctrl-\ respectivement. Le gestionnaire pour SIGQUIT ne devra être exécuté qu'une fois. En outre on demande que SIGINT (Ctrl-C) soit désactivé tant que l'on se situe dans le gestionnaire, et on veut encore qu'un signal soit masqué quand on est dans le gestionnaire de l'autre.

 

#include using namespace std; #include #include void gestionnaire_sigtstp(int sig) { cout << "Entree dans le gestionnaire SIGTSTP" << endl; cout << "Recu: " << strsignal(sig) << endl; sleep(2); cout << "Sortie gestionnaire SIGTSTP" << endl; } void gestionnaire_sigquit(int sig) { cout << "Entree dans le gestionnaire SIGTSTP" << endl; cout << "Recu: " << strsignal(sig) << endl; sleep(2); cout << "Sortie gestionnaire SIGQUIT" << endl; } int main() { struct sigaction action; // Configuration pour SIGTSTP action.sa_handler = gestionnaire_sigtstp; sigemptyset(&action.sa_mask); sigaddset(&action.sa_mask, SIGINT); sigaddset(&action.sa_mask, SIGQUIT); action.sa_flags = SA_RESTART; if(sigaction(SIGTSTP, &action, 0) != 0) { cerr << "Impossible installer gestionnaire SIGTSTP" << endl; } // Configuration pour SIGQUIT action.sa_handler = gestionnaire_sigquit; sigemptyset(&action.sa_mask); sigaddset(&action.sa_mask, SIGINT); sigaddset(&action.sa_mask, SIGTSTP); action.sa_flags = SA_RESTART|SA_ONESHOT; if(sigaction(SIGQUIT, &action, 0) != 0) { cerr << "Impossible installer gestionnaire SIGTSTP" << endl; } while(1) pause(); return 0; }

Ce code mérite d'être commenté. Nous utilisons donc dans cet exemple l'API sigaction en remplacement de l'API signal des exemples précédents. En plus du numéro de signal, sigaction attend un pointeur sur une structure dont le nom est également sigaction. Un membre de cette structure recevra le pointeur sur le gestionnaire du signal. Aucune difficulté ici. Le gestionnaire n'est pas plus évolué qu'avant. Il existe une alternative dont nous ne parlerons pas ici mais qui de toute façon ne casse pas trois pattes à un pingouin. Le membre sa_mask contient les signaux devant être masqués lors de l'exécution du gestionnaire. Attention ce membre est une donnée opaque et l'emploi des API sigemptyset et sigaddset est incontournable. Enfin le membre sa_flags permet d'affiner les comportements. Ainsi c'est avec SA_RESTART que nous demandons la relance systématique des appels systèmes qui aurait pu être interrompus par un signal et c'est avec SA_ONESHOT que nous que le gestionnaire de SIGQUIT ne soit exécuté qu'une fois. A la suite de quoi le comportement par défaut pour ce signal est rétabli.

 

Surveiller de multiples entrées/sorties

Nous allons aborder la question encore en suspend d'un mariage plus harmonieux des traitements de signaux et des entrées/sorties. Avant cela prenons le temps de voir comment il est possible d'attendre des opérations d'entrées/sorties sur plusieurs descripteurs de fichier simultanément, ce qui est un cas très fréquent avec des applications non triviales. En outre cette attente doit être limitée le temps. Tout ceci peut s'articuler autour de l'API select, comme ceci (DemoSelect.cpp):

#include using namespace std; #include int main() { fd_set readset; struct timeval temps_max; char c; int result; int fd = 0; // stdin temps_max.tv_sec = 5; temps_max.tv_usec = 0; FD_ZERO(&readset); FD_SET(fd, &readset); while(1) { result = select(1, &readset, 0, 0, &temps_max); if(result == 0) { cout << "Timeout -- Fin du programme" << endl; return 0; } if(FD_ISSET(fd, &readset)) { read(fd, &c, sizeof(c)); if(c == 'q') { cout << c << endl; cout << "Fin du programme" << endl; return 0; } cout << c; temps_max.tv_sec = 5; temps_max.tv_usec = 0; } } return 0; }

L'ensemble des descripteurs doit être contenu dans une structure de données readset (nous n'attendons que des lectures sur les descripteurs, et nous renvoyons le lecteur à la documentation de select pour tous les autres cas de figure). Attention, là encore cette structure de données est opaque et doit être manipulée avec les macros FD_XXX prévues à cet effet. Dans notre exemple nous ne mettons que le descripteur de stdin (soit la valeur 0) dans l'ensemble, mais rien n'empêche d'en mettre d'autres. Enfin nous limitons le temps d'attente à 5 secondes. Bien remarquer que la structure timeval qui gère ce temps d'attente est réinitialisée à chaque lecture. Ceci est du au comportement particulier de Linux qui met dans la structure le temps non encore écoulé à l'issue de l'opération de lecture. Les autres systèmes UNIX restaurent la valeur de départ automatiquement... Enfin bien regarder le premier argument reçu par select. Ici nous passons 1. La valeur attendue est celle du plus grand descripteur de fichier de l'ensemble plus 1. Donc ici 0 + 1 = 1 :)

 

Signalfd, le signal devient un descripteur

Dans l'exemple précédent si nous devions installer un gestionnaire celui-ci ne pourrait pas travailler avec, par exemple, des variables locales à la boucle while. Nous serions alors contraints à utiliser des variables globales pour aboutir à du code assez laid il faut bien le dire. Ce qui manquait à Linux jusqu'ici était la possibilité d'attendre des événements sur plusieurs objets de types différents. De la même façon que sous Windows, avec l'API WaitForMultipleObjects, il est possible d'attendre aussi bien le déclenchement d'un timer qu'une opération de lecture/écriture ou encore le basculement d'un objet de synchronisation. La communauté Linux, toujours imaginative, a proposé plusieurs solutions pour traiter ce problème. Ce qui a parfois entrainé des discussions houleuses, événement fréquent quand des développeurs de talents défendent leurs idées. Passons sur les détails de toutes ces discussions :) Linus Torvalds, dans son rôle d'arbitre, a recommandé une solution "unixish" qui a débouché sur des APIs du nom de signalfd, eventfd et timerfd. Fondamentalement, avec cette approche, les signaux UNIX trouvent une correspondance avec un inode anonyme. Concrètement cela veut dire que l'on peut attendre un signal en lisant dans un descripteur de fichier avec l'API standard read. Nous allons montrer un exemple avec signalfd, mais avant cela nous ne saurions trop encourager le lecteur à s'intéresser de près à timerfd qui simplifie elle aussi considérablement la programmation. Pour utiliser ces nouvelles fonctionnalités, vous devez avoir au moins un noyau 2.6.22, et une glibc 2.8 au moins. Et encore avec celle-ci vous aurez sans doute un problème de compilation en C++ à cause d'un bug. Bug qui peut être contourné par l'infâme hack que vous verrez dans le code ci-dessous (DemoSignalfd.cpp), mais le mieux est encore d'avoir une glibc 2.9 dans laquelle le bug est normalement corrigé. Voici donc un exemple qui attend SIGTSTP en lisant dans un fichier, conjointement à l'attente d'une opération de lecture sur le canal d'entrée standard, le tout dans une limite de temps de 5 secondes.

#include #include // horrible hack pour // contourner un bug de // la glic 2.8 qui empêche // la compilation en C++ #undef __THROW #define __THROW #include #include #include using namespace std; int main() { int fd_stdin = 0; // stdin vaut toujours 0 int fd_signal; fd_set readset; sigset_t signal_mask; struct timeval temps_max; char c; int result; temps_max.tv_sec = 5; temps_max.tv_usec = 0; // Reset de l'enseble read pour select FD_ZERO(&readset); // ajout du descripteur de stdin FD_SET(fd_stdin, &readset); // pour que signalfd fonctionne, le signal // classique correspondant doit être // masqué sigemptyset(&signal_mask); sigaddset(&signal_mask, SIGTSTP); if(sigprocmask(SIG_BLOCK, &signal_mask, NULL) == -1) { cout << "Echec de l'appel à sigprocmask -- Fin du programme" << endl; return 0; } // Obtenir un descripteur de fichier // réceptionner le signal fd_signal = signalfd(-1, &signal_mask, 0); if(fd_signal == -1) { cout << "Echec de l'appel à signalfd -- Fin du programme" << endl; return 0; } // ajout de ce descripteur à l'ensemble // read de select FD_SET(fd_signal, &readset); result = select(fd_signal+1, &readset, 0, 0, &temps_max); // Si timeout if(result == 0) { cout << "Timeout" << endl; } // Si caractère lu sur le terminal if(FD_ISSET(fd_stdin, &readset)) { read(fd_stdin, &c, sizeof(c)); cout << "echo: " << c << endl;; cout << "Temps restant: " << temps_max.tv_sec << endl; } // Si ctrl-Z actionné if(FD_ISSET(fd_signal, &readset)) { struct signalfd_siginfo infos; read(fd_signal, &infos, sizeof(infos)); cout << "Recu signal fd " << "Signal: " << strsignal(infos.ssi_signo) << endl; cout << "Temps restant: " << temps_max.tv_sec << endl; } cout << "Fin du programme" << endl; close(fd_signal); }

Si tout ce qui précède a été compris, ce code ne présente pas de difficultés. Cependant on notera que pour que le mécanisme de signalfd fonctionne correctement, il est impératif que le mécanisme classique des signaux soit inhibé pour les signaux avec lesquels on travaille. Nous obtenons cela avec l'API sigprocmask. Enfin lorsqu'un événement est reçu par select, la macro FD_ISSET permet de tester lequel il s'agit. Et dans l'exemple ci-dessus, lorsque Crtl-Z est actionné, nous pouvons y réagir dans le corps de l'application en ayant accès à d'éventuelles variables locales au lieu de nous livrer à des contorsions depuis un gestionnaire "excentré". Le revers de la médaille ? signalfd, eventfd et timerfd sont spécifiques à Linux et à lui seul. Impossible d'écrire du code UNIX portable avec eux, hélas.

© Frédéric Mazué Informatique

Fichier attachéTaille
SourcesDemosSignaux.zip2.88 Ko