Articles

How epoll Works in Linux: Internals, Edge-Triggered vs Level-Triggered, and Code Examples

If you have ever wondered how a single Linux server can juggle hundreds of thousands of concurrent TCP connections without breaking a sweat, the answer almost always involves epoll. It is the quiet workhorse behind Nginx, HAProxy, Node.js, Redis, and most modern high-performance network stacks. In this article we unpack how epoll works in Linux, look at what actually happens inside the kernel, compare edge-triggered vs level-triggered modes, and finish with a minimal C example that scales to thousands of sockets.

What is epoll, really?

epoll is a Linux-specific I/O event notification API introduced in kernel 2.5.44. Its job is simple to describe: tell the kernel which file descriptors you care about, and let the kernel wake you up only when something interesting happens on them.

Unlike select() and poll(), which rebuild their internal state on every call, epoll keeps a persistent data structure inside the kernel. That single design decision is why epoll scales to millions of descriptors while select struggles past a few thousand.

The three syscalls you need to know

  • epoll_create1(flags): creates an epoll instance in kernel space and returns a file descriptor referring to it.
  • epoll_ctl(epfd, op, fd, event): adds, modifies, or removes a file descriptor from the interest list.
  • epoll_wait(epfd, events, maxevents, timeout): blocks until one or more descriptors become ready, then returns only those.
linux server network

How epoll works inside the Linux kernel

To really understand epoll, it helps to look at what the kernel is doing under the hood. When you call epoll_create1, the kernel allocates a struct eventpoll. This structure holds two critical data collections:

  1. A red-black tree (rbr) that stores every file descriptor you have registered. Lookups, insertions and removals are O(log n).
  2. A ready list (rdllist), a doubly linked list of descriptors that currently have events pending.

The callback trick

Here is the clever part. When you register a file descriptor with epoll_ctl(EPOLL_CTL_ADD), epoll attaches a callback to the wait queue of that file. Every socket, pipe, or eventfd in Linux has a wait queue that is triggered whenever its state changes (data arrives, buffer space frees up, connection closes).

When such an event fires, the kernel does not scan anything. It simply runs the callback, which appends the corresponding entry to the ready list. When your program calls epoll_wait, the kernel just copies items from that ready list to userspace. No scan, no linear pass over descriptors.

This is why epoll is often described as O(1) with respect to the number of registered descriptors, while select and poll are O(n).

linux server network

epoll vs select vs poll

Feature select poll epoll
Max descriptors FD_SETSIZE (usually 1024) No hard limit No hard limit
Complexity per call O(n) O(n) O(1) on ready events
State kept in kernel No No Yes
Copy of fd set every call Yes Yes No
Edge-triggered mode No No Yes
Portable Everywhere POSIX Linux only

Use select when you need portability and only a handful of descriptors. Use poll when you want a nicer API but still work across Unixes. Use epoll when you are on Linux and care about scalability.

Edge-triggered vs level-triggered

epoll can notify you in two different ways, and choosing the wrong one is the source of most epoll bugs in the wild.

Level-triggered (LT) — the default

With level-triggered mode, epoll_wait reports a descriptor as ready as long as the condition is still true. If a socket has 4 KB of data waiting and you read only 1 KB, the next call to epoll_wait will happily tell you the socket is still readable. There’s a good example of this over at pixelperfectportfolios.com.

  • Behaves like a faster poll.
  • Easier and safer for beginners.
  • Slightly more syscalls in high-throughput scenarios.

Edge-triggered (ET)

With edge-triggered mode (EPOLLET flag), epoll only notifies you once, on the transition from not-ready to ready. If new data arrives after that, you get another notification. But if you fail to drain the socket, epoll will stay silent until more data arrives.

  • Fewer syscalls, higher throughput.
  • Requires non-blocking sockets.
  • You must loop on read()/write() until you get EAGAIN.

Which one should you pick?

Use case Recommended mode
Simple event loop, moderate load Level-triggered
High-performance proxy or web server Edge-triggered
You forget to drain buffers Level-triggered (safer)
You want minimum syscalls Edge-triggered
linux server network

A minimal C example: handling thousands of sockets

The following example sets up a TCP echo server using epoll in edge-triggered mode. It accepts connections, reads whatever data arrives, and echoes it back. It is deliberately small so you can focus on the epoll mechanics. See unixism.net for their take.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define PORT 9000
#define MAX_EVENTS 1024
#define BACKLOG 512

static int set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags == -1) return -1;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main(void) {
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr = {0};
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons(PORT);

    bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(listen_fd, BACKLOG);
    set_nonblocking(listen_fd);

    int epfd = epoll_create1(0);
    struct epoll_event ev = { .events = EPOLLIN | EPOLLET, .data.fd = listen_fd };
    epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

    struct epoll_event events[MAX_EVENTS];
    char buf[4096];

    for (;;) {
        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 accept queue (edge-triggered!)
                for (;;) {
                    int cfd = accept(listen_fd, NULL, NULL);
                    if (cfd == -1) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) break;
                        perror("accept"); break;
                    }
                    set_nonblocking(cfd);
                    struct epoll_event cev = { .events = EPOLLIN | EPOLLET | EPOLLRDHUP, .data.fd = cfd };
                    epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &cev);
                }
            } else {
                // Drain the socket completely
                int closed = 0;
                for (;;) {
                    ssize_t r = read(fd, buf, sizeof(buf));
                    if (r > 0) {
                        write(fd, buf, r); // For clarity; real code should handle partial writes
                    } else if (r == 0) {
                        closed = 1; break;
                    } else {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) break;
                        closed = 1; break;
                    }
                }
                if (closed) {
                    epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                    close(fd);
                }
            }
        }
    }
}

Key points in the code

  • The listening socket is non-blocking and registered with EPOLLET, so we must loop on accept() until it returns EAGAIN.
  • Client sockets use EPOLLIN | EPOLLET | EPOLLRDHUP so we also learn about half-closed peers.
  • We drain each socket fully on every notification. Forgetting this is the classic edge-triggered bug.

Common pitfalls when using epoll

  1. Using edge-triggered with blocking sockets. Your process will freeze the moment a read blocks. Always set O_NONBLOCK.
  2. Not draining the buffer. With ET, if you leave data in the socket, epoll will not remind you.
  3. Registering the same fd twice. epoll_ctl returns EEXIST. Use EPOLL_CTL_MOD instead.
  4. Closing a fd without removing it. If the fd number is reused, you may get events for the new fd unexpectedly. Modern kernels handle this well, but be explicit with EPOLL_CTL_DEL.
  5. Thundering herd with multiple workers. Use EPOLLEXCLUSIVE (available since kernel 4.5) when several processes share the same listening socket.
linux server network

When epoll is not the right tool

Even in 2026, epoll is not always the best answer. For truly asynchronous disk I/O and large-scale storage workloads, consider io_uring, which has matured significantly and often outperforms epoll for mixed network and file I/O. For portable code that must run on BSD or macOS, look at kqueue. And for simple tools with a handful of descriptors, plain poll() is still perfectly fine.

FAQ

Is epoll faster than select in every case?

Not always. For a very small number of descriptors (say, under 20), select can be marginally faster because it has less setup overhead. epoll shines once you monitor hundreds or thousands of file descriptors.

Can I use epoll for regular files?

No. Regular files on local filesystems are always considered ready by epoll, which makes it useless for them. Use io_uring or a thread pool for file I/O.

What is the difference between epoll_create and epoll_create1?

epoll_create1 is the modern version. It accepts a flags argument, most notably EPOLL_CLOEXEC to automatically close the epoll fd on exec(). The size argument of the older epoll_create is ignored today. man7.org has a solid rundown on this.

Should I use edge-triggered mode by default?

Only if you are comfortable writing careful non-blocking code that drains buffers until EAGAIN. For most applications, level-triggered mode is fast enough and much less error-prone.

How does epoll compare to io_uring?

epoll is a readiness API: it tells you when you can perform I/O. io_uring is a completion API: you submit operations and get notified when they finish. io_uring is generally more powerful for disk I/O and can outperform epoll for network workloads too, but it has a steeper learning curve and a larger API surface.

Does epoll work with signals?

Not directly, but you can create a signalfd and add it to your epoll instance. Same idea works for timers with timerfd and inter-thread wakeups with eventfd.

Conclusion

epoll is deceptively simple on the surface and beautifully engineered underneath. By keeping a persistent interest list in a red-black tree and using per-file wait queue callbacks to populate a ready list, the Linux kernel turns event notification into an almost free operation. Combined with edge-triggered mode and non-blocking sockets, epoll is what lets a modest Linux box handle the concurrency of a small data center. Whether you are building a proxy, a game server, or the next generation database, understanding how epoll works in Linux is a skill that pays back every time your service scales.

Latest Posts

No Posts Found!

Banner of the Day

NewsLetter

Do not miss our news
Sign up and receive the latest news of our company
Newsletter

Contact Info
Copyright © 2022 I-4 Linux. All Rights Reserved.