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:
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.- 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.
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:
The constituent latency factors include:
- 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. - 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
CR3control register, costing an additional 150 to 350 CPU cycles per invocation. - 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.
2.1 The Two Core Structures: Red-Black Tree & Ready List
Within fs/eventpoll.c, the kernel allocates a struct eventpoll containing:
- Red-Black Tree (
struct rb_root_cached rbr): Stores all monitored file descriptors wrapped instruct epitem. Keyed by the struct file pointer and FD integer, enabling addition (EPOLL_CTL_ADD), modification (EPOLL_CTL_MOD), and deletion (EPOLL_CTL_DEL). - Ready List (
struct list_head rdllist): A doubly-linked list containing only thestruct epiteminstances that have received hardware/driver I/O wakeups and are ready for user consumption.
2.2 The Lifecycle of an epoll Event
- Registration: When
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev)is called, the kernel allocates anepitem, inserts it into the Red-Black tree, and attaches a custom callback (ep_poll_callback) to the monitored file's driver wait queue usinginit_waitqueue_entry. - 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 invokeswake_up_interruptible()on the socket's wait queue. - Kernel Callback Execution: The
ep_poll_callback()executes in interrupt/softirq context:- Acquires the
eventpoll.lockspinlock. - Checks if the
epitemis already on therdllist. - If not, links the
epitemintordllistand wakes up any application threads blocked inepoll_wait().
- Acquires the
- User Consumption: The blocked thread in
epoll_wait()is awakened. The kernel traversesrdllist, copies the ready events into the user-providedstruct epoll_eventbuffer viacopy_to_user(), and updates the list according to trigger mode semantics.
2.3 Edge-Triggered (EPOLLET) vs Level-Triggered (EPOLLLT)
| Feature | Level-Triggered (EPOLLLT - Default) | Edge-Triggered (EPOLLET) |
|---|---|---|
| Notification Semantic | Fires 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 Handling | If 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 Requirement | Application 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 Overhead | Lower 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:
epollis 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 synchronousread()orwrite()syscalls. - Buffer Copying Overhead: Every
read()requirescopy_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
epolldescriptor withoutEPOLLEXCLUSIVE, 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.
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():
- Submission Queue (SQ): The user application is the Producer; the kernel is the Consumer.
- 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_sqeinto 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 updatessq_ring.headwith Acquire Memory Semantics.
Completion Queue Pointer Synchronization:
- As I/O operations complete, the kernel writes
struct io_uring_cqerecords into the CQ ring. - The kernel advances
cq_ring.tailwith a release store. - User space inspects
cq_ring.headandcq_ring.tail. When , user space consumes completed events and advancescq_ring.headwith an acquire load.
3.2 Key Data Structures
struct io_uring_sqe (Submission Queue Entry - 64 bytes)
struct io_uring_cqe (Completion Queue Entry - 16 bytes)
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).
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.
3. Linked Operations (IOSQE_IO_LINK)
Allows creating atomic chains of operations in the submission queue:
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.
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_buffis a massive data structure (exceeding 240 bytes of control metadata across multiple cache lines).- Allocating, initializing, and freeing
sk_buffper 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_buffalone 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:
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).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).XDP_REDIRECT: Bypasses the entire kernel stack and redirects the packet to another NIC, a Virtual Ethernet (veth) device, or anAF_XDPzero-copy user-space socket.XDP_PASS: Hands the packet over to the standard Linux TCP/IP stack, allocating ansk_buff.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:
- Fill Ring: User space deposits empty UMEM frame addresses for the NIC driver to populate with incoming packets.
- Rx Ring: The kernel driver notifies user space of received packets, passing UMEM descriptors containing packet lengths and offsets.
- Tx Ring: User space deposits populated UMEM frames to be transmitted directly by the NIC.
- 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
| Dimension | epoll | io_uring | eBPF / XDP | AF_XDP |
|---|---|---|---|---|
| Primary Domain | Socket readiness multiplexing | General-purpose async I/O (Disk + Net) | In-kernel packet filtering & L4 routing | Zero-copy user-space raw packet I/O |
| Kernel Intro | Linux 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 Model | Double-buffered (copy_to_user) | Shared lockless rings (mmap) | In-place driver packet memory | Shared lockless UMEM rings |
| Protocol Support | Full POSIX TCP/UDP/Unix | Full POSIX TCP/UDP/Files/Storage | Raw L2/L3/L4 Frames | Raw L2/L3/L4 Frames |
| Programming Model | Reactor / Proactor event loop | Completion-based Proactor | Sandboxed C / Restricted LLVM IR | Ring-buffer batch consumer |
| Context Switches | High () | Zero (with SQPOLL) | Zero | Zero |
| Throughput Ceiling | ||||
| Latency per Op |
5.2 Decision Framework: Choosing the Right Engine
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.
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:
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
8.2 Inspecting Active XDP Programs on Interfaces
8.3 Kernel Sysctl Tuning for C10M High-Throughput I/O
Add the following parameters to /etc/sysctl.d/99-high-performance-io.conf:
Apply immediately:
9. Academic Bibliography & Further Reading
- Axboe, J. (2019). Efficient IO with io_uring. Kernel Documentation, Linux Foundation. https://kernel.dk/io_uring.pdf
- 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
- 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).
- Starovoitov, A. (2014). Extended BPF (eBPF) Architecture and Verifier Rules. Linux Kernel Source Tree (
Documentation/bpf/). - Corbet, J., Rubini, A., & Kroah-Hartman, G. (2005). Linux Device Drivers (3rd Edition). O'Reilly Media.
- InitNode Signal Deep Dive: Distributed Consensus: Raft vs Multi-Paxos & State Machine Replication. InitNode Signal.
References
- [1] May 2019Efficient IO with io_uring (Jens Axboe, Kernel Documentation 2019)
- [2] Dec 2018The eXpress Data Path: Fast Programmable Packet Processing in the Linux Kernel (Toke Høiland-Jørgensen et al., ACM CoNEXT 2018)
- [3] Jun 2012Improving Network Performance with Multi-Core Systems and epoll (Aleksey Pesterev et al., USENIX ATC 2012)
- [4] Feb 2015Extended BPF in Linux Kernel (Alexei Starovoitov, Netdev 0.1 2015)
- [5] Sep 2026Distributed Consensus: Raft vs Multi-Paxos & State Machine Replication
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.
Climb the Architect Leaderboard and unlock verified reputation badges.
First-class LaTeX math, responsive sequence diagrams, and syntax highlighting.
Automated real-time submission to Google Indexing and IndexNow APIs.
Readers subscribe directly to you; automated email dispatches on release.