When you write a game server, a chat service or a real-time API, sooner or later you hit the same wall: spawning one thread per connection collapses at a few thousand users. On Linux the standard answer to this problem is the epoll server architecture — an event loop that handles tens of thousands of concurrent sockets on a single thread, paying attention only to the connections that actually have data. In this article you will see how epoll works, what edge-triggered mode means, and a complete, working C++ skeleton step by step.
Why not select/poll?
The classic select() and poll() force you to re-submit all the file descriptors (fds) you watch to the kernel on every call, and then scan all of them again on the way back. The cost grows linearly with the number of connections (O(n)); at 10,000 connections every loop iteration becomes a heavy scan.
epoll keeps the set of watched fds inside the kernel. You register once, then receive only the list of sockets that are ready. The work is proportional to the number of currently active events, not the total connection count. With thousands of idle connections that is a huge difference.
epoll_create1()— creates an epoll instance and returns an fd.epoll_ctl()— adds, removes or modifies an fd on that instance.epoll_wait()— blocks for and returns the ready events.
Setting up the event loop
First you open a listening socket, bind/listen it, then add it to the epoll instance. The critical point: for a scalable epoll server every socket must be in non-blocking mode.
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; // notify when ready to read
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
ev.data is a union; you can store an fd in it, or a pointer (ptr) to a per-connection object. In practice it is very common to carry a context (buffers, a state machine) for each connection through ptr.
Level-triggered or edge-triggered?
epoll offers two modes. The default is level-triggered: as long as there is unread data on the socket, epoll_wait keeps notifying you — similar to poll, and forgiving. Edge-triggered (EPOLLET) notifies you only once, when the state changes. Fewer system calls, higher performance, but it comes with a rule:
- In edge-triggered mode you must read in a loop until you have fully drained the socket (that is, until
read/recvreturnsEAGAINorEWOULDBLOCK). - Otherwise you will never be notified about the leftover data again and the connection stalls.
Accepting and reading connections
The heart of the loop is epoll_wait. For each returned event: if it comes from the listening socket we accept new connections; otherwise we read data. In edge-triggered mode accept must also run in a loop until EAGAIN, because several connections may have piled up behind a single 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) {
// Drain every pending new connection
while (true) {
int conn = accept(listen_fd, nullptr, nullptr);
if (conn == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
break; // other error
}
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); // dropped from epoll automatically on close
} else if (events[i].events & EPOLLIN) {
handle_read(epfd, fd);
}
}
}
The same discipline applies in the read function: if recv returns 0 the peer has closed the connection; if it returns EAGAIN all data for this round is exhausted.
void handle_read(int epfd, int fd) {
char buf[4096];
while (true) {
ssize_t r = recv(fd, buf, sizeof(buf), 0);
if (r > 0) {
// process the r bytes in buf / append to a buffer
} else if (r == 0) {
close(fd); // client closed
return;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) return;
if (errno == EINTR) continue;
close(fd); // real error
return;
}
}
}
Writing, backpressure and EPOLLOUT
Reading is the easy part. The real subtlety is writing: on a non-blocking socket send does not always swallow all your data — it may return EAGAIN, meaning "the buffer is full, try again later". In that case you store the remaining bytes in a per-connection output queue and re-register the fd with EPOLLOUT. When the socket becomes writable again epoll_wait wakes you, you flush the queue, and once it is empty you turn EPOLLOUT back off. This mechanism is the backpressure control that stops slow clients from bloating your server's memory.
- Do not listen for
EPOLLOUTall the time — it fires constantly and burns CPU. Enable it only while data is queued to be written. - Keep separate read and write buffers per connection; manage state with a state machine.
Taking it to production
The skeleton works; but a real epoll server needs a few extra steps. On multi-core machines you can use SO_REUSEPORT to run a separate accept loop per CPU core and let the kernel spread the load. Ignore SIGPIPE (or pass MSG_NOSIGNAL to send), otherwise writing to a closed socket kills the process. Raise the file-descriptor limit (ulimit -n) and the listen backlog. Finally, for timeout handling you can add a timerfd to epoll as well, so everything lives in a single loop.
Frequently Asked Questions
Should I use io_uring instead of epoll?
io_uring is a newer interface, especially powerful for disk I/O, and it is maturing for networking too. But epoll is still the most widespread, most portable and best-documented solution. For most network servers epoll is more than enough; switch to io_uring only if you have a concrete, measured bottleneck.
Is a single thread enough for thousands of connections?
Yes — if the workload is I/O-bound (most network applications are) a single epoll loop comfortably carries tens of thousands of connections. If you have heavy CPU-bound work, offload it to a thread pool and never block the loop.
Does epoll work on Windows?
No, epoll is Linux-specific. Windows has IOCP and macOS/BSD has kqueue. If you need portability, prefer libraries like libuv, libev or Boost.Asio that abstract these differences away.
Building a high-performance game or real-time server? Let's work together on the Linux network stack, C++ and a scalable architecture — get in touch.