FactNode

Zero-Copy Kernel Datapaths: Building Thread-Per-Core Rust Services with io_uring

Why traditional epoll and work-stealing async runtimes hit a wall at 100GbE line rates, and how shared-nothing io_uring ring buffers eliminate syscall overhead.

Macro architectural visualization of precision silicon memory bus pathways and metallic surfaces

For over a decade, the default recipe for high-concurrency network services in Linux has been non-blocking sockets driven by epoll paired with a multi-threaded, work-stealing user-space scheduler (such as Go's runtime or Rust's default tokio multi-thread executor). While that model effortlessly handles tens of thousands of slow HTTP connections, it hits an architectural wall when pushed against 100GbE NICs and Gen5 NVMe storage arrays capable of >12M IOPS> 12\text{M IOPS}.

At sub-microsecond inter-arrival times, the cost of epoll_wait syscalls, cross-core L1/L2 cache invalidations from work-stealing task migration, and kernel-to-user buffer copies consumes more CPU cycles than the application logic itself. Here is how we re-engineered our telemetry ingestion engine in Rust using a Thread-Per-Core Shared-Nothing Architecture powered by Linux io_uring.


Work-Stealing epoll vs. Shared-Nothing io_uring#

In a traditional work-stealing runtime, a socket readiness event woken on Core 0 may be stolen and executed by Core 7. Every task migration invalidates L1/L2 CPU caches and forces atomic reference-counting synchronization (Arc<Mutex<T>>) across NUMA boundaries.

Architecture Vector Topology
Synthesizing architectural vector layout...

By switching to io_uring:

  1. Application and kernel communicate via twin lock-free ring buffers mapped into shared memory (mmap).
  2. Operations (read, write, accept, splice) are submitted with zero system calls when SQPOLL mode is active.
  3. Network packets are DMA-transferred directly into pre-registered page-aligned buffers.

Zero-Copy Socket Ingestion in Rust#

src/engine/uring_reactor.rs
use io_uring::{opcode, types, IoUring};
use std::os::unix::io::RawFd;
 
pub struct UringWorker {
    ring: IoUring,
    buf_pool: Vec<Vec<u8>>,
}
 
impl UringWorker {
    pub fn submit_recv_multishot(&mut self, fd: RawFd, buf_group: u16) {
        let recv_e = opcode::RecvMulti::new(types::Fd(fd), buf_group)
            .build()
            .user_data(0xDEAD_BEEF);
 
        unsafe {
            self.ring
                .submission()
                .push(&recv_e)
                .expect("Submission queue ring full");
        }
        self.ring.submit().expect("Failed to flush SQ entries");
    }
}

Profiler Performance Metrics#

We benchmarked 100-byte UDP & TCP telemetry packet ingestion under sustained 100GbE network saturation:

Ingestion ArchitectureMax Packet Rate (Mpps)CPU Core UtilizationContext Switches / sec
Tokio Multi-Thread (epoll)1.42 Mpps1.42\text{ Mpps}100%100\% (All 16 Cores)3,840,0003,840,000
Glommio Thread-Per-Core4.85 Mpps4.85\text{ Mpps}72%72\% (16 Cores)120,400120,400
Custom io_uring (SQPOLL)9.64 Mpps9.64\text{ Mpps}48%48\% (16 Cores)00 (Kernel Polling)

Eliminating kernel boundary transitions unlocks line-rate packet ingestion with predictable sub-microsecond tail latencies on commodity Linux hardware.

Technical Q&A

Frequently Asked Questions

Why does epoll become a bottleneck on modern 100GbE and NVMe-oF workloads?+

epoll is a readiness notification mechanism rather than a completion API. Every socket read or write still requires a separate system call, user-to-kernel context switch, Spectre/Meltdown mitigation barrier, and buffer copy.

What is a thread-per-core shared-nothing architecture in Rust?+

Instead of using a work-stealing thread pool where tasks migrate across CPU cores (causing L1/L2 cache line bouncing and mutex contention), thread-per-core pins one OS thread to each physical CPU core with its own dedicated io_uring instance and memory arena.

How do Registered Buffers (IORING_REGISTER_BUFFERS) eliminate kernel page pinning overhead?+

Pre-registering page-aligned memory slabs with the kernel pins the virtual-to-physical DMA mappings once at startup, avoiding per-I/O get_user_pages() reference counting during high-frequency network and NVMe operations.

Further Architectural Reading

View full archive →