Skip to content

Latest commit

 

History

History
392 lines (301 loc) · 17.1 KB

File metadata and controls

392 lines (301 loc) · 17.1 KB

🔝 Retour au Sommaire

22.3.1 select et poll : Interfaces POSIX classiques

Introduction

select et poll sont les deux plus anciennes interfaces de multiplexage I/O disponibles sous Unix. Elles partagent le même modèle — readiness notification en level-triggered — et la même complexité algorithmique O(n), mais diffèrent par leur API et leurs limitations pratiques. Nous les traitons ensemble car elles représentent la même génération conceptuelle, et comprendre leurs défauts est indispensable pour apprécier les mécanismes plus modernes (epoll, io_uring) qui leur ont succédé.

⚠️ En 2026, select et poll ne sont plus recommandés pour des serveurs de production sous Linux. Leur étude reste pertinente pour trois raisons : la lecture de code legacy, la portabilité vers des systèmes non-Linux (BSD, macOS, Solaris), et la compréhension progressive des concepts avant d'aborder epoll.


select : la doyenne du multiplexage

Origine et principe

select est apparue dans BSD 4.2 en 1983. C'est l'interface de multiplexage la plus ancienne et la plus universellement disponible. Son principe : l'appelant fournit trois ensembles de descripteurs (lecture, écriture, exceptions) et select bloque jusqu'à ce qu'au moins un descripteur soit prêt, qu'un timeout expire, ou qu'un signal soit reçu.

Signature de l'appel système

#include <sys/select.h>

int select(int nfds,
           fd_set *readfds,      // Descripteurs surveillés en lecture
           fd_set *writefds,     // Descripteurs surveillés en écriture
           fd_set *exceptfds,    // Descripteurs surveillés pour exceptions
           struct timeval *timeout);

Les paramètres méritent une explication détaillée :

  • nfds : la valeur du plus grand descripteur surveillé plus un. Ce n'est pas le nombre de descripteurs, mais une borne supérieure. Le noyau itère de 0 à nfds - 1 pour vérifier chaque bit dans les ensembles — c'est l'une des sources d'inefficacité.
  • readfds : ensemble des descripteurs pour lesquels on veut savoir si une lecture est possible sans bloquer. Cela inclut aussi la détection de nouvelles connexions sur un socket d'écoute (car accept est une forme de « lecture »).
  • writefds : ensemble des descripteurs pour lesquels on veut savoir si une écriture est possible sans bloquer.
  • exceptfds : rarement utilisé, détecte les conditions exceptionnelles (données out-of-band TCP par exemple).
  • timeout : durée maximale d'attente. NULL pour bloquer indéfiniment, une struct timeval à zéro pour un poll immédiat (non bloquant).

La valeur de retour est le nombre total de descripteurs prêts, 0 en cas de timeout, ou -1 en cas d'erreur.

Manipulation des fd_set

Les fd_set se manipulent exclusivement via quatre macros :

fd_set fds;

FD_ZERO(&fds);          // Initialise l'ensemble à vide (obligatoire)  
FD_SET(fd, &fds);       // Ajoute fd à l'ensemble  
FD_CLR(fd, &fds);       // Retire fd de l'ensemble  
FD_ISSET(fd, &fds);     // Teste si fd est dans l'ensemble (après select)  

🔥 Piège critique : select modifie les fd_set passés en paramètre. Après l'appel, seuls les descripteurs effectivement prêts restent dans l'ensemble. Il faut donc reconstruire les ensembles à chaque itération de la boucle événementielle.

Exemple complet : serveur echo avec select

#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
#include <vector>
#include <algorithm>
#include <print>

bool set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags == -1) return false;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK) != -1;
}

int create_server(uint16_t port) {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) return -1;

    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    sockaddr_in addr{};
    addr.sin_family      = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port        = htons(port);

    if (bind(server_fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == -1) {
        close(server_fd);
        return -1;
    }
    if (listen(server_fd, SOMAXCONN) == -1) {
        close(server_fd);
        return -1;
    }

    set_nonblocking(server_fd);
    return server_fd;
}

int main() {
    constexpr uint16_t port = 8080;

    int server_fd = create_server(port);
    if (server_fd == -1) {
        std::print(stderr, "Erreur création serveur\n");
        return 1;
    }
    std::print("Serveur select en écoute sur le port {}\n", port);

    // Liste des descripteurs clients connectés
    std::vector<int> client_fds;

    while (true) {
        // ── Étape 1 : Reconstruire les fd_set à chaque itération ──
        fd_set read_fds;
        FD_ZERO(&read_fds);
        FD_SET(server_fd, &read_fds);

        int max_fd = server_fd;
        for (int cfd : client_fds) {
            FD_SET(cfd, &read_fds);
            max_fd = std::max(max_fd, cfd);
        }

        // ── Étape 2 : Attendre un événement ──
        // Timeout de 30 secondes (recréé à chaque itération car
        // select modifie aussi le timeout sur certains systèmes)
        timeval timeout{.tv_sec = 30, .tv_usec = 0};

        int ready = select(max_fd + 1, &read_fds, nullptr, nullptr, &timeout);
        if (ready == -1) {
            if (errno == EINTR) continue;  // Interrompu par un signal
            std::print(stderr, "Erreur select : {}\n", strerror(errno));
            break;
        }
        if (ready == 0) {
            std::print("Timeout — aucune activité depuis 30s\n");
            continue;
        }

        // ── Étape 3 : Nouvelle connexion ? ──
        if (FD_ISSET(server_fd, &read_fds)) {
            int client_fd = accept(server_fd, nullptr, nullptr);
            if (client_fd != -1) {
                set_nonblocking(client_fd);
                client_fds.push_back(client_fd);
                std::print("Nouveau client fd={} (total: {})\n",
                           client_fd, client_fds.size());
            }
        }

        // ── Étape 4 : Données des clients existants ──
        // On itère en sens inverse pour pouvoir supprimer sans invalider les indices
        for (int i = static_cast<int>(client_fds.size()) - 1; i >= 0; --i) {
            int cfd = client_fds[i];
            if (!FD_ISSET(cfd, &read_fds)) continue;

            char buf[4096];
            ssize_t n = recv(cfd, buf, sizeof(buf), 0);
            if (n <= 0) {
                // n == 0 : déconnexion propre
                // n <  0 : erreur (mais pas EAGAIN, car select a signalé le fd)
                std::print("Client fd={} déconnecté\n", cfd);
                close(cfd);
                client_fds.erase(client_fds.begin() + i);
            } else {
                // Echo : renvoyer les données reçues
                send(cfd, buf, n, 0);
            }
        }
    }

    // Nettoyage
    for (int cfd : client_fds) close(cfd);
    close(server_fd);
    return 0;
}

Limitations de select

Les limitations de select sont bien documentées et constituent la motivation historique de chaque mécanisme successeur :

Limite dure sur FD_SETSIZE. La macro FD_SETSIZE est définie à 1024 sur la plupart des systèmes Linux. Cela signifie que select ne peut pas surveiller de descripteur dont la valeur numérique dépasse 1023. Ce n'est pas une limite sur le nombre de connexions, mais sur la valeur du descripteur — et sous Linux, les descripteurs sont attribués séquentiellement. Dès que votre processus dépasse ~1000 fichiers ouverts (sockets, fichiers de log, pipes...), select devient inutilisable. On peut recompiler avec un FD_SETSIZE plus grand, mais cela reste un palliatif fragile.

Complexité O(n) systématique. À chaque appel, le noyau parcourt linéairement tous les descripteurs de 0 à nfds - 1, même si seuls quelques-uns sont surveillés. Côté userspace, il faut aussi itérer sur tous les descripteurs pour appeler FD_ISSET et trouver ceux qui sont prêts. Avec 10 000 descripteurs dont 3 sont actifs, le travail est proportionnel à 10 000, pas à 3.

Reconstruction obligatoire des ensembles. Puisque select écrase les fd_set, il faut les reconstruire intégralement avant chaque appel. Cela représente une copie mémoire proportionnelle au nombre de descripteurs à chaque itération.

Le timeout peut être modifié. Sur Linux, select met à jour la struct timeval pour indiquer le temps restant. Ce comportement n'est pas portable (POSIX le laisse indéfini). Il faut recréer le timeout à chaque itération pour un comportement prévisible.


poll : select sans la limite FD_SETSIZE

Motivation

poll, apparue dans System V Release 3 (1986) et standardisée par POSIX, corrige la limitation la plus gênante de select : la borne FD_SETSIZE. Au lieu de bitsets de taille fixe, poll utilise un tableau de structures pollfd de taille arbitraire. L'API est aussi plus propre : un seul tableau au lieu de trois fd_set, et les événements demandés sont séparés des événements retournés.

Signature

#include <poll.h>

int poll(struct pollfd *fds,   // Tableau de descripteurs à surveiller
         nfds_t nfds,          // Nombre d'éléments dans le tableau
         int timeout);         // Timeout en millisecondes (-1 = infini)

struct pollfd {
    int   fd;       // Descripteur de fichier
    short events;   // Événements demandés (entrée)
    short revents;  // Événements observés (sortie, rempli par le noyau)
};

La séparation events / revents est une amélioration majeure par rapport à select : le noyau écrit les résultats dans revents sans toucher à events, ce qui évite de reconstruire le tableau à chaque appel.

Constantes d'événements

POLLIN      // Données disponibles en lecture  
POLLOUT     // Écriture possible sans blocage  
POLLPRI     // Données urgentes (out-of-band)  
POLLERR     // Erreur sur le descripteur    (revents uniquement)  
POLLHUP     // Déconnexion du pair          (revents uniquement)  
POLLNVAL    // Descripteur invalide          (revents uniquement)  

POLLERR, POLLHUP et POLLNVAL sont toujours surveillés implicitement — inutile de les spécifier dans events, ils apparaissent dans revents si la condition se produit.

Exemple complet : serveur echo avec poll

#include <poll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
#include <vector>
#include <print>

bool set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags == -1) return false;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK) != -1;
}

int create_server(uint16_t port) {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) return -1;

    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    sockaddr_in addr{};
    addr.sin_family      = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port        = htons(port);

    if (bind(server_fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == -1) {
        close(server_fd);
        return -1;
    }
    if (listen(server_fd, SOMAXCONN) == -1) {
        close(server_fd);
        return -1;
    }

    set_nonblocking(server_fd);
    return server_fd;
}

int main() {
    constexpr uint16_t port = 8080;

    int server_fd = create_server(port);
    if (server_fd == -1) {
        std::print(stderr, "Erreur création serveur\n");
        return 1;
    }
    std::print("Serveur poll en écoute sur le port {}\n", port);

    // Le tableau de pollfd : le serveur est toujours en première position
    std::vector<pollfd> poll_fds;
    poll_fds.push_back({.fd = server_fd, .events = POLLIN, .revents = 0});

    while (true) {
        // ── Étape 1 : Attendre un événement ──
        int ready = poll(poll_fds.data(),
                         static_cast<nfds_t>(poll_fds.size()),
                         30'000);  // 30 secondes

        if (ready == -1) {
            if (errno == EINTR) continue;
            std::print(stderr, "Erreur poll : {}\n", strerror(errno));
            break;
        }
        if (ready == 0) {
            std::print("Timeout — aucune activité depuis 30s\n");
            continue;
        }

        // ── Étape 2 : Nouvelle connexion sur le socket serveur ? ──
        if (poll_fds[0].revents & POLLIN) {
            int client_fd = accept(server_fd, nullptr, nullptr);
            if (client_fd != -1) {
                set_nonblocking(client_fd);
                poll_fds.push_back({.fd = client_fd, .events = POLLIN, .revents = 0});
                std::print("Nouveau client fd={} (total: {})\n",
                           client_fd, poll_fds.size() - 1);
            }
        }

        // ── Étape 3 : Traiter les clients (indices 1 à N) ──
        for (size_t i = poll_fds.size() - 1; i >= 1; --i) {
            auto& pfd = poll_fds[i];

            // Déconnexion ou erreur
            if (pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) {
                std::print("Client fd={} : erreur ou déconnexion\n", pfd.fd);
                close(pfd.fd);
                poll_fds.erase(poll_fds.begin() + static_cast<long>(i));
                continue;
            }

            // Données disponibles
            if (pfd.revents & POLLIN) {
                char buf[4096];
                ssize_t n = recv(pfd.fd, buf, sizeof(buf), 0);
                if (n <= 0) {
                    std::print("Client fd={} déconnecté\n", pfd.fd);
                    close(pfd.fd);
                    poll_fds.erase(poll_fds.begin() + static_cast<long>(i));
                } else {
                    send(pfd.fd, buf, n, 0);
                }
            }
        }
    }

    for (auto& pfd : poll_fds) close(pfd.fd);
    return 0;
}

Avantages de poll par rapport à select

  • Pas de limite FD_SETSIZE : le tableau de pollfd est alloué dynamiquement, sa taille n'est bornée que par la mémoire et la limite ulimit -n du système.
  • Pas de reconstruction des entrées : le noyau écrit dans revents sans altérer events, donc le tableau peut être réutilisé tel quel.
  • API plus explicite : les événements sont des flags nommés plutôt que trois ensembles de bits distincts.
  • Le timeout est un simple entier en millisecondes, sans les subtilités de struct timeval.

Limitations partagées avec select

Malgré ses améliorations, poll conserve le défaut fondamental de select : la complexité O(n).

À chaque appel, le noyau parcourt l'intégralité du tableau poll_fds pour vérifier l'état de chaque descripteur, quel que soit le nombre de descripteurs effectivement actifs. Côté userspace, il faut également itérer sur tout le tableau pour inspecter les revents. Avec 50 000 descripteurs dans le tableau et 5 actifs, le travail est proportionnel à 50 000 — pas à 5.

De plus, le tableau entier est copié du userspace vers le kernel à chaque appel poll, puis recopié en retour. Cette double copie a un coût non négligeable en mémoire et en bande passante mémoire lorsque le nombre de descripteurs croît.


Comparaison synthétique select vs poll

Critère select poll
Limite de descripteurs FD_SETSIZE (1024 par défaut) Aucune (limité par ulimit -n)
Structure de données 3 bitsets fd_set 1 tableau de pollfd
Modification des entrées Oui (écrase les fd_set) Non (events préservé)
Complexité noyau O(nfds) O(nfds)
Complexité userspace O(nfds) O(nfds)
Copie user↔kernel 3 bitsets par appel 1 tableau par appel
Timeout struct timeval (modifiable) int en millisecondes
Portabilité POSIX, Windows (Winsock) POSIX (pas Windows natif)
Mode de notification Level-triggered uniquement Level-triggered uniquement

Quand utiliser select ou poll en 2026 ?

Ces interfaces restent pertinentes dans des cas précis :

  • Portabilité maximale : select est la seule interface de multiplexage disponible partout, y compris sur Windows via Winsock (avec quelques variations). Si votre code doit tourner sur Linux, macOS, FreeBSD, Solaris et Windows sans couche d'abstraction, select est le plus petit dénominateur commun.

  • Nombre réduit de descripteurs : pour un outil CLI qui surveille un socket et stdin (2-3 descripteurs), la différence de performance avec epoll est inexistante. select ou poll sont alors plus simples à mettre en place.

  • Code legacy : beaucoup de bases de code existantes utilisent select ou poll. Comprendre ces API est indispensable pour la maintenance et la migration progressive.

Pour tout serveur réseau visant plus d'une centaine de connexions simultanées sous Linux, epoll (section 22.3.2) est le choix approprié. Pour les workloads les plus exigeants, io_uring (section 22.3.3) offre un palier de performance supplémentaire.

⏭️ epoll : Le standard Linux