Home
ArenaGraphSignalTopics
Back to Feed

High-Throughput Linux I/O: io_uring & eBPF/XDP vs epoll

Last Updated • 1d ago
High-Throughput Linux I/O: io_uring & eBPF/XDP vs epoll

High-Throughput Linux I/O: io_uring & eBPF/XDP vs epoll

The quest for maximum I/O throughput in Linux systems has traversed three distinct architectural epochs. In the early 2000s, the C10K problem catalyzed the migration from blocking multi-threaded and multiplexing APIs (select, poll) to stateful, event notifications via epoll. For nearly two decades, epoll served as the foundational bedrock for high-performance servers, powering web titans such as NGINX, HAProxy, Envoy, Redis, and Netty.

However, modern cloud-native workloads, microsecond-scale NVMe storage arrays (exceeding 10 million IOPS per device), and 100GbE/400GbE network interfaces exposed the fundamental physical limits of epoll. The C10M problem cannot be resolved simply by polling file descriptors more aggressively. The systemic bottlenecks of epoll are rooted in hardware-level realities: the exorbitant CPU overhead of user-kernel context switching, catastrophic instruction-cache (I-cache) pollution, page table isolation penalties (KPTI), and repeated memory copying over the system memory bus.

To breach these physical barriers, the modern Linux kernel introduced two revolutionary paradigms:

  1. io_uring: A lockless, memory-mapped dual ring-buffer interface created by Jens Axboe that delivers truly asynchronous, zero-syscall, zero-copy storage and network I/O.
  2. eBPF & XDP (eXpress Data Path): An in-kernel, JIT-compiled sandboxed virtual machine enabling programmable, line-rate packet processing at the Network Interface Card (NIC) driver layer—bypassing the entire Linux network stack allocation path (struct sk_buff).

This architectural deep dive analyzes the internal mechanics, memory layouts, CPU cache interactions, and mathematical throughput bounds of epoll, io_uring, and eBPF/XDP/AF_XDP, culminating in production implementation code, performance benchmarks, and kernel diagnostic runbooks.


Interactive Blueprint
Rendering diagram...

1. The Cost of Modern I/O: Hardware & Kernel Realities

To understand why epoll degrades under extreme concurrency, we must quantify the microarchitectural cost of servicing an I/O request through conventional synchronous system calls.

1.1 The Anatomy of a System Call Overhead

When an application invokes epoll_wait(), read(), or write(), the CPU executes an unprivileged-to-privileged transition via the syscall instruction on x86-64:

Interactive Blueprint
Rendering diagram...

The constituent latency factors include:

  1. Hardware Context Switch (): Saving user-space CPU registers (RAX, RCX, R11, RSP, FLAGS) to the kernel thread stack, switching CPU privilege levels from Ring 3 to Ring 0, and transitioning the CPU stack pointer.
  2. Kernel Page Table Isolation (): Following the hardware vulnerabilities Meltdown (CVE-2017-5754) and Spectre, modern kernels isolate user and kernel address spaces via KPTI. Every transition forces a reload of the Translation Lookaside Buffer (TLB) address space identifier or a write to the CR3 control register, costing an additional 150 to 350 CPU cycles per invocation.
  3. Instruction & Data Cache Pollution (): The kernel's I/O and network stack execution paths displace the application's working data from the CPU L1/L2 caches ( L1i / L1d). Returning to user space triggers costly cache misses (L3 access takes ; DRAM access takes ).

For an application processing , if each request requires an average of 2.5 syscalls (epoll_wait, read, write), the CPU spends:

The CPU spends nearly its entire compute budget crossing the kernel boundary rather than executing application business logic.


2. Deep Dive: The Internal Architecture of epoll

epoll is an event-notification subsystem designed to monitor multiple file descriptors (FDs) to determine whether I/O is possible. Unlike select() and poll(), which require passing an array of FDs to the kernel on every call ( scanning cost), epoll maintains state within the kernel via a dedicated kernel object.

Interactive Blueprint
Rendering diagram...

2.1 The Two Core Structures: Red-Black Tree & Ready List

Within fs/eventpoll.c, the kernel allocates a struct eventpoll containing:

  1. Red-Black Tree (struct rb_root_cached rbr): Stores all monitored file descriptors wrapped in struct epitem. Keyed by the struct file pointer and FD integer, enabling addition (EPOLL_CTL_ADD), modification (EPOLL_CTL_MOD), and deletion (EPOLL_CTL_DEL).
  2. Ready List (struct list_head rdllist): A doubly-linked list containing only the struct epitem instances that have received hardware/driver I/O wakeups and are ready for user consumption.

2.2 The Lifecycle of an epoll Event

  1. Registration: When epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev) is called, the kernel allocates an epitem, inserts it into the Red-Black tree, and attaches a custom callback (ep_poll_callback) to the monitored file's driver wait queue using init_waitqueue_entry.
  2. Hardware Interrupt & Driver Notification: When a network packet arrives at the NIC, the hardware triggers an interrupt. The NIC driver services the interrupt, allocates an sk_buff, places it on the socket's receive queue, and invokes wake_up_interruptible() on the socket's wait queue.
  3. Kernel Callback Execution: The ep_poll_callback() executes in interrupt/softirq context:
    • Acquires the eventpoll.lock spinlock.
    • Checks if the epitem is already on the rdllist.
    • If not, links the epitem into rdllist and wakes up any application threads blocked in epoll_wait().
  4. User Consumption: The blocked thread in epoll_wait() is awakened. The kernel traverses rdllist, copies the ready events into the user-provided struct epoll_event buffer via copy_to_user(), and updates the list according to trigger mode semantics.

2.3 Edge-Triggered (EPOLLET) vs Level-Triggered (EPOLLLT)

FeatureLevel-Triggered (EPOLLLT - Default)Edge-Triggered (EPOLLET)
Notification SemanticFires repeatedly as long as the underlying buffer contains unconsumed bytes or buffer space.Fires only once on state transition (e.g., when new data arrives on an empty buffer).
Ready List HandlingIf data remains unread, epoll_wait keeps or immediately re-inserts the epitem into rdllist.The epitem is immediately removed from rdllist after copying to user space.
Drain RequirementApplication can read partial bytes (e.g. chunk) and return to epoll_wait.Application must loop read() until EAGAIN or EWOULDBLOCK is returned; otherwise, the event is permanently lost.
Syscall OverheadLower risk of starvation, but higher syscall volume due to repeated notifications.Minimal notification overhead, but risk of thread starvation if one socket processes excessive data.

2.4 The Fundamental Limits of epoll

  • Two-Step Synchronous Model: epoll is strictly an event-readiness notification system, not an asynchronous I/O engine. It notifies that an FD is ready, but the application must still issue synchronous read() or write() syscalls.
  • Buffer Copying Overhead: Every read() requires copy_to_user() to move bytes from kernel page cache or socket ring buffers into user-space heap buffers.
  • Thundering Herd: If multiple worker threads wait on the same epoll descriptor without EPOLLEXCLUSIVE, a single incoming connection awakens all threads, causing severe lock contention.

3. Deep Dive: io_uring Architecture & Lockless Rings

Introduced by Jens Axboe in Linux 5.1, io_uring eliminates the fundamental inefficiencies of epoll. Rather than separating notification from I/O execution, io_uring provides a unified, submission-and-completion interface operating over shared memory lockless ring buffers.

Interactive Blueprint
Rendering diagram...

3.1 Lockless Ring Buffer Mechanics (Single-Producer Single-Consumer)

io_uring establishes two ring buffers shared between the user process and the Linux kernel via mmap():

  1. Submission Queue (SQ): The user application is the Producer; the kernel is the Consumer.
  2. Completion Queue (CQ): The kernel is the Producer; the user application is the Consumer.

Because each ring has exactly one producer and one consumer, synchronization requires zero mutexes and zero spinlocks. Coordination is achieved entirely through atomic operations and memory ordering barriers.

Submission Queue Pointer Synchronization:

  • User space writes one or more struct io_uring_sqe into the SQE array.
  • User space updates the SQ ring tail: Using an atomic store with Release Memory Semantics (atomic_store_explicit(..., memory_order_release)).
  • The kernel reads entries up to sq_ring.tail, executes the requests, and updates sq_ring.head with Acquire Memory Semantics.

Completion Queue Pointer Synchronization:

  • As I/O operations complete, the kernel writes struct io_uring_cqe records into the CQ ring.
  • The kernel advances cq_ring.tail with a release store.
  • User space inspects cq_ring.head and cq_ring.tail. When , user space consumes completed events and advances cq_ring.head with an acquire load.
c
Loading code editor...

3.2 Key Data Structures

struct io_uring_sqe (Submission Queue Entry - 64 bytes)

c
Loading code editor...

struct io_uring_cqe (Completion Queue Entry - 16 bytes)

c
Loading code editor...

3.3 Advanced io_uring Features

1. Zero-Syscall Mode: IORING_SETUP_SQPOLL

When initialized with the IORING_SETUP_SQPOLL flag, the kernel spawns a dedicated kernel polling thread (io_uring-sq).

Interactive Blueprint
Rendering diagram...

In this mode, steady-state I/O operations execute with 0 system calls and 0 context switches.

2. Fixed Buffers (IORING_REGISTER_BUFFERS)

Standard read() and write() calls require the kernel to look up the user-space virtual memory addresses, lock physical pages into memory (get_user_pages()), and create scatter-gather lists on every operation. With io_uring_register_buffers(), the application pre-pins physical memory pages at startup. Subsequent I/O operations specify a buf_index, eliminating virtual-to-physical translation overhead and achieving true zero-copy storage transfers.

Allows creating atomic chains of operations in the submission queue:

Interactive Blueprint
Rendering diagram...

If SQE 1 fails, the kernel automatically aborts SQE 2 and SQE 3 without returning control to user space.


4. Deep Dive: eBPF, XDP, and AF_XDP Zero-Copy Networking

While io_uring redefines general-purpose file and socket I/O, eBPF (Extended Berkeley Packet Filter) and XDP (eXpress Data Path) represent the ultimate evolution in programmable, line-rate network packet processing.

Interactive Blueprint
Rendering diagram...

4.1 The Overhead of struct sk_buff

In the standard Linux network stack, every incoming packet is wrapped in a struct sk_buff (socket buffer).

  • struct sk_buff is a massive data structure (exceeding 240 bytes of control metadata across multiple cache lines).
  • Allocating, initializing, and freeing sk_buff per packet consumes significant CPU cycles and creates massive cache churn.
  • For a 100GbE link saturated with 64-byte minimum-size packets (), the CPU has only per packet. Allocating an sk_buff alone takes , making line-rate processing mathematically impossible via standard socket APIs.

4.2 The XDP Architecture

XDP attaches an eBPF program directly to the lowest level of the network driver, immediately after the NIC has completed Direct Memory Access (DMA) transfer into host RAM, before any memory allocation or kernel stack processing occurs.

An XDP eBPF program receives a lightweight struct xdp_md containing raw pointers to the packet memory (data and data_end) and returns one of five deterministic verdicts:

  1. XDP_DROP: Immediately drops the packet at the driver level. Drops over 24 million packets per second per CPU core (indispensable for line-rate DDoS scrubbing).
  2. XDP_TX: Bounces the packet back out of the same network interface it arrived on, modifying packet headers in-place (ideal for high-throughput Layer 4 load balancers like Facebook Katran).
  3. XDP_REDIRECT: Bypasses the entire kernel stack and redirects the packet to another NIC, a Virtual Ethernet (veth) device, or an AF_XDP zero-copy user-space socket.
  4. XDP_PASS: Hands the packet over to the standard Linux TCP/IP stack, allocating an sk_buff.
  5. XDP_ABORTED: Indicates an eBPF program error; drops the packet and triggers an eBPF tracepoint.

4.3 AF_XDP (XSK): Zero-Copy Kernel Bypass

AF_XDP is an address family optimized for ultra-high-speed packet processing that bridges XDP into user-space applications without sacrificing the Linux security model.

AF_XDP registers a dedicated memory region called UMEM (User Memory), divided into fixed-size chunks (e.g. ). Communication between the driver and user space occurs over four lockless ring queues:

Interactive Blueprint
Rendering diagram...
  1. Fill Ring: User space deposits empty UMEM frame addresses for the NIC driver to populate with incoming packets.
  2. Rx Ring: The kernel driver notifies user space of received packets, passing UMEM descriptors containing packet lengths and offsets.
  3. Tx Ring: User space deposits populated UMEM frames to be transmitted directly by the NIC.
  4. Completion Ring: The kernel notifies user space that transmission is complete and the UMEM frame can be reused.

In Zero-Copy mode (XDP_ZEROCOPY), the NIC's DMA engine transfers raw packet bytes directly into user-accessible UMEM frames. The application reads and writes packets with zero memory copies, zero kernel allocations, and microsecond latencies.


5. Architectural Comparison & Mathematical Bounds

5.1 Comprehensive Architectural Matrix

Dimensionepollio_uringeBPF / XDPAF_XDP
Primary DomainSocket readiness multiplexingGeneral-purpose async I/O (Disk + Net)In-kernel packet filtering & L4 routingZero-copy user-space raw packet I/O
Kernel IntroLinux 2.6 (2002)Linux 5.1 (2019)Linux 4.8 (2016)Linux 4.18 (2018)
Syscalls per I/O (epoll_wait + read/write) ( with SQPOLL) (Runs in driver IRQ/NAPI) (Polling or sendto)
Memory ModelDouble-buffered (copy_to_user)Shared lockless rings (mmap)In-place driver packet memoryShared lockless UMEM rings
Protocol SupportFull POSIX TCP/UDP/UnixFull POSIX TCP/UDP/Files/StorageRaw L2/L3/L4 FramesRaw L2/L3/L4 Frames
Programming ModelReactor / Proactor event loopCompletion-based ProactorSandboxed C / Restricted LLVM IRRing-buffer batch consumer
Context SwitchesHigh ()Zero (with SQPOLL)ZeroZero
Throughput Ceiling
Latency per Op

5.2 Decision Framework: Choosing the Right Engine

Interactive Blueprint
Rendering diagram...

6. Production Implementation: High-Throughput io_uring Server

The following production C implementation utilizes liburing to implement a high-throughput, non-blocking asynchronous TCP echo server with batched multi-shot operations and registered rings.

c
Loading code editor...

7. Production eBPF / XDP DDoS Mitigation Filter

The following C program compiles with clang -target bpf -O2 to provide hardware line-rate SYN flood and UDP amplification filtering at the NIC driver layer:

c
Loading code editor...

8. SRE & Performance Diagnostic Runbook

When debugging high-throughput I/O stalls, use the following kernel observability tools and performance profilers.

8.1 Tracing io_uring Latency Stalls with bpftrace

bash
Loading code editor...

8.2 Inspecting Active XDP Programs on Interfaces

bash
Loading code editor...

8.3 Kernel Sysctl Tuning for C10M High-Throughput I/O

Add the following parameters to /etc/sysctl.d/99-high-performance-io.conf:

ini
Loading code editor...

Apply immediately:

bash
Loading code editor...

9. Academic Bibliography & Further Reading

  1. Axboe, J. (2019). Efficient IO with io_uring. Kernel Documentation, Linux Foundation. https://kernel.dk/io_uring.pdf
  2. Høiland-Jørgensen, T., Brouer, J. D., Mankami, M. S., Aelbrecht, P., & Ward, D. (2018). The eXpress Data Path: Fast Programmable Packet Processing in the Linux Kernel. In Proceedings of the 14th International Conference on Emerging Networking EXperiments and Technologies (ACM CoNEXT '18), pp. 54–66. https://doi.org/10.1145/3281411.3281443
  3. Pesterev, A., Zeldovich, N., & Morris, R. (2012). Improving Network Performance with Multi-Core Systems and epoll. In Proceedings of the 2012 USENIX Annual Technical Conference (USENIX ATC '12).
  4. Starovoitov, A. (2014). Extended BPF (eBPF) Architecture and Verifier Rules. Linux Kernel Source Tree (Documentation/bpf/).
  5. Corbet, J., Rubini, A., & Kroah-Hartman, G. (2005). Linux Device Drivers (3rd Edition). O'Reilly Media.
  6. InitNode Signal Deep Dive: Distributed Consensus: Raft vs Multi-Paxos & State Machine Replication. InitNode Signal.
EDITORIAL & AUTHOR NETWORK

Write for InitNode. Earn Proof of Work.

Unlike Medium or Dev.to, InitNode is built exclusively for senior software engineers, infrastructure architects, and systems builders. Every published blueprint is free of paywalls, indexed within seconds, and permanently linked to your verified engineering pedigree.

+250 PoW XP

Climb the Architect Leaderboard and unlock verified reputation badges.

Rich Math & Mermaid

First-class LaTeX math, responsive sequence diagrams, and syntax highlighting.

Instant Indexing

Automated real-time submission to Google Indexing and IndexNow APIs.

Own Your Audience

Readers subscribe directly to you; automated email dispatches on release.

No paywalls. No popups. Strictly high-signal engineering.