Wanneer je een game-server, een chatdienst of een real-time API schrijft, loop je vroeg of laat tegen dezelfde muur aan: één thread per verbinding starten stort in bij een paar duizend gebruikers. Op Linux is het standaardantwoord op dit probleem de epoll server-architectuur — een event-loop die tienduizenden gelijktijdige sockets op één thread afhandelt en alleen aandacht geeft aan de verbindingen die daadwerkelijk data hebben. In dit artikel zie je hoe epoll werkt, wat edge-triggered modus betekent, en een volledig, werkend C++-skelet stap voor stap.
Waarom niet select/poll?
De klassieke select() en poll() dwingen je om alle file descriptors (fd's) die je bewaakt bij elke aanroep opnieuw aan de kernel door te geven, en bij terugkomst allemaal opnieuw te doorlopen. De kosten groeien lineair met het aantal verbindingen (O(n)); bij 10.000 verbindingen wordt elke iteratie een zware scan.
epoll houdt de set bewaakte fd's in de kernel. Je registreert één keer en ontvangt daarna alleen de lijst van sockets die klaar zijn. Het werk is evenredig met het aantal actieve events, niet met het totale aantal verbindingen. Met duizenden inactieve verbindingen is dat een enorm verschil.
epoll_create1()— maakt een epoll-instantie en geeft een fd terug.epoll_ctl()— voegt een fd toe, verwijdert of wijzigt deze op die instantie.epoll_wait()— blokkeert op en retourneert de klaarstaande events.
De event-loop opzetten
Eerst open je een luistersocket, doe je bind/listen, en voeg je hem toe aan de epoll-instantie. Het cruciale punt: voor een schaalbare epoll server moet elke socket in non-blocking modus staan.
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; // meld wanneer klaar om te lezen
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
ev.data is een union; je kunt er een fd in opslaan, of een pointer (ptr) naar een object per verbinding. In de praktijk is het heel gebruikelijk om voor elke verbinding een context (buffers, een state machine) via ptr mee te dragen.
Level-triggered of edge-triggered?
epoll biedt twee modi. De standaard is level-triggered: zolang er ongelezen data op de socket staat, blijft epoll_wait je melden — vergelijkbaar met poll, en vergevingsgezind. Edge-triggered (EPOLLET) meldt je slechts één keer, wanneer de toestand verandert. Minder systeemaanroepen, hogere prestaties, maar er geldt een regel:
- In edge-triggered modus moet je in een lus lezen tot je de socket volledig hebt geleegd (dat wil zeggen tot
read/recvEAGAINofEWOULDBLOCKteruggeeft). - Anders word je nooit meer gemeld over de resterende data en blijft de verbinding hangen.
Verbindingen accepteren en lezen
Het hart van de lus is epoll_wait. Voor elk teruggegeven event: komt het van de luistersocket, dan accepten we nieuwe verbindingen; anders lezen we data. In edge-triggered modus moet accept ook in een lus draaien tot EAGAIN, omdat er meerdere verbindingen achter één melding opgestapeld kunnen zijn.
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) {
// Leeg elke wachtende nieuwe verbinding
while (true) {
int conn = accept(listen_fd, nullptr, nullptr);
if (conn == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
break; // andere fout
}
make_nonblocking(conn);
struct epoll_event cev{};
cev.events = EPOLLIN | EPOLLET; // edge-triggered
cev.data.fd = conn;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &cev);
}
} else if (events[i].events & (EPOLLERR | EPOLLHUP)) {
close(fd); // valt bij sluiten automatisch uit epoll
} else if (events[i].events & EPOLLIN) {
handle_read(epfd, fd);
}
}
}
Dezelfde discipline geldt bij het lezen: geeft recv 0 terug, dan heeft de tegenpartij de verbinding gesloten; geeft het EAGAIN terug, dan is alle data voor deze ronde op.
void handle_read(int epfd, int fd) {
char buf[4096];
while (true) {
ssize_t r = recv(fd, buf, sizeof(buf), 0);
if (r > 0) {
// verwerk de r bytes in buf / voeg toe aan een buffer
} else if (r == 0) {
close(fd); // client sloot af
return;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) return;
if (errno == EINTR) continue;
close(fd); // echte fout
return;
}
}
}
Schrijven, backpressure en EPOLLOUT
Lezen is het makkelijke deel. De echte subtiliteit zit in het schrijven: op een non-blocking socket slikt send niet altijd al je data — het kan EAGAIN teruggeven, wat betekent "de buffer is vol, probeer later opnieuw". In dat geval bewaar je de resterende bytes in een uitvoerwachtrij per verbinding en herregistreer je de fd met EPOLLOUT. Wanneer de socket weer beschrijfbaar wordt, wekt epoll_wait je, leeg je de wachtrij, en zet je EPOLLOUT weer uit zodra die leeg is. Dit mechanisme is de backpressure-controle die voorkomt dat trage clients het geheugen van je server opblazen.
- Luister niet de hele tijd naar
EPOLLOUT— het vuurt voortdurend en verspilt CPU. Zet het alleen aan terwijl er data in de wachtrij staat om te schrijven. - Houd aparte lees- en schrijfbuffers per verbinding; beheer de toestand met een state machine.
Naar productie brengen
Het skelet werkt; maar een echte epoll server vraagt een paar extra stappen. Op multi-core machines kun je met SO_REUSEPORT per CPU-core een aparte accept-lus draaien en de kernel de last laten verdelen. Negeer SIGPIPE (of geef MSG_NOSIGNAL mee aan send), anders doodt schrijven naar een gesloten socket het proces. Verhoog de file-descriptor-limiet (ulimit -n) en de listen-backlog. Tot slot kun je voor timeout-beheer ook een timerfd aan epoll toevoegen, zodat alles in één lus leeft.
Veelgestelde vragen
Moet ik io_uring gebruiken in plaats van epoll?
io_uring is een nieuwere interface, vooral krachtig voor disk-I/O, en het rijpt ook voor netwerken. Maar epoll is nog steeds de meest verspreide, meest draagbare en best gedocumenteerde oplossing. Voor de meeste netwerkservers is epoll ruim voldoende; stap pas over op io_uring als je een concreet, gemeten knelpunt hebt.
Is één thread genoeg voor duizenden verbindingen?
Ja — als de werklast I/O-gebonden is (de meeste netwerkapplicaties zijn dat) draagt één epoll-lus moeiteloos tienduizenden verbindingen. Heb je zwaar CPU-gebonden werk, delegeer dat dan naar een thread pool en blokkeer de lus nooit.
Werkt epoll op Windows?
Nee, epoll is Linux-specifiek. Windows heeft IOCP en macOS/BSD heeft kqueue. Heb je draagbaarheid nodig, kies dan bibliotheken als libuv, libev of Boost.Asio die deze verschillen wegabstraheren.
Bouw je een high-performance game- of real-time server? Laten we samenwerken aan de Linux-netwerkstack, C++ en een schaalbare architectuur — neem contact op.