Envoi d’un signal vers un processus depuis le kernel

Publié par cpb
Mar 21 2012

Nous avons examiné récemment le temps de réveil d’une tâche endormie dans un appel-système. Je voulais compléter cette expérience en m’intéressant au passage d’un signal depuis le noyau vers l’espace utilisateur. Nous allons plus particulièrement mesurer le temps d’activation d’un processus lorsqu’un signal temps réel lui est envoyé depuis le noyau.

Module du noyau

Le petit module ci-dessous déclare un nouveau périphérique qui apparaîtra sous forme de fichier spécial dans /dev/. Lorsqu’un processus ouvre ce fichier, la structure task_struct qui le représente – pointée par la variable globale current au moment de l’appel système open() – est mémorisée. Un timer, déclenché tous les dixièmes de seconde, enverra alors un signal SIGRTMAX à ce processus jusqu’à ce qu’il referme le fichier spécial.

En accompagnement d’un signal temps-réel, il est possible de transmettre une union sigval_t contenant soit un entier soit un pointeur. Nous allons transmettre un entier contenant la partie “nanosecondes” de l’heure actuelle.

Voici le code du module signal-sur-timer.c. L’archive contenant les codes cource se trouve ici.

#include <linux/interrupt.h>
#include <linux/version.h>
#include <linux/device.h>
#include <linux/module.h>
#include <linux/sched.h>
#include <linux/cdev.h>
#include <linux/fs.h>

#include <asm/uaccess.h>

static dev_t          dev_reveil;
static struct cdev    cdev_reveil;
static struct class * class_reveil = NULL;

struct timer_list timer_reveil;
static void timer_function(unsigned long);

static int open_reveil  (struct inode *, struct file *);
static int flush_reveil (struct file *,  fl_owner_t);

static struct file_operations fops_reveil = {
    .owner   =  THIS_MODULE,
    .open    =  open_reveil,
    .flush   =  flush_reveil,
};

static int __init init_reveil (void)
{
    int erreur;

    erreur = alloc_chrdev_region(& dev_reveil, 0, 1, THIS_MODULE->name);
    if (erreur < 0)
        return erreur;

    class_reveil = class_create(THIS_MODULE, "classe-signal");
    if (IS_ERR(class_reveil)) {
        unregister_chrdev_region(dev_reveil, 1);
        return -EINVAL;
    }
    device_create(class_reveil, NULL, dev_reveil,
                  NULL, THIS_MODULE->name);

    cdev_init(& cdev_reveil, & fops_reveil);

    erreur = cdev_add(& cdev_reveil, dev_reveil, 1);
    if (erreur != 0) {
        device_destroy(class_reveil, dev_reveil);
        class_destroy(class_reveil);
        unregister_chrdev_region(dev_reveil, 1);
        return erreur;
    }
    init_timer(& timer_reveil);
    timer_reveil.function = timer_function;
    timer_reveil.expires  = HZ;
    add_timer(& timer_reveil);
    return 0;
}

static void __exit exit_reveil (void)
{
    del_timer(& timer_reveil);
    cdev_del(& cdev_reveil);
    device_destroy(class_reveil, dev_reveil);
    class_destroy(class_reveil);
    unregister_chrdev_region(dev_reveil, 1);
}

static struct task_struct * task_reveil = NULL;

static int open_reveil(struct inode * ind, struct file * filp)
{
    task_reveil = current;
    return 0;
}

static int flush_reveil(struct file * fil,  fl_owner_t id)
{
    task_reveil = NULL;
    return 0;
}

static void timer_function(unsigned long unused)
{
    struct siginfo info;
    struct timespec ts;

    ktime_get_real_ts(& ts);

    info.si_signo = SIGRTMAX;
    info.si_errno = 0;
    info.si_code = SI_QUEUE;
    info.si_pid = 0;
    info.si_int = ts.tv_nsec;
    if (task_reveil != NULL)
        send_sig_info(SIGRTMAX, & info, task_reveil);

    mod_timer(& timer_reveil, jiffies + HZ/10);
}

module_init(init_reveil);
module_exit(exit_reveil);
MODULE_LICENSE("GPL");

Nous pouvons compiler ce programme et le charger. Pour savoir sur quel CPU se déclenchera le timer, je vais utiliser la commande shell taskset au moment du chargement du module.

# taskset -c 0 insmod ./signal-sur-timer.ko 
# ls /sys/class/
ata_device  bdi  classe-signal [...]
# ls /sys/class/classe-signal/
signal_sur_timer
# ls /sys/class/classe-signal/signal_sur_timer
dev  power  subsystem  uevent
# cat /sys/class/classe-signal/signal_sur_timer/dev
250:0
# ls -l /dev/signal_sur_timer
crw------- 1 root root 250, 0 2012-03-22 07:11 /dev/signal_sur_timer
#

 Programme d’attente du signal

Le processus  qui recevra le signal dans l’espace utilisateur va également lire l’heure et calculer la différence avec celle reçue depuis le noyau. Comme nous n’avons pas transmis le champ “secondes” de l’heure, mais seulement le complément en nanosecondes, il faudra gérer un éventuel débordement en rajoutant un milliard de secondes.

Le programme s’arrêtera automatiquement au bout de 1000 signaux (1 minute 40 secondes, car les signaux surviennent tous les dixièmes de secondes). La durée entre le déclenchement du timer noyau et l’activation du processus utilisateur est affichée sur la sortie standard.

attente-signal.c:
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>

void handler_sigrtmax(int num, siginfo_t * info, void * unused)
{
    static int compteur = 0;
    struct timespec ts;
    long int duree;
    clock_gettime(CLOCK_REALTIME, & ts);

    if (info != NULL) {
        duree = ts.tv_nsec - info->si_int;
        if (duree < 0)
            duree += 1000000000;
    }
    fprintf(stdout, "%ldn", duree);
    compteur ++;
    if (compteur == 1000)
        exit(0);
}

int main(int argc, char * argv[])
{
    int fd;
    struct sigaction action;

    sigfillset(& action.sa_mask);
    action.sa_sigaction = handler_sigrtmax;
    action.sa_flags = SA_SIGINFO;
    sigaction(SIGRTMAX, & action, NULL);

    if ((argc != 2) || ((fd = open(argv[1], O_RDONLY)) < 0)) {
        fprintf(stderr, "usage: %s fichier-spcialn", argv[0]);
        exit(EXIT_FAILURE);
    }

    while (1) {
        pause();
    }
    return EXIT_FAILURE;
}

On notera que dans le corps principal du programme, celui-ci reste endormi en permanence en attente du signal suivant.

Premiers résultats

Avant d’exécuter notre programme, nous allons fixer la fréquence CPU des deux coeurs afin d’obtenir des résultats plus constants.

# echo userspace > /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
# echo userspace > /sys/devices/system/cpu/cpu1/cpufreq/scaling_governor
# cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq > /sys/devices/system/cpu/cpu0/cpufreq/scaling_setspeed
# cat /sys/devices/system/cpu/cpu1/cpufreq/scaling_max_freq > /sys/devices/system/cpu/cpu1/cpufreq/scaling_setspeed
#

Exécutons-le une première fois, sur un CPU différent du timer.

# taskset -c 1 ./attente-signal /dev/signal_sur_timer > resultats-1.txt
# cat resultats-1.txt
43131
30878
101946
137725
30878
31858
29898
27447
31369
31858
32348
34309
133315
179877
32838
[...]
30388
29897
29408
27447
#

Pour analyser les résultats, je vais utiliser quelques scripts que j’ai développé pour mon livre “Solutions temps réel sous Linux” (à paraître très prochainement). Ils seront disponibles dans quelques jours avec les sources des exemples du livre.

# calculer-statistiques < resultats-1.txt
 Nb mesures = 1000
 Minimum = 22055
 Maximum = 1631636
 Moyenne = 46002
 Ecart-type = 66924
# calculer-histogramme 100 0 3000000 < resultats-1.txt > histo-1.txt
# afficher-histogramme.sh histo-1.txt "Attente signal - CPU différents"
#

Figure 1 - Activation d'un processus par un signal du kernel

Ceci nous fournit une première figure avec la durée d’activation en abscisse et en ordonnée le nombre d’activations observées (l’échelle est logarithmique).

Sur la figure 1, nous observons que l’essentiel des temps d’activation sont inférieurs à 500 microsecondes (500000 nanosecondes), mais qu’il y a des pointes jusqu’à 1631 microsecondes, soit 1,6 millisecondes.

La moyenne s’établit à 46002 nanosecondes, soit 46 microsecondes.

Réitérons cette expérience en exécutons le processus en attente sur le même CPU que le timer du kernel.

# calculer-statistiques < resultats-2.txt
 Nb mesures = 1000
 Minimum = 15194
 Maximum = 3102998
 Moyenne = 39288
 Ecart-type = 113346
#

Nous remarquons que la durée maximale a augmenté, ce qui nous oblige à allonger légèrement l’axe des abscisses pour notre figure.

# calculer-histogramme 100 0 3200000 < resultats-2.txt > histo-2.txt
# afficher-histogramme.sh histo-2.txt "Attente signal - Même CPU"
#

 

Figure 2 - Activation d'un processus par un signal kernel - Même CPU

Figure 2 - Activation d'un processus par un signal kernel - Même CPU

La figure montre quelques activations sensiblement retardées, ce qui est typique d’une exécution sous un ordonnancement temps partagé. Toutefois, nous pouvons remarquer que la valeur moyenne est meilleure (39 microsecondes contre 46 microsecondes précédemment).

En outre, la figure présente un meilleur regroupement des données sur la gauche du graphique, la plupart des activations étant survenues dans le premier intervalle du graphique.

Nous pouvons en conclure que l’activation par un signal est plus efficace si le code émetteur et le processus récepteur s’exécutent sur le même CPU.

Pour dépasser les limites de l’ordonnancement temps partagé, nous pouvons recommencer notre mesure en passant le processus récepteur en temps réel grâce la commande shell chrt.

# taskset -c 0 chrt -f 40  ./attente-signal /dev/signal_sur_timer > resultats-3.txt
# calculer-statistiques < resultats-3.txt
 Nb mesures = 1000
 Minimum = 20096
 Maximum = 212225
 Moyenne = 47692
 Ecart-type = 37429
#

La valeur moyenne est proche de la première mesure, mais la valeur maximale est bien meilleure, nous allons “zoomer” sensiblement l’axe des abscisses de notre figure.

# calculer-histogramme 100 0 250000 < resultats-3.txt > histo-3.txt
# afficher-histogramme.sh histo-3.txt "Attente signal - Temps réel"
#
Figure 3 - Activation d'un processus temps réel par un signal kernel

Figure 3 - Activation d'un processus temps réel par un signal kernel

Cette fois, la plupart des mesures sont en-dessous de 150 microsecondes, et seules quelques unes se trouvent dans la zone entre 150 et 212 microsecondes. Nous nous rapprochons des performances attendues sur un système temps réel. Notons que ces expériences sont menées sur un système avec un noyau 3.0 générique provenant d’une distribution classique. Les résultats avec un noyau modifié par le patch Linux-rt seraient probablement meilleurs (je ferai l’essai dans quelques jours et posterai les résultats ici).

Notification de processus actif

Dans cette première étape, nous avons laissé notre processus endormi dans un appel système pause(), dont il n’émerge que pour exécuter le handler du signal reçu avant de se rendormir. Mais l’intérêt d’un signal est avant tout sa possibilité de notification asynchrone. Autrement dit, il n’est pas nécessaire d’être en attente bloquante. Le processus peut très bien réaliser des opérations de calcul ou de traitement des données, dès que le signal lui sera délivré, le programme déroutera son exécution pour se brancher sur le gestionnaire de signal avant de reprendre son travail comme précédemment.

Dans notre nouvelle expérience, nous allons simplement modifier la fin de la fonction main(), ainsi.

boucle-et-signal.c:
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
[...]
int main(int argc, char * argv[])
{
    [...]
    while (1) {
        ;
    }
    return EXIT_FAILURE;
}

Première exécution, comme précédemment, commençons par lancer le processus sur un autre CPU que celui du timer du kernel.

# taskset -c 1 ./boucle-et-signal /dev/signal_sur_timer > resultats-4.txt
# cat resultats-4.txt
24507
12743
10293
8822
10293
8822
9802
8823
9803
[...]
10100065
22056
42641
17154
18135
18625
18135
20096
18625
18135
18134
119101
119101
119591
117140
14704
12743
# calculer-statistiques < resultats-4.txt
 Nb mesures = 1000
 Minimum = 8332
 Maximum = 10100065
 Moyenne = 28521
 Ecart-type = 326958
#

Les résultats sont sensiblement meilleurs en moyenne que précédemment, c’est normal car le processus étant actif, le processeur ne peut pas se mettre en veille et la délivrance du signal est d’autant plus efficace.
Toutefois la durée maximale a bien empiré : plus de 10ms ! Ceci est également normal car le processus s’exécute sous un ordonnancement temps partagé.

Relançons-le en temps réel, sur le même CPU que le timer du kernel.

# taskset -c 0 chrt -f 40  ./boucle-et-signal /dev/signal_sur_timer > resultats-5.txt
# calculer-statistiques < resultats-5.txt
 Nb mesures = 1000
 Minimum = 2940
 Maximum = 328386
 Moyenne = 20025
 Ecart-type = 37036
#
Figure 4 - Signal sur processus actif en temps-réel

Figure 4 - Signal sur processus actif en temps-réel

Nous voyons sur la figure 4 que la majorité des signaux sont traités en moins de 120 microsecondes, et que seules quelques exceptions peuvent être retardées jusqu’à 328 microsecondes.

Il faudrait laisser fonctionner le processus beaucoup plus longtemps, avec une charge système importante pour s’assurer de la durée maximale d’activation d’un processus par un signal provenant du kernel. Toutefois, je pense que l’ordre de grandeur (quelques centaines de microsecondes) est celui que l’on peut attendre avec Linux vanilla.

Nous pourrions probablement obtenir des résultats similaires avec un noyau contenant le patch Linux-rt, voire meilleurs en ce qui concerne les cas extrêmes.

Conclusion

En conclusion je pense que le réveil ou l’activation d’un processus par un signal provenant du kernel, pour le notifier par exemple de l’occurrence d’une interruption matérielle, est une alternative à la mise en sommeil sur un appel système bloquant – read(), ioctl(), poll()… – qu’il faut considérer lors de la mise au point d’un système interactif devant répondre à des événements externes.

 

URL de trackback pour cette page