Quand on écrit un serveur de jeu, un service de chat ou une API temps réel, on finit toujours par se heurter au même mur : lancer un thread par connexion s'effondre dès quelques milliers d'utilisateurs. Sous Linux, la réponse standard à ce problème est l'architecture epoll server — une boucle d'événements qui gère des dizaines de milliers de sockets simultanés sur un seul thread, en ne s'occupant que des connexions qui ont réellement des données. Dans cet article, vous verrez comment fonctionne epoll, ce qu'est le mode déclenché par front (edge-triggered) et un squelette C++ complet et fonctionnel, étape par étape.
Pourquoi pas select/poll ?
Les classiques select() et poll() vous obligent à re-soumettre tous les descripteurs de fichiers (fd) surveillés au noyau à chaque appel, puis à les parcourir tous au retour. Le coût croît linéairement avec le nombre de connexions (O(n)) ; à 10 000 connexions, chaque itération devient un balayage lourd.
epoll conserve l'ensemble des fd surveillés dans le noyau. Vous l'enregistrez une fois, puis ne recevez que la liste des sockets prêts. Le travail est proportionnel au nombre d'événements actifs, pas au total des connexions. Avec des milliers de connexions inactives, la différence est énorme.
epoll_create1()— crée une instance epoll et renvoie un fd.epoll_ctl()— ajoute, supprime ou modifie un fd sur cette instance.epoll_wait()— se bloque et renvoie les événements prêts.
Mise en place de la boucle d'événements
On ouvre d'abord un socket d'écoute, on le bind/listen, puis on l'ajoute à l'instance epoll. Le point crucial : pour un epoll server évolutif, tous les sockets doivent être en mode non bloquant.
int make_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) return -1;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int epfd = epoll_create1(0);
struct epoll_event ev{};
ev.events = EPOLLIN; // prévenir quand prêt à lire
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
ev.data est une union ; vous pouvez y stocker un fd ou un pointeur (ptr) vers un objet par connexion. En pratique, il est très courant de transporter un contexte (tampons, machine à états) pour chaque connexion via ptr.
Déclenché par niveau ou par front ?
epoll propose deux modes. Par défaut, le mode déclenché par niveau (level-triggered) : tant qu'il reste des données non lues sur le socket, epoll_wait vous prévient à chaque fois — semblable à poll, et indulgent. Le mode déclenché par front (EPOLLET) ne vous prévient qu'une seule fois, quand l'état change. Moins d'appels système, plus de performance, mais une règle s'impose :
- En mode edge-triggered, vous devez lire dans une boucle jusqu'à avoir entièrement vidé le socket (c'est-à-dire jusqu'à ce que
read/recvrenvoieEAGAINouEWOULDBLOCK). - Sinon, vous ne serez plus jamais averti des données restantes et la connexion se fige.
Accepter et lire les connexions
Le cœur de la boucle est epoll_wait. Pour chaque événement renvoyé : s'il provient du socket d'écoute, on accept de nouvelles connexions ; sinon, on lit les données. En mode edge-triggered, accept doit aussi tourner dans une boucle jusqu'à EAGAIN, car plusieurs connexions ont pu s'accumuler derrière une seule notification.
struct epoll_event events[MAX_EVENTS];
while (true) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; ++i) {
int fd = events[i].data.fd;
if (fd == listen_fd) {
// Vider toutes les nouvelles connexions en attente
while (true) {
int conn = accept(listen_fd, nullptr, nullptr);
if (conn == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
break; // autre erreur
}
make_nonblocking(conn);
struct epoll_event cev{};
cev.events = EPOLLIN | EPOLLET; // déclenché par front
cev.data.fd = conn;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &cev);
}
} else if (events[i].events & (EPOLLERR | EPOLLHUP)) {
close(fd); // retiré d'epoll automatiquement à la fermeture
} else if (events[i].events & EPOLLIN) {
handle_read(epfd, fd);
}
}
}
La même discipline s'applique à la lecture : si recv renvoie 0, le pair a fermé la connexion ; s'il renvoie EAGAIN, toutes les données de ce tour sont épuisées.
void handle_read(int epfd, int fd) {
char buf[4096];
while (true) {
ssize_t r = recv(fd, buf, sizeof(buf), 0);
if (r > 0) {
// traiter les r octets de buf / les ajouter à un tampon
} else if (r == 0) {
close(fd); // le client a fermé
return;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) return;
if (errno == EINTR) continue;
close(fd); // erreur réelle
return;
}
}
}
Écriture, contre-pression et EPOLLOUT
La lecture est la partie facile. La vraie subtilité est l'écriture : sur un socket non bloquant, send n'avale pas toujours toutes vos données — il peut renvoyer EAGAIN, signifiant « le tampon est plein, réessaie plus tard ». Vous stockez alors les octets restants dans une file de sortie par connexion et ré-enregistrez le fd avec EPOLLOUT. Quand le socket redevient inscriptible, epoll_wait vous réveille, vous videz la file, et une fois vide, vous désactivez EPOLLOUT. Ce mécanisme est le contrôle de contre-pression (backpressure) qui empêche les clients lents de saturer la mémoire de votre serveur.
- N'écoutez pas
EPOLLOUTen permanence — il se déclenche sans cesse et gaspille le CPU. Activez-le uniquement quand des données attendent dans la file d'écriture. - Gardez des tampons de lecture et d'écriture séparés par connexion ; gérez l'état avec une machine à états.
Le passage en production
Le squelette fonctionne ; mais un vrai epoll server demande quelques étapes supplémentaires. Sur les machines multi-cœurs, vous pouvez utiliser SO_REUSEPORT pour faire tourner une boucle d'acceptation distincte par cœur CPU et laisser le noyau répartir la charge. Ignorez SIGPIPE (ou passez MSG_NOSIGNAL à send), sinon écrire sur un socket fermé tue le processus. Augmentez la limite de descripteurs de fichiers (ulimit -n) et le backlog de listen. Enfin, pour la gestion des délais, vous pouvez aussi ajouter un timerfd à epoll, afin que tout vive dans une seule boucle.
Questions fréquentes
Dois-je utiliser io_uring au lieu d'epoll ?
io_uring est une interface plus récente, particulièrement puissante pour les E/S disque, et elle mûrit aussi pour le réseau. Mais epoll reste la solution la plus répandue, la plus portable et la mieux documentée. Pour la plupart des serveurs réseau, epoll suffit largement ; ne passez à io_uring que si vous avez un goulot d'étranglement concret et mesuré.
Un seul thread suffit-il pour des milliers de connexions ?
Oui — si la charge est liée aux E/S (la plupart des applications réseau le sont), une seule boucle epoll porte aisément des dizaines de milliers de connexions. Si vous avez du travail lourd lié au CPU, déléguez-le à un pool de threads et ne bloquez jamais la boucle.
epoll fonctionne-t-il sous Windows ?
Non, epoll est spécifique à Linux. Windows dispose d'IOCP et macOS/BSD de kqueue. Si vous avez besoin de portabilité, préférez des bibliothèques comme libuv, libev ou Boost.Asio qui masquent ces différences.
Vous construisez un serveur de jeu ou temps réel haute performance ? Travaillons ensemble sur la pile réseau Linux, le C++ et une architecture évolutive — contactez-moi.