Skip to content

Latest commit

 

History

History
486 lines (361 loc) · 22.3 KB

File metadata and controls

486 lines (361 loc) · 22.3 KB

🔝 Retour au Sommaire

22.3.2 epoll : Le standard Linux

Introduction

epoll est l'interface de multiplexage I/O native de Linux, introduite dans le noyau 2.5.44 (2002) pour résoudre les problèmes de scalabilité fondamentaux de select et poll. Là où ces derniers imposent un coût O(n) proportionnel au nombre total de descripteurs surveillés, epoll offre une complexité O(1) pour l'attente d'événements et un coût O(k) proportionnel uniquement au nombre de descripteurs effectivement actifs.

Ce n'est pas une amélioration marginale : c'est un changement d'architecture. Avec 50 000 connexions ouvertes dont 10 sont actives à un instant donné, poll effectue 50 000 vérifications tandis qu'epoll en effectue 10. C'est cette propriété qui a permis l'émergence des serveurs capables de gérer des centaines de milliers de connexions simultanées — et c'est pourquoi epoll est le mécanisme sous-jacent de Nginx, Redis, Node.js (via libuv), et de la quasi-totalité des serveurs haute performance sous Linux.

⚠️ epoll est spécifique à Linux. Les équivalents sur d'autres systèmes sont kqueue (FreeBSD, macOS) et IOCP (Windows). Les librairies comme Asio (section 22.4) ou libuv abstraient ces différences.


Architecture : un objet noyau persistant

La différence fondamentale entre epoll et ses prédécesseurs tient à l'architecture. Avec select et poll, l'ensemble des descripteurs à surveiller est transmis du userspace vers le kernel à chaque appel. Le noyau doit parcourir cet ensemble, vérifier chaque descripteur, puis retourner les résultats — un travail proportionnel au nombre total de descripteurs, répété à chaque itération de la boucle événementielle.

epoll inverse cette logique. On crée d'abord un objet persistant dans le noyau (l'instance epoll), puis on y enregistre ou retire des descripteurs de manière incrémentale. L'attente d'événements ne transmet aucune liste de descripteurs — le noyau connaît déjà les descripteurs à surveiller. Il retourne uniquement ceux qui sont prêts.

select / poll                           epoll
─────────────                           ─────

  Boucle :                              Initialisation :
  ┌─ Construire la liste (N fds)          epoll_create1()
  │  Copier N fds vers le kernel          epoll_ctl(ADD, fd1)
  │  Kernel : parcourir N fds             epoll_ctl(ADD, fd2)
  │  Copier résultats vers userspace      epoll_ctl(ADD, fd3)
  │  Parcourir N fds en userspace         ...
  └─ Recommencer
                                        Boucle :
  Coût par itération : O(N)             ┌─ epoll_wait()  ← pas de copie
                                        │  Kernel : retourne K fds prêts
                                        │  Traiter K fds
                                        └─ Recommencer

                                        Coût par itération : O(K)
                                        (K = fds actifs, K << N)

En interne, le noyau maintient une structure basée sur un red-black tree pour stocker les descripteurs surveillés (insertion/suppression en O(log n)) et une ready list alimentée par des callbacks dans la couche réseau. Quand un paquet arrive sur un socket, le noyau n'a pas besoin de parcourir tous les descripteurs — le callback du socket ajoute directement l'entrée correspondante dans la ready list.


L'API epoll en trois appels système

L'API est volontairement minimaliste : trois fonctions suffisent.

epoll_create1 : créer l'instance

#include <sys/epoll.h>

int epoll_create1(int flags);

Crée une instance epoll et retourne un descripteur de fichier qui la représente. Ce descripteur doit être fermé avec close() quand il n'est plus nécessaire.

Le seul flag utile est EPOLL_CLOEXEC, qui positionne le flag close-on-exec pour éviter que le descripteur soit hérité par des processus fils créés avec exec. C'est une bonne pratique systématique :

int epoll_fd = epoll_create1(EPOLL_CLOEXEC);  
if (epoll_fd == -1) {  
    std::print(stderr, "epoll_create1 : {}\n", strerror(errno));
    return 1;
}

📝 La fonction epoll_create(int size) (sans le suffixe 1) existe aussi mais est obsolète. Le paramètre size est ignoré depuis Linux 2.6.8. Préférez toujours epoll_create1.

epoll_ctl : enregistrer, modifier ou supprimer un descripteur

int epoll_ctl(int epfd,       // Descripteur de l'instance epoll
              int op,          // Opération : EPOLL_CTL_ADD, _MOD, _DEL
              int fd,          // Descripteur cible
              struct epoll_event *event);

struct epoll_event {
    uint32_t     events;  // Masque d'événements
    epoll_data_t data;    // Données utilisateur (union)
};

typedef union epoll_data {
    void    *ptr;
    int      fd;
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

Les trois opérations :

  • EPOLL_CTL_ADD : enregistre un nouveau descripteur. Le noyau l'ajoute dans son red-black tree. Tenter d'ajouter un descripteur déjà présent retourne EEXIST.
  • EPOLL_CTL_MOD : modifie les événements surveillés pour un descripteur existant. Utile pour activer/désactiver la surveillance en écriture selon l'état du buffer d'envoi.
  • EPOLL_CTL_DEL : retire un descripteur de la surveillance. Le paramètre event est ignoré (peut être nullptr). Notez que close() sur un descripteur le retire automatiquement de toutes les instances epoll — un EPOLL_CTL_DEL explicite avant close() est donc techniquement redondant, mais clarifie l'intention.

Les événements les plus courants :

Flag Description
EPOLLIN Données disponibles en lecture (ou nouvelle connexion sur un socket d'écoute)
EPOLLOUT Écriture possible sans blocage
EPOLLERR Erreur sur le descripteur (toujours surveillé, pas besoin de le spécifier)
EPOLLHUP Déconnexion du pair (toujours surveillé)
EPOLLRDHUP Le pair a fermé son côté écriture (half-close, depuis Linux 2.6.17)
EPOLLET Active le mode edge-triggered (par défaut : level-triggered)
EPOLLONESHOT Désactive automatiquement le descripteur après un événement (nécessite EPOLL_CTL_MOD pour le réactiver)

Le champ data est une union qui vous permet d'associer un contexte arbitraire à chaque descripteur. Le noyau vous retourne ce contexte tel quel lors des événements, sans l'interpréter. L'usage le plus simple est d'y stocker le descripteur de fichier lui-même (data.fd = fd), mais on peut aussi y placer un pointeur vers une structure de contexte par connexion (data.ptr = &connection_ctx).

epoll_wait : attendre des événements

int epoll_wait(int epfd,
               struct epoll_event *events,  // Tableau de sortie (alloué par l'appelant)
               int maxevents,               // Taille du tableau
               int timeout);                // Timeout en ms (-1 = infini)

Bloque jusqu'à ce qu'au moins un descripteur soit prêt, que le timeout expire, ou qu'un signal soit reçu. Retourne le nombre d'événements écrits dans le tableau, 0 en cas de timeout, ou -1 en cas d'erreur.

Le tableau events n'a pas besoin d'être dimensionné au nombre total de descripteurs surveillés — il suffit de le dimensionner au nombre maximum d'événements que vous souhaitez traiter par itération. Une taille de 64 à 1024 est courante.

📝 epoll_pwait et epoll_pwait2 (Linux 5.11) sont des variantes qui acceptent un masque de signaux et un timeout en nanosecondes respectivement. Elles sont utiles pour les cas avancés de gestion de signaux.


Exemple complet : serveur echo en mode level-triggered

Commençons par le mode par défaut, level-triggered (LT), qui est le plus simple et le plus sûr :

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

constexpr int    max_events = 64;  
constexpr size_t buf_size   = 4096;  

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 client_count = 0;

    // ── Création du serveur ──
    int server_fd = create_server(port);
    if (server_fd == -1) {
        std::print(stderr, "Erreur création serveur\n");
        return 1;
    }

    // ── Création de l'instance epoll ──
    int epoll_fd = epoll_create1(EPOLL_CLOEXEC);
    if (epoll_fd == -1) {
        std::print(stderr, "epoll_create1 : {}\n", strerror(errno));
        close(server_fd);
        return 1;
    }

    // ── Enregistrement du socket serveur ──
    epoll_event ev{};
    ev.events  = EPOLLIN;      // Level-triggered (pas de EPOLLET)
    ev.data.fd = server_fd;
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev);

    std::print("Serveur epoll (LT) en écoute sur le port {}\n", port);

    // ── Boucle événementielle ──
    epoll_event events[max_events];

    while (true) {
        int n = epoll_wait(epoll_fd, events, max_events, -1);
        if (n == -1) {
            if (errno == EINTR) continue;
            std::print(stderr, "epoll_wait : {}\n", strerror(errno));
            break;
        }

        // Seuls les descripteurs prêts sont dans events[0..n-1]
        for (int i = 0; i < n; ++i) {
            int fd = events[i].data.fd;

            // ── Nouvelle connexion ──
            if (fd == server_fd) {
                // Accepter toutes les connexions en attente
                while (true) {
                    int client_fd = accept(server_fd, nullptr, nullptr);
                    if (client_fd == -1) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) {
                            break;  // Plus de connexions en attente
                        }
                        std::print(stderr, "accept : {}\n", strerror(errno));
                        break;
                    }
                    set_nonblocking(client_fd);

                    epoll_event cev{};
                    cev.events  = EPOLLIN;
                    cev.data.fd = client_fd;
                    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &cev);

                    ++client_count;
                    std::print("Nouveau client fd={} (total: {})\n",
                               client_fd, client_count);
                }
                continue;
            }

            // ── Erreur ou déconnexion ──
            if (events[i].events & (EPOLLERR | EPOLLHUP)) {
                std::print("Client fd={} : erreur ou déconnexion\n", fd);
                close(fd);  // Retire automatiquement de epoll
                --client_count;
                continue;
            }

            // ── Données disponibles ──
            if (events[i].events & EPOLLIN) {
                char buf[buf_size];
                ssize_t bytes = recv(fd, buf, sizeof(buf), 0);
                if (bytes <= 0) {
                    std::print("Client fd={} déconnecté\n", fd);
                    close(fd);
                    --client_count;
                } else {
                    send(fd, buf, bytes, 0);
                }
            }
        }
    }

    close(epoll_fd);
    close(server_fd);
    return 0;
}

Plusieurs points méritent d'être soulignés dans cet exemple :

On n'itère que sur les n événements retournés, pas sur l'ensemble des connexions. C'est la source du gain de performance O(k) par rapport à poll.

L'ajout/suppression de descripteurs est incrémental. On appelle epoll_ctl(ADD) une seule fois par connexion, pas à chaque itération. C'est le contraire de select qui exige la reconstruction complète des fd_set.

Le close() suffit pour le retrait. Fermer un descripteur le désenregistre automatiquement de l'instance epoll. Pas besoin d'appeler EPOLL_CTL_DEL avant close().


Mode edge-triggered : plus performant, plus exigeant

Le mode edge-triggered (ET) s'active en ajoutant le flag EPOLLET lors de l'enregistrement :

epoll_event ev{};  
ev.events  = EPOLLIN | EPOLLET;  // Edge-triggered  
ev.data.fd = client_fd;  
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev);  

En mode ET, le noyau ne vous notifie qu'une seule fois lors de la transition de « pas de données disponibles » à « données disponibles ». Si vous ne lisez pas toutes les données lors de cette notification, vous ne serez pas renotifié — même si des données restent dans le buffer — jusqu'à ce que de nouvelles données arrivent.

Cela impose une discipline stricte : lire en boucle jusqu'à obtenir EAGAIN à chaque notification.

// ── Lecture edge-triggered : OBLIGATION de drainer le buffer ──
if (events[i].events & EPOLLIN) {
    while (true) {
        char buf[buf_size];
        ssize_t bytes = recv(fd, buf, sizeof(buf), 0);

        if (bytes == -1) {
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                break;  // Buffer drainé, on attend la prochaine notification
            }
            // Vraie erreur
            std::print(stderr, "recv fd={} : {}\n", fd, strerror(errno));
            close(fd);
            --client_count;
            break;
        }

        if (bytes == 0) {
            // Déconnexion propre
            close(fd);
            --client_count;
            break;
        }

        // Traiter les données (ici : echo)
        send(fd, buf, bytes, 0);
    }
}

La même logique s'applique à accept sur le socket serveur en mode ET : il faut accepter en boucle jusqu'à EAGAIN, car plusieurs connexions peuvent arriver simultanément mais ne déclencher qu'une seule notification.

Quand choisir ET vs LT ?

Le mode level-triggered est le choix par défaut et le plus sûr. Il pardonne l'oubli de lire toutes les données, car le descripteur sera renotifié tant que des données restent disponibles.

Le mode edge-triggered est préférable quand :

  • Vous avez besoin de réduire le nombre de retours d'epoll_wait sous très forte charge (des dizaines de milliers d'événements par seconde).
  • Votre architecture utilise des buffers applicatifs par connexion et vous maîtrisez le cycle lecture/écriture.
  • Vous utilisez EPOLLONESHOT en combinaison avec un thread pool (chaque événement n'est délivré qu'une seule fois, évitant les races entre threads).

En pratique, la plupart des serveurs de production performants utilisent ET + EPOLLET, mais LT reste parfaitement viable pour la majorité des cas d'usage.


Gestion correcte de l'écriture

Un piège fréquent avec epoll concerne la gestion de l'écriture. Enregistrer EPOLLOUT de manière permanente est une erreur : tant que le buffer d'envoi du noyau n'est pas plein, le socket est toujours prêt en écriture, ce qui provoque un réveil permanent d'epoll_wait et une consommation CPU de 100 %.

La bonne stratégie est de n'activer EPOLLOUT que lorsqu'une écriture partielle s'est produite, puis de le désactiver dès que le buffer applicatif est vidé :

/// @brief Tente d'envoyer toutes les données. Si l'envoi est partiel,
///        enregistre EPOLLOUT pour être notifié quand l'écriture redevient possible.
/// @return true si toutes les données ont été envoyées, false sinon.
bool try_send(int epoll_fd, int fd, const char* data, size_t len, size_t& offset) {
    while (offset < len) {
        ssize_t sent = send(fd, data + offset, len - offset, MSG_NOSIGNAL);
        if (sent == -1) {
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                // Buffer noyau plein : activer EPOLLOUT
                epoll_event ev{};
                ev.events  = EPOLLIN | EPOLLOUT;  // Ajouter EPOLLOUT
                ev.data.fd = fd;
                epoll_ctl(epoll_fd, EPOLL_CTL_MOD, fd, &ev);
                return false;  // Envoi incomplet
            }
            return false;  // Erreur réelle
        }
        offset += sent;
    }

    // Tout a été envoyé : désactiver EPOLLOUT
    epoll_event ev{};
    ev.events  = EPOLLIN;  // Retirer EPOLLOUT
    ev.data.fd = fd;
    epoll_ctl(epoll_fd, EPOLL_CTL_MOD, fd, &ev);
    return true;
}

Ce pattern d'activation/désactivation dynamique de EPOLLOUT est fondamental dans toute implémentation de serveur basée sur epoll.


Utiliser epoll_data.ptr pour un contexte par connexion

Stocker le descripteur de fichier dans data.fd suffit pour un serveur simple, mais les applications réelles ont besoin de davantage de contexte par connexion : buffer de lecture, buffer d'écriture, état du protocole, timestamp de dernière activité, etc.

Le champ data.ptr permet d'associer un pointeur vers une structure de contexte :

struct Connection {
    int         fd;
    std::string read_buf;
    std::string write_buf;
    size_t      write_offset = 0;

    // État applicatif (protocole HTTP, session, etc.)
};

// À l'accept d'une nouvelle connexion :
auto* conn = new Connection{.fd = client_fd};

epoll_event ev{};  
ev.events  = EPOLLIN;  
ev.data.ptr = conn;  // Le noyau retournera ce pointeur tel quel  
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev);  

// Dans la boucle événementielle :
for (int i = 0; i < n; ++i) {
    auto* conn = static_cast<Connection*>(events[i].data.ptr);

    if (events[i].events & EPOLLIN) {
        // Utiliser conn->fd, conn->read_buf, etc.
    }
}

// À la déconnexion :
close(conn->fd);  
delete conn;  

⚠️ data est une union : fd, ptr, u32 et u64 partagent le même espace mémoire. Si vous utilisez data.ptr, vous ne pouvez pas simultanément lire data.fd — il faut stocker le descripteur dans votre structure Connection.


Pièges courants et bonnes pratiques

1. Descripteurs dupliqués et epoll

epoll surveille des descriptions de fichiers ouverts (file descriptions dans le noyau), pas des descripteurs de fichiers (file descriptors). Deux descripteurs obtenus par dup() ou dup2() pointent vers la même description. Si vous enregistrez fd1 dans epoll puis fermez fd1 sans fermer fd2, la description reste ouverte et epoll continue de la surveiller — mais EPOLL_CTL_DEL sur fd1 échouera car fd1 n'est plus valide. La règle : fermez toujours tous les descripteurs pointant vers une description avant de considérer le retrait d'epoll comme effectif.

2. EPOLLRDHUP pour détecter les half-close

TCP permet à un pair de fermer son côté écriture tout en continuant à lire (half-close via shutdown(fd, SHUT_WR)). Le flag EPOLLRDHUP (disponible depuis Linux 2.6.17) détecte cette condition sans avoir à effectuer un recv() qui retournerait 0 :

ev.events = EPOLLIN | EPOLLRDHUP;

C'est plus efficace que de dépendre uniquement du retour de recv, et permet de distinguer un half-close d'un buffer temporairement vide.

3. Starvation en mode level-triggered

En mode LT, un descripteur très actif (qui reçoit un flux continu de données) sera signalé à chaque appel d'epoll_wait. Si vous traitez toujours les événements dans le même ordre, les descripteurs en fin de tableau peuvent souffrir de starvation. Deux stratégies de mitigation :

  • Limiter la quantité de données traitées par descripteur par itération.
  • Faire une rotation de l'ordre de traitement (round-robin).

4. Dimensionnement du tableau d'événements

Le tableau passé à epoll_wait ne contrôle pas le nombre de descripteurs surveillés — il contrôle combien d'événements sont retournés par appel. Un tableau trop petit ne provoque pas de perte d'événements (ils seront retournés à l'appel suivant), mais réduit le débit en augmentant la fréquence des appels système. Un tableau trop grand gaspille de la mémoire stack. La plage 64–1024 convient à la plupart des cas.


Comparaison de performance : epoll vs poll

Pour illustrer concrètement l'avantage d'epoll, voici les ordres de grandeur typiques sur un serveur moderne (mesures indicatives) :

Connexions totales Connexions actives poll (appels/s) epoll (appels/s) Facteur
100 10 ~250 000 ~280 000 ~1×
1 000 10 ~80 000 ~270 000 ~3×
10 000 10 ~9 000 ~260 000 ~29×
100 000 10 ~900 ~250 000 ~278×

Le point clé : les performances d'epoll restent quasi constantes indépendamment du nombre total de connexions. Celles de poll se dégradent linéairement.


Résumé : quand et comment utiliser epoll

epoll est le choix par défaut pour toute application réseau performante ciblant Linux. Voici un récapitulatif des recommandations :

  • Mode LT pour commencer, puis migrer vers ET si le profiling montre que la fréquence des epoll_wait est un goulot d'étranglement.
  • EPOLL_CLOEXEC systématiquement sur epoll_create1.
  • Non bloquant sur tous les descripteurs surveillés, même en mode LT.
  • EPOLLOUT dynamique : ne l'activer qu'en cas d'écriture partielle, le désactiver dès que le buffer est vidé.
  • EPOLLRDHUP pour détecter proprement les half-close TCP.
  • data.ptr vers une structure de contexte pour les applications réelles, plutôt que data.fd.
  • EPOLLONESHOT + thread pool si plusieurs threads consomment les événements du même epoll_fd.

Pour les workloads qui nécessitent encore plus de performance — en particulier ceux qui combinent I/O réseau et I/O fichier, ou qui visent à minimiser les transitions user/kernel — la section suivante présente io_uring (22.3.3), qui représente le prochain palier de performance sous Linux.

⏭️ io_uring : I/O asynchrone haute performance (Linux 5.1+)