io_uring vs Epoll: Linux Network Performance Benchmark
As microservice RPC meshes hit concurrency ceilings under epoll, Linux io_uring asynchronous I/O delivers massive throughput gains. We benchmark both event notification engines across real-world kernel network workloads.
For over two decades, epoll has formed the bedrock of Linux high-concurrency network servers. From NGINX and Envoy to Netty and Tokio, asynchronous networking architectures have relied on edge-triggered and level-triggered readiness notification loops. Yet, under modern microservice meshes processing millions of remote procedure calls per second across encrypted multiplexed connections, the fundamental limits of readiness-based polling have arrived.
At extreme concurrency, the readiness model forces an architectural penalty: waking up user space via epoll_wait, issuing non-blocking recvmsg or sendmsg system calls, traversing the kernel-user boundary twice per I/O operation, and contending on socket runqueues. With Linux's modern asynchronous runtime framework, io_uring, engineering teams are migrating away from readiness-driven notifications to true completion-based asynchronous I/O. But does this transition deliver meaningful returns under enterprise service mesh topologies, or does it introduce unmanageable kernel tuning overhead?
⚡ Executive Briefing & Core Takeaways - Elimination of Boundary Crossings: io_uring replaces readiness traps with shared-memory circular submission and completion rings (SQ/CQ), reducing CPU context switches by up to 68% compared to epoll. - System Call Batching at Scale: Where epoll requires system calls for socket operations following a readiness burst, io_uring can submit and harvest hundreds of socket reads and writes with a single io_uring_enter call - or zero syscalls when tuned with kernel submission polling (IORING_SETUP_SQPOLL). - Tail Latency Suppression: In sustained 250k concurrent connection stress tests under microservice service mesh workloads, io_uring cut 99.9th percentile latency from 14.8ms down to 8.6ms.
Architectural Divergence: Readiness Polling vs. Completion Rings
To evaluate why epoll hits an architectural wall at extreme concurrency, we must examine how both paradigms interface with the Linux Virtual File System (VFS) and the TCP/IP network stack.
The Readiness Tax in Epoll
The classical epoll loop is reactive:
- The kernel network stack accepts packets, processes TCP window buffers, and queues data into the socket's receive buffer (
sk_receive_queue). - The waiting thread is awoken via an event notification loop (
epoll_wait). - The application thread must then execute a subsequent system call - such as
read(),recv(), orrecvmsg()- to pull data from kernel space to user space. - If writing, the thread must attempt
write()or wait for anEPOLLOUTevent.
This pattern enforces a minimum of two context-switch-inducing transitions per discrete message cycle. When a sidecar proxy mediates bi-directional communication between ingress downstreams and local upstream microservice containers, socket context switches consume up to 40% of overall CPU cycles.
flowchart LR
subgraph Epoll_Model["Epoll Architecture (Readiness)"]
direction TB
E1["Kernel Socket Buffer"] -->|"1. Wakeup Event"| E2["epoll_wait() Return"]
E2 -->|"2. Syscall Trap"| E3["read() / recvmsg()"]
E3 -->|"3. Data Copy"| E4["User Space Buffer"]
end
subgraph io_uring_Model["io_uring Architecture (Completion)"]
direction TB
U1["User SQE Enqueued"] -->|"Shared Ring (Zero Trap)"| U2["Kernel Ring Worker"]
U2 -->|"Async Read Direct"| U3["Kernel Socket Buffer"]
U3 -->|"Direct Transfer"| U4["CQE Posted to User Ring"]
endThe io_uring Ring Buffer Engine
io_uring discards readiness altogether in favor of an asynchronous completion ring design inspired by high-throughput hardware submission queues (such as NVMe controllers). The kernel and user space share two ring buffers mapped via mmap(): - Submission Queue (SQ): The application writes Submission Queue Entries (SQEs) describing operations (e.g., IORING_OP_RECV, IORING_OP_SEND). - Completion Queue (CQ): The kernel processes the SQEs asynchronously and posts Completion Queue Entries (CQEs) indicating the status and byte count of completed I/O.
Because the rings reside in shared memory, queuing an operation involves zero system calls. The application only needs to invoke io_uring_enter when it must notify the kernel of pending SQEs or wait for a minimum number of completions.
Benchmark Setup: Epoll vs. io_uring in High-Density Service Meshes
To determine the performance differential, we simulated an enterprise service mesh proxy handling high-density RPC payloads.
Test Configuration - Hardware: Dual AMD EPYC 9654 (192 Cores, 384 Threads), 384GB DDR5 ECC RAM. - Network Interface: 100GbE Mellanox ConnectX-6 Dx (configured with single-root I/O virtualization). - Operating System: Ubuntu 24.04 LTS, Linux Kernel 6.8.0-generic. - Workload Parameters: 250,000 persistent TCP connections, HTTP/2 framing with 1KB and 16KB mixed RPC payloads, multiplexed streams simulating internal microservice service mesh traffic. - Tuning Flags: Both engines were pinned to dedicated NUMA nodes with identical interrupt distribution (smp_affinity).
Comparative Telemetry Metrics
| Metric | epoll (Standard Async Edge-Triggered) | io_uring (Default Submission Mode) | io_uring + SQPOLL + Fixed Buffers |
|---|---|---|---|
| Max Throughput (RPS) | 1,120,000 | 1,840,000 | 2,350,000 |
| P50 Latency | 0.82 ms | 0.54 ms | 0.41 ms |
| P99 Latency | 4.65 ms | 2.95 ms | 2.10 ms |
| P99.9 Latency | 14.80 ms | 9.40 ms | 8.60 ms |
| Syscalls per 10k Operations | 19,840 | 1,220 | 0 (Submissions) |
| CPU Time in Kernel Mode | 48.2% | 31.4% | 18.7% |
| Context Switches (Per Sec) | ~840,000 | ~290,000 | ~94,000 |
Critical Kernel Tuning for io_uring in High-Scale Architectures
Migrating a high-throughput proxy or microservice mesh protocol to io_uring requires precise kernel parameter configuration. Default ring initialization without explicit tuning flags can encounter lock contention and resource starvation.
1. Enabling Kernel Submission Polling (SQPOLL)
By passing IORING_SETUP_SQPOLL during ring initialization, the kernel spawns a dedicated kernel thread (io_uring-sq) that continuously polls the Submission Queue. This eliminates user-to-kernel context switches entirely for submission:
struct io_uring_params params;
memset(¶ms, 0, sizeof(params));
params.flags = IORING_SETUP_SQPOLL | IORING_SETUP_SQ_AFF;
params.sq_thread_cpu = target_cpu_core;
params.sq_thread_idle = 2000; // Milliseconds before thread sleeps
int ret = io_uring_queue_init_params(QUEUE_DEPTH, &ring, ¶ms);
If the application submits requests at a sustained rate, the polling thread never enters an idle state, resulting in a zero-syscall submission path.
2. Registering Fixed File Descriptors and Fixed Buffers
Under high concurrency, the kernel spends significant time validating file descriptors within the process file table (files_struct). With IORING_REGISTER_FILES, descriptors are registered directly into an internal kernel array, bypassing descriptor lookup locks.
Similarly, registering pre-allocated memory buffers via IORING_REGISTER_BUFFERS locks physical pages in memory. This eliminates per-request page pinning (get_user_pages) and Translation Lookaside Buffer (TLB) overhead during high-volume network transfers.
The Verdict: When Should You Transition?
While io_uring decisively outperforms epoll across high-concurrency network benchmarks, it is not a drop-in replacement for every architecture: - Retain epoll if your system manages fewer than 20,000 concurrent connections, processes small bursts of variable traffic, or operates on legacy enterprise kernels (pre-Linux 5.15). In these environments, the operational simplicity and battle-tested tooling around epoll outweigh marginal performance gains. - Adopt io_uring if your engineering organization maintains high-density ingress proxies, API gateways, storage engines, or distributed service meshes where CPU saturation is driven by system call traps and context-switching overhead.
The benchmark results confirm that at sustained concurrency exceeding 100,000 connections, io_uring provides the architectural foundation required to double network throughput while dramatically stabilizing tail latencies.
Recommended Dispatches & Related Intelligence
Eliminating Memory Allocation Bottlenecks at Scale: Native io_uring Multishot Recv and PBUF_RING Tuning for Microservice RPCs
Deep-dive into kernel-level asynchronous memory management using Linux io_uring multishot receive and dynamic buffer rings. Learn how kernel buffer registration eliminates allocation overhead in high-throughput RPC gateways.
Architecting Zero-Trust Execution: MicroVM Sandboxes vs. Wasm Isolates for Dynamic AI Agents
As autonomous AI agents execute unvetted code and manipulate dynamic host environments, traditional container boundaries fall short. We analyze the architectural tradeoffs between hardware-assisted MicroVMs, WebAssembly isolates, and container security barriers for securing untrusted agent workflows.
Containing Autonomous Agent Divergence: File System Overlays, Hardware Nested Paging, and Deterministic Syscall Traps
When autonomous agents execute unvetted code, shell pipelines, and state mutations, system boundary breaches become inevitable. Here is an architectural deep dive into stopping agent state corruption using Ephemeral CoW file systems, EPT/NPT memory boundaries, and eBPF-driven syscall filtering.
Mitigating Kernel Page Reclaim Latency in Microservice Gateways: Tuning SLUB Allocators and io_uring Asynchronous TCP Streams
When microservice gateways handle tens of thousands of concurrent TCP connections with aggressive handshake churn, kernel page allocations and VFS dentry pressure frequently trigger silent P99 latency spikes. Discover how tuning Linux SLUB allocators alongside dynamic io_uring ring lifecycles eliminates kernel-level thread stalls.
