Networking in C · intermediate · ~25 min

epoll — modern Linux I/O multiplexing

- Create and manage an `epoll` instance and register file descriptors with `epoll_ctl`. - Write a single-threaded event loop around `epoll_wait` that scales to thousands of concurrent connections. - Explain the difference between level-triggered and edge-triggered notification, and write the drain-to-`EAGAIN` loop edge mode requires. - Use `EPOLLONESHOT`, `EPOLLRDHUP`, and `data.ptr` correctly, and re-arm fds when needed. - Handle error conditions (`EPOLLERR`, `EPOLLHUP`) and clean up per-connection resources without leaking memory.

Overview

epoll is Linux's event-driven I/O multiplexer. A multiplexer lets one thread watch many file descriptors (open sockets, pipes, files, timers) at the same time and react only to the ones that have something to do.

You already met multiplexing in select / poll for multiplexing. With select and poll, every call hands the kernel the entire list of fds you care about, the kernel scans all of them, and you scan all of them again afterwards to find the ready ones. That is O(n) work per wakeup, where n is the total number of watched fds — even if only one of them is ready. At ten thousand idle connections, that scanning dominates.

epoll flips the model. You register each fd once with epoll_ctl. The kernel keeps that registration in an internal table (the "interest list") and maintains a separate "ready list" of fds that have become active. epoll_wait then returns only the fds on the ready list. The cost is proportional to the number of ready fds, not the total number registered: O(ready), not O(registered). That is why one thread can comfortably handle tens of thousands of concurrent connections.

This builds directly on File descriptors (each socket is an fd) and Sockets introduction (the listening socket and accepted client sockets you will watch). epoll itself is also a file descriptor — an fd that refers to a kernel object holding your interest list — which is why you close it with close() like any other.

The key vocabulary: an epoll instance is the kernel object you create. The interest list is the set of fds you registered. The ready list is the subset that became ready. Level-triggered and edge-triggered are two ways the kernel decides when to report a fd as ready.

Why it matters

On Linux, production C network code is almost always built on epoll. nginx, HAProxy, Redis, and libuv (the engine under Node.js) all run epoll event loops at their core. Reading or contributing to any of these means reading epoll loops, so the core semantics are expected knowledge for a systems programmer.

It also matters because the failure modes are subtle and expensive:

  • An edge-triggered fd that is not drained to EAGAIN will silently stop receiving events. The connection looks alive but never makes progress — a stall that is hard to reproduce.
  • An EPOLLONESHOT fd that is never re-armed goes permanently quiet after its first event.
  • Forgetting to free the per-connection struct attached to data.ptr leaks memory on every disconnect, which adds up fast on a busy server.

Getting these right is the difference between a server that holds 50k connections for weeks and one that mysteriously degrades after a day. The semantics you must internalize are: edge- vs level-triggered notification, EPOLLONESHOT and re-arming, EPOLLRDHUP for half-close, and the meaning of data.ptr.

Core concepts

epoll_create1 — making the instance

A definition: epoll_create1(flags) creates a new epoll instance and returns a file descriptor that refers to it.

Everything else (epoll_ctl, epoll_wait) takes that fd as its first argument. Pass EPOLL_CLOEXEC as the flag so the epoll fd is automatically closed if your process calls exec() — without it, child programs you launch could inherit and accidentally hold the fd open. When you are finished, close() the epoll fd like any other.

When to use / not use: create one epoll instance per event loop (usually one per thread). You do not need a fresh instance per connection.

Pitfall: the older epoll_create(int size) exists, but its size argument is obsolete and ignored; prefer epoll_create1.

epoll_ctl — editing the interest list

epoll_ctl(epfd, op, fd, event) adds, modifies, or removes a watched fd. op is one of EPOLL_CTL_ADD, EPOLL_CTL_MOD, or EPOLL_CTL_DEL. The event argument is a struct epoll_event whose events field is a bitmask of what you care about:

Flag Meaning
EPOLLIN fd is readable
EPOLLOUT fd is writable
EPOLLET edge-triggered notification
EPOLLONESHOT report once, then disable until re-armed
EPOLLRDHUP peer closed its write half (half-close)
EPOLLERR / EPOLLHUP error / hang-up (always reported; you cannot opt out)

The data field is a union you control; the kernel stores it and hands it back to you unchanged in epoll_wait. You typically set data.fd (the fd number) or data.ptr (a pointer to your per-connection struct).

When NOT to use: do not EPOLL_CTL_ADD an fd that is already registered — that returns EEXIST. Use EPOLL_CTL_MOD to change an existing registration.

Pitfall: the data union has only one active member. If you set data.ptr, do not also read data.fd later — store the fd inside your struct instead.

epoll_wait — collecting ready fds

epoll_wait(epfd, events, maxevents, timeout_ms) blocks until at least one fd is ready, the timeout expires, or a signal interrupts it. It returns the number of ready fds and fills your events[] array (up to maxevents entries) with one struct epoll_event per ready fd. timeout_ms of -1 means block forever; 0 means return immediately (poll).

  interest list (registered once)        ready list (built by kernel)
  +----+----+----+----+----+             +----+----+
  | s0 | s1 | s2 | s3 | s4 | ...   --->   | s1 | s3 |   <-- only these returned
  +----+----+----+----+----+             +----+----+
         (10,000 fds)                    epoll_wait fills events[0..1]

Pitfall: if maxevents is smaller than the number of ready fds, the extra ready fds are simply returned on the next epoll_wait call — nothing is lost, but pick a reasonable batch size (e.g. 64–1024).

Knowledge check: You register 10,000 fds and exactly 3 become readable. How many struct epoll_event entries does a single epoll_wait fill, assuming maxevents >= 3?

Level-triggered vs edge-triggered

This is the concept that trips people up.

  • Level-triggered (default): epoll_wait keeps reporting a fd as ready as long as the condition holds. If a socket has 4000 unread bytes and you read only 100, the next epoll_wait still reports it readable, because data is still available. This is forgiving and behaves like poll.
  • Edge-triggered (EPOLLET): the kernel reports readiness only on a transition — when data arrives where there was none. You get one notification per arrival edge. If you do not consume everything, you will not be told again until new data arrives.
Level-triggered:    data present ──► notify, notify, notify ... (every wait)
Edge-triggered:     [empty]──data arrives──►notify once───────► silence
                                            (must read until EAGAIN)

With edge-triggered mode you MUST loop reading (or writing) until the syscall returns -1 with errno == EAGAIN (or EWOULDBLOCK), which means "nothing more right now." This requires the fd to be non-blocking (covered next in Non-blocking sockets), otherwise the final read would block forever. If you stop early, the kernel will not notify you again and the connection stalls.

When to use which: start with level-triggered — it is simpler and correct by default. Reach for edge-triggered only when profiling shows the extra wakeups matter; it reduces syscall count under very high load.

Knowledge check (find-the-bug): A server registers a client socket with EPOLLIN | EPOLLET, then on each event calls read(fd, buf, sizeof buf) exactly once. Under load, some clients hang. Why?

EPOLLONESHOT and re-arming

EPOLLONESHOT tells epoll to report a fd once and then automatically disable it (set its event mask to none) until you re-enable it with EPOLL_CTL_MOD. This is invaluable in multi-threaded servers: it guarantees that only one thread handles a given fd at a time, because the fd produces no further events until you explicitly re-arm it.

Pitfall: after handling a EPOLLONESHOT event you must epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev) with the events you want again. Forget this and the fd goes silent forever.

EPOLLRDHUP and error events

EPOLLRDHUP is set when the peer closes its writing half (sends a TCP FIN). It lets you notice a half-close promptly instead of discovering it only when a read returns 0. EPOLLERR and EPOLLHUP are always reported regardless of what you registered, so your loop must check events[i].events for them and clean up the connection.

Knowledge check (explain in your own words): Why must you inspect events[i].events for EPOLLERR/EPOLLHUP even though you never put those flags in the mask you registered?

data.ptr — attaching state

The data union (epoll_data_t) holds whatever context you need to find your connection state when an event fires. Setting data.ptr to a heap-allocated per-connection struct is the standard pattern: when epoll_wait returns the event, you cast data.ptr back to your struct and have everything (fd, buffers, parser state) in hand. You own that memory and must free it when the connection closes.

Syntax notes

#include <sys/epoll.h>

int epoll_create1(int flags);                  /* flags: 0 or EPOLL_CLOEXEC */
int epoll_ctl(int epfd, int op, int fd,        /* op: ADD / MOD / DEL */
              struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout_ms); /* timeout: -1 = block forever */

struct epoll_event {
    uint32_t      events;   /* bitmask: EPOLLIN | EPOLLOUT | EPOLLET | ... */
    epoll_data_t  data;     /* union you control; returned untouched */
};

typedef union epoll_data {
    void    *ptr;           /* pointer to your per-connection state */
    int      fd;            /* or just the fd number */
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

All three functions return -1 and set errno on failure. epoll_wait returns the number of ready fds (0 means timeout).

Lesson

select and poll work, but they scan every fd on every call: O(n) per wakeup.

epoll (Linux only) is event-driven. The kernel remembers which fds you registered, and epoll_wait returns only the ones that fired.

This is the foundation of every modern C network server, including nginx, redis, and libuv (the Linux counterpart to BSD's kqueue).

Code examples

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

#define PORT        8080
#define MAX_EVENTS  64
#define BUF_SIZE    4096

/* Make an fd non-blocking so edge-triggered reads can drain to EAGAIN. */
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);
}

/* Create, bind and listen on a TCP socket; returns the listening fd. */
static int make_listener(int port) {
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd == -1) { perror("socket"); return -1; }

    int yes = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);

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

    if (bind(fd, (struct sockaddr *)&addr, sizeof addr) == -1) {
        perror("bind"); close(fd); return -1;
    }
    if (listen(fd, SOMAXCONN) == -1) {
        perror("listen"); close(fd); return -1;
    }
    if (set_nonblocking(fd) == -1) { close(fd); return -1; }
    return fd;
}

int main(void) {
    int listen_fd = make_listener(PORT);
    if (listen_fd == -1) return EXIT_FAILURE;

    int ep = epoll_create1(EPOLL_CLOEXEC);
    if (ep == -1) { perror("epoll_create1"); close(listen_fd); return EXIT_FAILURE; }

    /* Watch the listener (edge-triggered) for incoming connections. */
    struct epoll_event ev = { .events = EPOLLIN | EPOLLET, .data.fd = listen_fd };
    if (epoll_ctl(ep, EPOLL_CTL_ADD, listen_fd, &ev) == -1) {
        perror("epoll_ctl: listen_fd"); close(ep); close(listen_fd);
        return EXIT_FAILURE;
    }

    struct epoll_event ready[MAX_EVENTS];
    printf("Echo server listening on port %d\n", PORT);

    for (;;) {
        int n = epoll_wait(ep, ready, MAX_EVENTS, -1);
        if (n == -1) {
            if (errno == EINTR) continue;   /* interrupted by signal: retry */
            perror("epoll_wait"); break;
        }

        for (int i = 0; i < n; i++) {
            int fd = ready[i].data.fd;
            uint32_t e = ready[i].events;

            /* Error or hang-up: drop the connection. */
            if (e & (EPOLLERR | EPOLLHUP)) {
                close(fd);              /* closing removes it from epoll */
                continue;
            }

            if (fd == listen_fd) {
                /* Edge-triggered: accept until EAGAIN. */
                for (;;) {
                    int c = accept(listen_fd, NULL, NULL);
                    if (c == -1) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) break;
                        perror("accept"); break;
                    }
                    if (set_nonblocking(c) == -1) { close(c); continue; }
                    struct epoll_event cev = {
                        .events = EPOLLIN | EPOLLET | EPOLLRDHUP,
                        .data.fd = c
                    };
                    if (epoll_ctl(ep, EPOLL_CTL_ADD, c, &cev) == -1) {
                        perror("epoll_ctl: client"); close(c);
                    }
                }
                continue;
            }

            /* Client socket: edge-triggered, so drain to EAGAIN. */
            int closed = 0;
            for (;;) {
                char buf[BUF_SIZE];
                ssize_t r = read(fd, buf, sizeof buf);
                if (r > 0) {
                    /* Echo back. (A robust server would buffer partial writes.) */
                    write(fd, buf, (size_t)r);
                } else if (r == 0) {
                    closed = 1; break;           /* peer closed */
                } else {
                    if (errno == EAGAIN || errno == EWOULDBLOCK) break;
                    if (errno == EINTR) continue;
                    closed = 1; break;           /* real error */
                }
            }
            if (closed || (e & EPOLLRDHUP)) close(fd);
        }
    }

    close(ep);
    close(listen_fd);
    return EXIT_SUCCESS;
}

What it does: this is a complete single-threaded TCP echo server. It listens on port 8080, registers the listening socket with epoll, and then loops on epoll_wait. When the listener is ready it accepts every pending connection (draining to EAGAIN because the listener is edge-triggered) and registers each client with EPOLLIN | EPOLLET | EPOLLRDHUP. When a client is readable it reads until EAGAIN, echoing each chunk back, and closes the socket on EOF, error, or half-close.

Expected output: the server prints Echo server listening on port 8080 and then runs silently. Test it from another terminal:

$ nc localhost 8080
hello
hello        <-- echoed back

Key edge cases: accept returning EAGAIN is normal (the edge has been fully drained); EINTR on epoll_wait or read is a retry, not an error; closing an fd automatically removes it from the epoll interest list, so no explicit EPOLL_CTL_DEL is needed.

Line by line

We trace the path of one client connection through the loop.

  1. make_listener(PORT) creates a TCP socket, sets SO_REUSEADDR (so a quick restart can rebind), binds to 0.0.0.0:8080, calls listen, and makes the socket non-blocking. The fd is returned as listen_fd.
  2. epoll_create1(EPOLL_CLOEXEC) returns the epoll fd ep. In memory there is now a kernel object with an empty interest list.
  3. We fill ev with events = EPOLLIN | EPOLLET and data.fd = listen_fd, then epoll_ctl(ep, EPOLL_CTL_ADD, listen_fd, &ev) copies that into the kernel's interest list. The listener is now watched.
  4. The loop calls epoll_wait(ep, ready, 64, -1) and blocks. Nothing happens until a client connects.
  5. A client connects. The kernel marks listen_fd readable and adds it to the ready list. epoll_wait returns n = 1 and fills ready[0] = { .events = EPOLLIN, .data.fd = listen_fd }.
  6. In the inner loop fd == listen_fd, so we run the accept loop. accept returns a new client fd c; we make it non-blocking and register it with EPOLLIN | EPOLLET | EPOLLRDHUP. We call accept again; it returns -1 with errno == EAGAIN, so we break — the edge is drained.
  7. The client sends hello\n. The kernel marks c readable. Next epoll_wait returns ready[0].data.fd == c.
  8. fd != listen_fd, so we enter the read loop. read returns r = 6 (hello\n); we write those 6 bytes back. We call read again; it returns -1 with EAGAIN, so we break.
  9. The client presses Ctrl-D / closes. The kernel sets EPOLLRDHUP (and a later read would return 0). The read loop sees r == 0, sets closed = 1, and close(fd) removes c from epoll and frees the kernel socket.
Step epoll_wait returns fd read result action
connect n=1 listen_fd accept c, register c
data n=1 c r=6 echo 6 bytes
drained c r=-1 EAGAIN break read loop
close n=1 c r=0 close(c)

The result — bytes echoed back, connection cleaned up — is produced because each edge is fully drained before we trust epoll_wait to tell us about the next one.

Common mistakes

Mistake 1: edge-triggered without draining

/* WRONG: one read per event under EPOLLET */
if (ready[i].events & EPOLLIN) {
    ssize_t r = read(fd, buf, sizeof buf);  /* reads only the first chunk */
    write(fd, buf, r);
}

Why it is wrong: with EPOLLET the kernel notifies you once per arrival edge. If two chunks arrive before you wake up, one read consumes the first; the second sits unread, and you are never notified again. The connection stalls.

/* CORRECT: drain until EAGAIN */
for (;;) {
    ssize_t r = read(fd, buf, sizeof buf);
    if (r > 0) { write(fd, buf, r); continue; }
    if (r == 0) { close(fd); break; }
    if (errno == EAGAIN || errno == EWOULDBLOCK) break;
    if (errno == EINTR) continue;
    close(fd); break;
}

Recognize it: connections that "work in testing" but hang under concurrency, or hang only with large payloads.

Mistake 2: forgetting to re-arm EPOLLONESHOT

After an EPOLLONESHOT fd fires, it is disabled. If you do not EPOLL_CTL_MOD it back with the events you want, it never reports again. Fix: re-arm at the end of handling, e.g. ev.events = EPOLLIN | EPOLLONESHOT; epoll_ctl(ep, EPOLL_CTL_MOD, fd, &ev);.

Mistake 3: blocking fd with edge-triggered mode

If the fd is not non-blocking, the final read in your drain loop (the one that should return EAGAIN) instead blocks the whole thread, freezing every other connection. Always pair EPOLLET with O_NONBLOCK.

Mistake 4: re-adding an already-registered fd

epoll_ctl(ep, EPOLL_CTL_ADD, fd, &ev);  /* second time -> -1, errno EEXIST */

Use EPOLL_CTL_MOD to change an existing registration. Recognize it by an EEXIST from epoll_ctl.

Mistake 5: ignoring EPOLLERR/EPOLLHUP

These arrive whether or not you asked for them. If your branch only checks EPOLLIN, an errored fd never gets closed and lingers. Always test events[i].events & (EPOLLERR | EPOLLHUP) first and clean up.

Debugging tips

Compiler errors

  • EPOLLRDHUP undeclared or EPOLL_CLOEXEC undeclared: define #define _GNU_SOURCE before any include, or compile with -D_GNU_SOURCE.
  • implicit declaration of function 'epoll_create1': you forgot #include <sys/epoll.h>.

Runtime errors

  • bind: Address already in use: a previous instance is still holding the port (TIME_WAIT). Set SO_REUSEADDR, or wait. Use ss -tlnp to see who holds it.
  • epoll_ctl: File exists (EEXIST): you added an fd twice — use EPOLL_CTL_MOD.
  • epoll_wait: Interrupted system call (EINTR): a signal interrupted the wait; just loop and retry.

Logic errors (the hard ones)

  • Connections stall under load: almost always an edge-triggered fd that is not drained to EAGAIN. Switch temporarily to level-triggered (drop EPOLLET) — if the stall disappears, your drain loop is the bug.
  • A connection goes permanently silent after one exchange: a missing EPOLLONESHOT re-arm.

Tools

  • strace -e trace=epoll_create1,epoll_ctl,epoll_wait,accept,read ./prog shows the exact syscall sequence and arguments — invaluable for seeing whether you re-arm or drain.
  • ss -tnp lists current TCP connections and the owning process.
  • For a hung server: gdb -p PID then bt shows whether you are blocked in epoll_wait (idle, fine) or stuck in a read (blocking-fd bug).

Questions to ask when it doesn't work

  • Is every fd I read/write with EPOLLET set non-blocking?
  • Do I loop until EAGAIN on every edge-triggered fd, including the listener?
  • Do I re-arm after every EPOLLONESHOT?
  • Do I check for EPOLLERR/EPOLLHUP before assuming the fd is usable?

Memory safety

epoll is a thin kernel interface, but the surrounding C code has the usual hazards.

  • The events buffer is yours to size. epoll_wait writes up to maxevents entries into events[]. If you declare struct epoll_event ready[64] you must pass maxevents = 64, never larger — passing a bigger count than the array holds is an out-of-bounds write.
  • data.ptr lifetime. When you attach a heap-allocated per-connection struct via data.ptr, that struct must outlive every event for the fd and be freed exactly once, when the connection closes. Free it too early and the next event hands you a dangling pointer (use-after-free); never free it and every disconnect leaks. Pattern: allocate on accept, free immediately after the close(fd) that ends the connection.
  • Closing removes the fd from epoll automatically, but only if no other fd refers to the same open file description (e.g. after dup). If a duplicate exists, you must EPOLL_CTL_DEL explicitly, or stale events keep arriving for a closed-looking fd.
  • Uninitialized struct epoll_event. Always zero or fully initialize it before epoll_ctl; reading whichever data union member you did not set is a logic error that produces garbage fds/pointers.
  • Integer/ssize_t care. read returns ssize_t; store it there, not in an int, and only cast the positive length to size_t for write after confirming r > 0. Treating -1 as an unsigned length would attempt a huge write.

This lesson is not security-focused, but the same defensive habits — bounds on the events array, single-owner lifetime for data.ptr, and checking every return value — are exactly what keeps a long-running server from corrupting memory after days of uptime.

Real-world uses

Concrete uses. epoll is the I/O core of nginx and HAProxy (each worker process runs one epoll loop), Redis (single-threaded event loop), and libuv — the cross-platform loop under Node.js, which uses epoll on Linux and kqueue on BSD/macOS. Message brokers, database proxies, and game servers written in C almost universally sit on an epoll loop. Higher-level languages reach it indirectly: Python's asyncio selector, Go's runtime netpoller, and the JVM's NIO selector all call epoll underneath on Linux.

Professional best practices.

Beginner rules:

  • Start level-triggered; only move to edge-triggered when you have a measured reason.
  • Always pair EPOLLET with non-blocking fds and a drain-to-EAGAIN loop.
  • Check the return value of every epoll_ctl/epoll_wait and handle EINTR.
  • Close fds promptly; let close remove them from epoll.

Advanced practices:

  • Attach per-connection state via data.ptr and own its lifetime with a single allocate/free pair.
  • For multi-threaded designs, use EPOLLONESHOT so exactly one thread owns an fd at a time, and re-arm after handling.
  • Buffer partial writes: write on a non-blocking socket can return short or EAGAIN, so a robust server tracks an output buffer and registers EPOLLOUT only while it has pending data.
  • Batch wakeups: a maxevents of a few hundred amortizes syscall overhead under high load.
  • Keep the loop body fast — never do blocking work (disk, DNS, locks) inside it; offload to a thread pool, or the whole server stalls.

Practice tasks

Beginner 1 — level-triggered echo server

Objective: build a working echo server using level-triggered epoll. Requirements: create a listener on a port of your choice, register it with EPOLLIN (no EPOLLET), accept connections, and echo back whatever each client sends. Example: nc localhost 9000 then typing hi prints hi. Constraints: single thread; check every return value. Hints: with level-triggered mode a single read/write per event is acceptable. Concepts: epoll_create1, epoll_ctl, epoll_wait, EPOLLIN.

Beginner 2 — count and classify events

Objective: extend the loop to print, for each epoll_wait wakeup, how many fds were ready and which flags each carried. Requirements: for every ready[i], print the fd and a decoded list of flags (EPOLLIN, EPOLLOUT, EPOLLRDHUP, EPOLLERR, EPOLLHUP). Constraints: do not alter echo behavior. Hints: test each bit with &. Concepts: the events bitmask, EPOLLERR/EPOLLHUP always being reported.

Intermediate 1 — convert to edge-triggered

Objective: switch the server to edge-triggered and keep it correct. Requirements: register clients with EPOLLIN | EPOLLET, make every fd non-blocking, and read in a loop until EAGAIN. Verify it still handles a client that sends a large file via nc. Input/output: cat bigfile | nc localhost 9000 should echo the entire file back without stalling. Constraints: no blocking reads. Hints: a stall on large input means your drain loop is missing. Concepts: EPOLLET, O_NONBLOCK, drain-to-EAGAIN.

Intermediate 2 — clean half-close handling

Objective: detect and handle the peer closing its write half. Requirements: add EPOLLRDHUP to client registrations; when it fires (or read returns 0), finish echoing any buffered data, then close. Distinguish EPOLLRDHUP from EPOLLERR/EPOLLHUP in your log output. Constraints: no leaked fds. Hints: a half-closed peer can still receive your remaining echo before you close. Concepts: EPOLLRDHUP, EOF (read == 0), cleanup ordering.

Challenge — per-connection state with EPOLLONESHOT

Objective: attach a heap-allocated per-connection struct via data.ptr and process each fd with EPOLLONESHOT semantics. Requirements: define a struct conn { int fd; size_t bytes_echoed; }; allocate one per accepted client and store its pointer in data.ptr; register with EPOLLIN | EPOLLONESHOT. After handling an event, re-arm with EPOLL_CTL_MOD. On close, free the struct exactly once and track total bytes echoed. Constraints: no use-after-free, no leak (verify with valgrind --leak-check=full). Hints: re-arm before you might return to epoll_wait, but never after you have freed the struct. Concepts: data.ptr, EPOLLONESHOT, re-arming, ownership/lifetime.

Summary

  • epoll is a kernel-side registry of fds. You register each fd once with epoll_ctl, and epoll_wait returns only the ready ones, so cost scales with readiness (O(ready)), not total connections — unlike select/poll.
  • Core calls: epoll_create1(EPOLL_CLOEXEC) makes the instance; epoll_ctl(ADD/MOD/DEL) edits the interest list; epoll_wait fills your events[] array.
  • Level-triggered (default) keeps reporting while data remains — simple and safe. Edge-triggered (EPOLLET) reports once per arrival, requires non-blocking fds, and demands a drain-to-EAGAIN loop or the connection stalls. This is the single most important rule.
  • Use EPOLLONESHOT to give one thread sole ownership of an fd, and remember to re-arm with EPOLL_CTL_MOD. Use EPOLLRDHUP to catch half-closes early. EPOLLERR/EPOLLHUP always arrive — check for them.
  • Attach per-connection state via data.ptr, and free it exactly once when the connection closes. Closing an fd removes it from epoll automatically.
  • Common mistakes: no drain loop, blocking fd with EPOLLET, missing re-arm, double-add (EEXIST), and ignoring error events. Remember: epoll is the bedrock of nginx, Redis, HAProxy, and libuv.

Practice with these exercises