Futexes, poll/epoll, eventfd, and io_uring

Compare futex waiting, readiness polling, eventfd and io_uring submission.

17 min readChapter 48 of 168Intermediate → advanced
In this chapter

Before you start#

Read signals, pidfds, and job control, pipes and Unix sockets, and locks and sleeping. Linux v6.12 is the historical source baseline. The main lab needs Python on Linux and uses only local socketpair and eventfd objects; the optional C example depends on io_uring availability and policy.

Learning goals#

Explain futex compare-and-block and a lost-wakeup protocol, epoll readiness and descriptor lifetime, and io_uring submission and completion. Predict what happens when another consumer drains a ready fd, a wait value changes, or cancellation races completion.

Source walkthrough#

Futexes, poll/epoll, eventfd, and io_uring
Futex
Compare userspace word and wait; wake means recheck predicate.
epoll
Ready list reports an operation may make progress.
io_uring
A CQE reports the result of an identified submitted request.
Source map. Conditional Linux v6.12 source relationships; explanatory model, not a captured execution.

A futex is not a kernel mutex object automatically attached to a variable. It is an interface combining a userspace aligned 32-bit word and kernel wait queues. The common uncontended state transitions happen in userspace using atomics; a waiter can ask the kernel to sleep only if the word still equals an expected value. The kernel derives a key from the address and private/shared operation, hashes it to a bucket, and uses queue and locking rules to coordinate wait and wake. The caller owns the lock algorithm, memory ordering, word lifetime and mapping. A private futex optimization is appropriate only when waiters share the same address space as required; a process-shared futex requires a shared mapping and matching operation flags. See kernel/futex/core.c and kernel/futex/waitwake.c.

futex_wait_setup, GPL-2.0-or-later, locks the hash bucket and reads the userspace word. Its mismatch branch is:

c
	if (uval != val) {
		futex_q_unlock(*hb);
		ret = -EWOULDBLOCK;
	}

The value is compared while the relevant queue lock is held. If it has changed, the function releases that lock and returns the would-block error (EWOULDBLOCK, the same numeric value as EAGAIN on Linux), rather than sleeping based on a stale observation. If reading under the lock faults, preceding code unlocks, attempts an ordinary user access, and retries or returns an error. This is a compare-and-block primitive, not an assertion that the application condition is true after a wake.

Consider a hypothetical condition word initially 0. Waiter W observes 0 and intends to sleep. Waker P stores 1 with the ordering required by their protocol, then calls wake. If P's store occurs before W enters the kernel check, W sees 1 instead of expected 0 and receives EAGAIN; it rechecks the condition and does not sleep forever waiting for an already-consumed wake. If W is queued first, P's wake can make it runnable. The application must loop around the predicate because a wake is not ownership of the resource and can be interrupted, timed out, or rendered stale by another thread. The kernel's ordering and bucket protocol pair with a correct userspace atomic protocol; a plain non-atomic C data race is not repaired by calling futex.

In the matching futex_wake path, Linux checks that the wake bitset is nonzero, derives the futex key and hashes it to a bucket. When waiters may be present, the function holds the bucket spinlock while matching queued keys and bitsets, collects eligible wakeups, releases the lock, then calls wake_up_q. A PI waiter encountered on this non-PI wake path can cause -EINVAL; the generic wake is not a substitute for the PI protocol. A count of awakened tasks is not a count of successfully acquired application locks.

In kernel/futex/waitwake.c, v6.12, GPL-2.0-or-later (the bucket unlock and wake_up_q after this loop are omitted):

c
	spin_lock(&hb->lock);

	plist_for_each_entry_safe(this, next, &hb->chain, list) {
		if (futex_match (&this->key, &key)) {
			if (this->pi_state || this->rt_waiter) {
				ret = -EINVAL;
				break;
			}

			/* Check if one of the bits is set in both bitsets */
			if (!(this->bitset & bitset))
				continue;

			this->wake(&wake_q, this);
			if (++ret >= nr_wake)
				break;

A PI or rt-mutex waiter on this non-PI path makes the wake stop with -EINVAL, and a waiter whose bitset does not overlap the wake bitset is skipped rather than woken. The return value counts waiters selected to wake, not locks acquired. The excerpt does not show how the key is derived, the pending-waiter fast check, or the unlock that follows.

Futex wait and wake with a changed word
Futex wait and wake with a changed word1. Waiter thread to futex_wait_setup: futex_wait(word, expected 0). 2. futex_wait_setup to Waiter thread (return): EWOULDBLOCK if word != 0. 3. Waker thread to futex_wake: store 1, then futex_wake. 4. futex_wake to Waiter thread (return): wake a queued waiter, if any.Waiter threadfutex_wait_setupWaker threadfutex_wakefutex_wait(word,expected 0)EWOULDBLOCK if word!= 0store 1, thenfutex_wakewake a queued waiter, if any
  1. Waiter threadfutex_wait_setup

    futex_wait(word, expected 0)

  2. futex_wait_setupWaiter thread

    Return. EWOULDBLOCK if word != 0

  3. Waker threadfutex_wake

    store 1, then futex_wake

  4. futex_wakeWaiter thread

    Return. wake a queued waiter, if any

Time runs downward. Dashed arrows show returns.

Read the text version
  1. Waiter thread → futex_wait_setup: futex_wait(word, expected 0)
  2. futex_wait_setup → Waiter thread (return): EWOULDBLOCK if word != 0
  3. Waker thread → futex_wake: store 1, then futex_wake
  4. futex_wake → Waiter thread (return): wake a queued waiter, if any
Conceptual model. Teaching model of futex_wait_setup and futex_wake in Linux v6.12, not a captured trace; the store order is hypothetical and the application protocol decides correctness.

A futex key and queued waiter must continue to refer to valid shared memory for the protocol's lifetime. Unmapping or reusing the word while waiters still rely on it can confuse the application even if the kernel eventually removes a waiter. Requeue operations can move waiters between futex queues under their defined rules, useful for some condition-variable implementations; they do not transfer an application lock by magic. Priority-inheritance futexes use kernel PI state and an RT-mutex mechanism to address priority inversion. They have specific owner and operation constraints, including error and owner-death handling, and cannot be mixed arbitrarily with non-PI operations on the same protocol. Alignment, accessible memory, key selection and correct wake count all matter.

epoll: a readiness list, not a completion queue#

epoll maintains an interest set of watched open-file relationships and a ready list of items that may be ready. epoll_ctl adds, changes or removes interest; the target file's poll method and wait queues connect state changes to callbacks. ep_poll_callback can run in contexts where it cannot take the ordinary epoll mutex, so it uses the relevant locking and ready-list protocol. In fs/eventpoll.c, GPL-2.0-or-later, the usual callback path contains:

c
	} else if (!ep_is_linked(epi)) {
		/* In the usual case, add event to ready list. */
		if (list_add_tail_lockless(&epi->rdllink, &ep->rdllist))
			ep_pm_stay_awake_rcu(epi);
	}

It checks whether the item is already linked before adding it, then performs the associated wakeup-source action when insertion succeeds. During transfer of events to userspace, a separate overflow-list path preserves concurrent notifications. Other code checks event masks, handles one-shot state, wakes waiters and rechecks actual readiness. This small fragment alone is not the entire concurrency proof.

do_epoll_ctl obtains references for the epoll fd and target fd, rejects a target that cannot be polled, and checks that the epoll file is valid and not the same file as the target before the relevant add, modify or delete work. Failure paths release the acquired fd references. This is a control-plane operation; the callback above is a later notification path and can race with registration changes under the kernel's synchronization rules.

Readiness means an operation is expected to make progress under the interface at the observed time, not that a particular future read has already completed. Suppose socket fd 7 has three bytes abc. epoll reports readable; before thread A calls read, thread B consumes all three bytes. A nonblocking read by A can return EAGAIN. This is not an epoll bug: readiness and consumption were separate events. A robust loop handles short reads, EOF and EAGAIN even after notification. The actual integer fd is a process-table index and can be reused after close.

Level-triggered mode can report a file again while the condition remains ready. Edge-triggered mode reports transitions and is normally paired with nonblocking I/O that drains until EAGAIN; leaving bytes unread and then waiting for a new edge can stall the application. EPOLLONESHOT disables further reports for the item after delivery until rearmed with EPOLL_CTL_MOD. Duplicate descriptors can refer to one open file description; closing one fd does not necessarily remove all references, and fd reuse can make a stored integer misleading. Manage the watched file and the application's registration lifetime deliberately. The exact removal semantics depend on the fd and underlying open-file references, not just the numeric value.

eventfd provides a kernel-maintained 64-bit counter with read/write and poll semantics. A normal read obtains the counter and resets it to zero; semaphore mode has different decrement behavior. A read when the counter is zero can block or return EAGAIN in nonblocking mode. It is a notification/counting primitive, not the payload queue itself: the consumer still needs a protocol for the associated data and races. fs/eventfd.c implements these operations.

io_uring: requests and completions#

An io_uring instance exposes a submission queue (SQ) and completion queue (CQ), normally mapped into userspace. An SQ entry (SQE) describes an operation and a user_data value; a CQ entry (CQE) returns that value, a result and flags. The ring head and tail are shared state with memory-ordering requirements. Userspace must initialize an SQE before publishing its tail and must not overwrite unconsumed entries. The kernel consumes submissions, creates and tracks request state, issues or defers work, and posts completion under the operation's rules. A CQE with a negative result represents a negative errno for that operation; it is not necessarily a failed io_uring_enter syscall.

In io_uring/io_uring.c, io_uring_enter can call io_submit_sqes, which prepares requests and reaches io_issue_sqe. That function chooses the operation definition and calls its issue method; a successful IOU_OK result takes a deferred or posted completion path depending on flags. For a simple NOP, io_uring/nop.c, GPL-2.0, contains:

c
	if (nop->result < 0)
		req_set_fail(req);
	io_req_set_res(req, nop->result, 0);
	return IOU_OK;

The NOP sets request failure state for a negative injected result, records the result and flags for completion, then returns the internal success code to the issue machinery. Normal NOP preparation sets result zero unless the supported injection option is requested. IOU_OK is an internal issue result, not the userspace CQE's res field. This is a source trace, not evidence that a ring ran in this session.

In the same file, io_submit_sqes, v6.12, GPL-2.0 (function prologue omitted), is the loop that turns published SQEs into requests:

c
	ret = left = min(nr, entries);
	io_get_task_refs(left);
	io_submit_state_start(&ctx->submit_state, left);

	do {
		const struct io_uring_sqe *sqe;
		struct io_kiocb *req;

		if (unlikely(!io_alloc_req(ctx, &req)))
			break;
		if (unlikely(!io_get_sqe(ctx, &sqe))) {
			io_req_add_to_cache(req, ctx);
			break;
		}

		/*
		 * Continue submitting even for sqe failure if the
		 * ring was setup with IORING_SETUP_SUBMIT_ALL
		 */
		if (unlikely(io_submit_sqe(ctx, req, sqe)) &&
		    !(ctx->flags & IORING_SETUP_SUBMIT_ALL)) {
			left--;
			break;
		}
	} while (--left);

This loop only submits; it does not wait for completions. A failed SQE ends the loop unless the ring was set up with IORING_SETUP_SUBMIT_ALL, and an allocation failure also stops it early. The excerpt does not show io_uring_enter's wait logic or how io_submit_sqe prepares each request.

NOP submission path in io_uring
  1. io_uring_enter

    Syscall that submits published SQEs; may also wait for completions.

  2. io_submit_sqes

    Loops over SQEs, allocating and preparing one request each.

  3. io_issue_sqe

    Picks the io_issue_defs entry for the opcode and calls its issue method.

  4. io_nop

    Sets failure state for a negative injected result; returns IOU_OK.

  5. CQE posted

    Completion carries user_data and res back through the CQ ring.

Source map. Source map of Linux v6.12 io_uring submission for one NOP, not a captured trace; the ring and completion details are configuration-dependent.

SQPOLL can let a kernel thread poll the submission ring; IOPOLL concerns polling I/O completion for supported operations and devices. They are different features and may require particular permissions or configurations. io-wq workers can execute work that cannot complete in the initial issue context, and task work can defer completion-related work to a task under the relevant rules. Registered files and buffers can reduce repeated setup but keep references and memory pinned or accounted according to their registration and resource limits. Do not free, unmap, close or reuse a buffer or registered resource before the API says outstanding operations no longer need it.

Cancellation is a request that can race the original operation. A cancel CQE and the original operation's CQE describe separate outcomes; a cancellation failure can mean the operation already completed or could not be canceled. Linked timeouts also have ordering and cancellation semantics and are not a guarantee that a device stopped exactly at a wall-clock deadline. A completion can arrive after the application stopped waiting, so user_data should identify a live request generation rather than a pointer to freed state. Registered resources, credentials, limits, security policy, kernel configuration and supported opcodes affect behavior. Never disable seccomp or other security controls just to make the lab run.

Readiness versus completion: one concrete comparison#

Use a nonblocking socket containing abc. With epoll, a readable event tells the application to attempt a read; another reader can remove abc first, leaving EAGAIN. The event does not contain the three bytes or guarantee which thread gets them. With an io_uring read SQE, the application supplies an fd, destination buffer and requested length. A later CQE reports the operation's result, for example res=3; only after the applicable completion and buffer-lifetime rules can the application consume those three received bytes as that request's result. A CQE of -EAGAIN or a short read is different from res=3. Completion does not by itself imply an application message boundary or durable storage.

These interfaces can coexist: epoll may monitor a ring fd, and an io_uring operation can poll readiness. Their meanings remain different. A readiness event is a prompt to attempt an operation; a CQE is a result for an identified submitted operation. For futex, a wake is neither of those: it makes eligible waiters runnable so they can recheck their userspace predicate. Confusing the three leads to missed state changes, premature buffer reuse or treating a notification count as data.

Registration and cleanup are part of the protocol#

When an epoll instance registers a target, the target's poll implementation connects wait queues to epoll callback entries. Event delivery and removal can race with a callback, so the kernel uses synchronization around item lifetime as well as ready-list membership. User code also needs a lifetime rule for the object it associates with epoll_event.data: a returned event may refer to application state that another thread is trying to destroy. A numeric fd alone is not a generation identifier after close and reuse. Coordinated deregistration and deferred application-object reclamation avoid treating an old event as an event for a new connection.

An io_uring ring can have requests whose CQEs have not yet been consumed even after the initiating function returns. Applications must size and drain their CQ according to the configured interface and avoid overwriting their own bookkeeping when user_data values are reused. Registered buffers and files have explicit registration and unregistration lifetimes; an operation holding a reference may continue to use the underlying resource even if another descriptor is closed. Resource accounting and security restrictions vary across kernels and configurations. A NOP tests the minimal submission/completion loop, not the lifetime correctness of a buffer used by a real read.

Practice#

Source-only route:

sh
rg -n -A55 'int futex_wait_setup\(' "$KERNEL_SRC/kernel/futex/waitwake.c"
rg -n -A65 'static int ep_poll_callback\(' "$KERNEL_SRC/fs/eventpoll.c"
rg -n -A35 'static int io_issue_sqe\(' "$KERNEL_SRC/io_uring/io_uring.c"
rg -n -A12 'int io_nop\(' "$KERNEL_SRC/io_uring/nop.c"

Confirm the checkout tag is v6.12. Mark the futex mismatch unlock, epoll ready-list insertion and NOP result-to-completion path. Explain the 0 -> 1 race above without claiming it ran. Find io_uring_enter and io_submit_sqes in the same core source to identify the syscall/submission boundary.

The following unprivileged Linux Python observation needs select.epoll, socketpair and os.eventfd; it exits cleanly if eventfd is unavailable:

sh
python3 - <<'PY'
import os, select, socket
if not hasattr(os, 'eventfd'):
    print('eventfd unavailable'); raise SystemExit(0)
a, b = socket.socketpair(); a.setblocking(False)
e = select.epoll(); efd = os.eventfd(0, os.EFD_NONBLOCK)
try:
    e.register(a.fileno(), select.EPOLLIN)
    e.register(efd, select.EPOLLIN)
    b.sendall(b'abc'); os.eventfd_write(efd, 2)
    events = e.poll(1)
    print('socket ready:', any(fd == a.fileno() for fd, _ in events))
    print('eventfd ready:', any(fd == efd for fd, _ in events))
    print('read:', a.recv(3), 'counter:', os.eventfd_read(efd))
    try: a.recv(1)
    except BlockingIOError: print('drained: EAGAIN')
    try: os.eventfd_read(efd)
    except BlockingIOError: print('counter empty: EAGAIN')
finally:
    e.close(); os.close(efd); a.close(); b.close()
PY

Expected both readiness lines True, then b'abc' and counter 2, followed by the two EAGAIN lines. Other events and scheduling can vary; the example checks membership rather than event order. If the kernel or Python lacks a required interface, record the error or unavailable message. No network interfaces, privileges or persistent objects are changed. Deliver a timeline of socket bytes, epoll report, read result and counter state.

Optional io_uring NOP observation#

This optional single-request C program uses the Linux io_uring UAPI and system calls directly, with no liburing dependency. It maps the SQ, CQ and SQEs, publishes one NOP with release ordering, waits for a completion, reads it with acquire ordering and closes the ring. Run it only where a compiler and compatible Linux UAPI headers are available. Kernel configuration, seccomp and other policy can reject setup; do not disable protections. The code is a synthetic lab program, not an upstream source excerpt.

c
#define _GNU_SOURCE
#include <linux/io_uring.h>
#include <sys/syscall.h>
#include <sys/mman.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>

int main(void) {
    struct io_uring_params p = {0};
    int fd = syscall(__NR_io_uring_setup, 2, &p);
    if (fd < 0) { perror("io_uring_setup"); return 1; }
    size_t slen = p.sq_off.array + p.sq_entries * sizeof(unsigned);
    size_t clen = p.cq_off.cqes + p.cq_entries * sizeof(struct io_uring_cqe);
    if (p.features & IORING_FEAT_SINGLE_MMAP) {
        if (clen > slen) slen = clen;
        clen = slen;
    }
    char *sq = mmap(NULL, slen, PROT_READ | PROT_WRITE, MAP_SHARED, fd, IORING_OFF_SQ_RING);
    if (sq == MAP_FAILED) { perror("mmap SQ"); close(fd); return 1; }
    char *cq = sq;
    if (!(p.features & IORING_FEAT_SINGLE_MMAP)) {
        cq = mmap(NULL, clen, PROT_READ | PROT_WRITE, MAP_SHARED, fd, IORING_OFF_CQ_RING);
        if (cq == MAP_FAILED) { perror("mmap CQ"); munmap(sq, slen); close(fd); return 1; }
    }
    size_t elen = p.sq_entries * sizeof(struct io_uring_sqe);
    struct io_uring_sqe *entries = mmap(NULL, elen, PROT_READ | PROT_WRITE, MAP_SHARED, fd, IORING_OFF_SQES);
    if (entries == MAP_FAILED) {
        perror("mmap SQEs"); if (cq != sq) munmap(cq, clen); munmap(sq, slen); close(fd); return 1;
    }
    unsigned *stail = (unsigned *)(sq + p.sq_off.tail);
    unsigned *smask = (unsigned *)(sq + p.sq_off.ring_mask);
    unsigned *array = (unsigned *)(sq + p.sq_off.array);
    unsigned tail = __atomic_load_n(stail, __ATOMIC_RELAXED);
    unsigned index = tail & *smask;
    memset(&entries[index], 0, sizeof(entries[index]));
    entries[index].opcode = IORING_OP_NOP;
    entries[index].user_data = 42;
    array[index] = index;
    __atomic_store_n(stail, tail + 1, __ATOMIC_RELEASE);
    int result = 1;
    if (syscall(__NR_io_uring_enter, fd, 1, 1, IORING_ENTER_GETEVENTS, NULL, 0) < 0) {
        perror("io_uring_enter");
    } else {
        unsigned *head = (unsigned *)(cq + p.cq_off.head);
        unsigned *ctail = (unsigned *)(cq + p.cq_off.tail);
        unsigned *cmask = (unsigned *)(cq + p.cq_off.ring_mask);
        unsigned h = __atomic_load_n(head, __ATOMIC_RELAXED);
        if (h == __atomic_load_n(ctail, __ATOMIC_ACQUIRE)) {
            fprintf(stderr, "no completion\n");
        } else {
            struct io_uring_cqe *c = (struct io_uring_cqe *)(cq + p.cq_off.cqes);
            struct io_uring_cqe *item = &c[h & *cmask];
            printf("user_data=%llu res=%d\n", (unsigned long long)item->user_data, item->res);
            result = !(item->user_data == 42 && item->res == 0);
            __atomic_store_n(head, h + 1, __ATOMIC_RELEASE);
        }
    }
    munmap(entries, elen); if (cq != sq) munmap(cq, clen); munmap(sq, slen); close(fd);
    return result;
}

Save it as nop.c in your own temporary directory, compile with cc -std=c11 -Wall -Wextra nop.c -o nop, then run ./nop. Expected user_data=42 res=0. ENOSYS, EPERM, unsupported headers, a resource limit or seccomp restriction is a valid result; record it and use the source-only route. Delete only your own files afterward. The example exercises one NOP and is not a general concurrent ring library: it does not test storage, cancellation, registered buffers, SQPOLL or IOPOLL.

Knowledge check#

  1. Why does a futex wait return EAGAIN when the word no longer matches the expected value?
  2. Why can a nonblocking read return EAGAIN after epoll reported readability?
  3. What must an edge-triggered reader do with available data, and how is one-shot rearmed?
  4. What does a NOP CQE with user_data=42, res=0 establish?
  5. Why is a cancel request not proof that the original I/O has stopped?

Answers#

  1. The compare-and-block check sees changed state and refuses to sleep on the stale expected value; userspace must recheck its predicate.
  2. Another consumer or a state change can remove readiness between notification and read. The event was not a reservation of bytes.
  3. Normally use nonblocking I/O and drain until EAGAIN; one-shot interest is rearmed with EPOLL_CTL_MOD.
  4. The identified NOP completed with result zero. It does not demonstrate storage I/O, durable data or support for other opcodes.
  5. Cancellation and original completion race; inspect both results and keep request resources alive until the applicable completion/lifetime contract is satisfied.

Checkpoint#

Save the futex race timeline, epoll/eventfd output and either NOP output or the documented reason it was skipped. Pass when you distinguish a predicate wake, readiness notification and operation completion, and identify safe buffer lifetime and cancellation handling. Re-read the three excerpts and retry the timeline if any claim conflates them. This is practice, not certification.

Sources#

A little further beneath the surface.

Check your understanding with the checkpoint. Marking a chapter as read saves a bookmark, not a proficiency assessment.