Comprendre le fonctionnement de select()

Publié par cpb
déc 27 2013

Fonctionnement de select()

Dans un commentaire récent, Thomas m’interrogeait sur le fonctionnement de l’appel-système select() lorsqu’il est invoqué pour surveiller des entrées GPIO par l’intermédiaire du système de fichiers /sys.

C’est effectivement un sujet intéressant, un peu complexe, que je vais essayer de développer ici. Nous allons commencer par examiner comment select() fonctionne pour des fichiers spéciaux représentant des périphériques classiques puis verrons comment il se comporte lorsqu’il est invoqué pour surveiller un fichier de sysfs.

Utilisation de select()

L’appel-système select(), comme son homologue poll(), sert à attendre passivement qu’une condition soit réalisée sur un ou plusieurs descripteurs de fichier que l’on peut surveiller simultanément. Les descripteurs représentent des fichiers au comportement dynamique (sockets, tubes, périphériques, etc.) et les conditions attendues se traduisent par la présence de données disponibles pour la lecture, la possibilité d’envoyer des données en écriture ou l’occurrence d’une situation exceptionnelle (par exemple l’arrivée de données urgentes hors-bande dans un flux TCP/IP).

La grande force de select() est que l’attente est passive : tant qu’aucune condition n’est réalisée, la tâche appelante est endormie. Elle peut néanmoins indiquer un délai maximal d’attente, un timeout, au-delà de laquelle elle sera automatiquement réveillée.

Voici un exemple extrait de mon livre « Développement système sous Linux » (chapitre 25) dans lequel un processus crée dix processus enfants qui lui envoient des données dans des tubes (pipes) avec des périodes différentes. Le processus parent est en attente passive sur les dix descripteurs simultanément.

exemple-select.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>

#define NB_FILS 10

int main (void)
{
        int tube[NB_FILS][2];
        int i, fils;
        char c = 'c';
        fd_set ensemble;

        for (i = 0; i < NB_FILS; i ++)
                if (pipe (tube[i]) < 0) {
                        perror("pipe");
                        exit(EXIT_FAILURE);
                }
        for (fils = 0; fils < NB_FILS; fils ++)
                if (fork() == 0)
                        break;
        for (i = 0; i < NB_FILS; i ++)
                if (fils == NB_FILS) {
                        // On est dans le pere
                        close(tube[i][1]);
                } else {
                        close(tube[i][0]);
                        if (i != fils)
                                close(tube[i][1]);
                }
        if (fils == NB_FILS) {
                // Processus pere
                while (1) {
                        FD_ZERO(& ensemble);
                        for (i = 0; i < NB_FILS; i ++)
                                FD_SET(tube[i][0], & ensemble);
                        if (select(FD_SETSIZE, & ensemble, NULL, NULL, NULL) <= 0) {
                                perror("select");
                                break;
                        }
                        for (i = 0; i < NB_FILS; i ++)
                                if (FD_ISSET(tube[i][0], & ensemble)) {
                                        fprintf(stdout, "%d ", i);
                                        fflush(stdout);
                                        read(tube[i][0], & c, 1);
                                }
                }
        } else {
                while (1) {
                        usleep((fils + 1) * 1000000);
                        write(tube[fils][1], & c, 1);
                }
        }
        return EXIT_SUCCESS;
}

 

Les waitqueues du noyau

Avant de détailler le fonctionnement interne de select(), faisons un petit point sur le principe des opérations bloquantes que l’on peut réaliser sur un périphérique. Lorsqu’une tâche appelle une fonction de lecture sur un périphérique, la méthode read() du driver correspondant est invoquée.

Si des données sont déjà disponibles, la méthode read() les renvoie immédiatement et se termine.

Si aucune donnée n’est disponible, la fonction read() va inscrire la tâche appelante dans une waitqueue (file d’attente) et l’endormir. Pour cela, elle bascule l’état de la tâche à Sleeping et invoque directement l’ordonnanceur (fonction schedule() du kernel). Ce dernier peut alors activer une autre tâche ou basculer le processeur en mode idle.

Dès que des données se présentent en entrée sur le périphérique, une interruption matérielle est déclenchée. Le handler associé – qui fait partie du driver – est invoqué, et il réveille une tâche dans sa file d’attente (en la passant à l’état Runnable). Lors de la prochaine invocation de l’ordonnanceur, ce dernier pourra choisir d’activer la tâche réveillée, et la fonction read() continuera alors son exécution en renvoyant à l’espace utilisateur les données fraîchement arrivées.

Implémentation de select()

L’appel-système select() est bien entendu implémenté dans le kernel. Lorsqu’une tâche l’invoque depuis l’espace utilisateur, le processeur bascule en mode kernel et se branche sur la fonction do_select() de fs/select.c (noyau 3.12) après être passé par la routine sys_select() du même fichier – dont le nom est construit par la macro SYSCALL_DEFINE5(select,...).

Dans cette fonction do_select(), le kernel invoque successivement les méthodes poll() de tous les drivers concernés par les descripteurs de fichiers des différents ensembles.

La méthode poll() d’un driver est une fonction (non-bloquante) qui a deux objectifs :

  • renvoyer une valeur indiquant l’état du périphérique quant à la disponibilité de données en entrée, la capacité d’écrire des données en sortie ou l’occurrence de conditions exceptionnelles,
  • inscrire dans une table qui lui a été passée en argument la liste des waitqueues dans lesquelles il convient de s’inscrire pour être notifié de la modification de l’état de ce périphérique.

Après avoir invoqué les méthodes poll() de tous les périphériques, la fonction do_select() va endormir passivement la tâche appelante – avec poll_schedule_timeout() – en attente d’une notification sur l’une des waitqueues de la table (à moins bien entendu qu’un des drivers ait indiqué que les conditions attendues sont déjà réalisées, auquel cas do_select() se termine tout de suite).

Dès que l’un des handlers d’interruption des drivers est invoqué par le matériel concerné, il réveille les tâches en attente dans sa waitqueue. Ce qui réveille la tâche endormie dans do_select(). Cette dernière re-balaye alors les méthodes poll() de tous les descripteurs afin de vérifier les états des différents périphériques et choisit éventuellement de revenir dans l’espace utilisateur si les conditions attendues sont réunies.

Implémentation de select()

 Utilisation de select() dans /sys

Le principe de fonctionnement de select() lorsqu’il sert à surveiller les changements d’état d’une broche GPIO par l’intermédiaire de /sys (comme on le voit dans cet article) est identique. L’attente se fait en utilisant le descripteur du fichier value mais celui ci ne sert ici que de support pour accéder aux fonctionnalités de select(), il n’a pas de contenu physique sur le disque.

Dans la fonction gpio_setup_irq() du kernel (drivers/gpio/gpiolib.c) un handler nommé gpio_sysfs_irq() est installé qui notifiera les tâches en attente de modification de l’état du fichier value associé au GPIO. Son implémentation (noyau 3.12) est très simple.

static irqreturn_t gpio_sysfs_irq(int irq, void *priv)
{
    struct sysfs_dirent *value_sd = priv;

    sysfs_notify_dirent(value_sd);
    return IRQ_HANDLED;
}

 

La méthode poll() est celle de sysfs, elle est implémentée dans fs/sysfs/file.c et nommée sysfs_poll().  On notera que celle-ci renvoie POLLPRI|POLLERR, ce qui explique pourquoi c’est le troisième ensemble de descripteurs de select(), celui qui attend des conditions exceptionnelles qu’il convient d’utiliser pour les GPIO via /sys.

Conclusion

Récapitulons ce qui se passe lorsqu’un programme comme gpio-select.c de cet article s’exécute.

  • L’invocation select() du programme transmet le contrôle à l’appel-système sys_select() du kernel().
  • Ce dernier invoque la méthode poll() de sysfs qui lui indique si une condition attendue est réalisée, et inscrit sa waitqueue (od->poll) dans la table wait reçue en argument.
  • Si la condition n’est pas réalisée, sys_select() endort la tâche en attente passive d’une notification sur l’une des waitqueue de la table.

Lorsqu’un changement d’état du type attendu (fronts montant et/ou descendant) se produit :

  • Une interruption est générée par le contrôleur GPIO.
  • Dans le handler d’interruption, la notification des tâches en attente dans la waitqueue réveille celle se trouvant dans sys_select().
  • Après avoir vérifié si une condition POLL_PRI est bien réalisée, sys_select() revient dans l’espace utilisateur, laissant notre programme continuer son exécution.

 

Pour conclure, nous voyons que l’attente est bien passive, il n’y a aucune scrutation active consommant du CPU. Lors d’un changement d’état d’un broche GPIO d’entrée, les tâches en attente dans select() sont immédiatement réveillées (leur activation dépend de leur priorité) par la notification provenant du handler d’interruption.

2 Réponses

  1. Fabrice dit :

    Bonjour Christophe,
    merci pour ce superbe article fort intéressant et vraiment très pédagogique.
    J’ai encore appris quelque chose aujourd’hui sur le fonctionnement de Linux :-)

    Merci pour ces articles de fond qui restent quand même abordable pour le plus grand nombre et bonne année 2014 !

  2. Mohamed dit :

    Bonjour M Christphe,

    Serait-t-il possible de nous écrire un article qui nous aidera à comprendre la mise en place du moteur graphique OpenGL ES 2.0 sur la BBB (BeagleBone Black) ou la Raspberry par exemple ?

    Merci d’avance !

URL de trackback pour cette page