🔝 Retour au Sommaire
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.
⚠️ epollest spécifique à Linux. Les équivalents sur d'autres systèmes sontkqueue(FreeBSD, macOS) etIOCP(Windows). Les librairies comme Asio (section 22.4) ou libuv abstraient ces différences.
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 est volontairement minimaliste : trois fonctions suffisent.
#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 suffixe1) existe aussi mais est obsolète. Le paramètresizeest ignoré depuis Linux 2.6.8. Préférez toujoursepoll_create1.
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 retourneEEXIST.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ètreeventest ignoré (peut êtrenullptr). Notez queclose()sur un descripteur le retire automatiquement de toutes les instances epoll — unEPOLL_CTL_DELexplicite avantclose()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).
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_pwaitetepoll_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.
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().
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.
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_waitsous 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
EPOLLONESHOTen 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.
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.
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;
⚠️ dataest une union :fd,ptr,u32etu64partagent le même espace mémoire. Si vous utilisezdata.ptr, vous ne pouvez pas simultanément liredata.fd— il faut stocker le descripteur dans votre structureConnection.
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.
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.
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).
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.
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.
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_waitest un goulot d'étranglement. EPOLL_CLOEXECsystématiquement surepoll_create1.- Non bloquant sur tous les descripteurs surveillés, même en mode LT.
EPOLLOUTdynamique : ne l'activer qu'en cas d'écriture partielle, le désactiver dès que le buffer est vidé.EPOLLRDHUPpour détecter proprement les half-close TCP.data.ptrvers une structure de contexte pour les applications réelles, plutôt quedata.fd.EPOLLONESHOT+ thread pool si plusieurs threads consomment les événements du mêmeepoll_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.