← Back to roadmap
Phase 3Week 32~110 min read
0%·0/4

io_uring & Batched I/O

Fewer syscalls, more work per wakeup.

Performance · Measure, Profile, Then Go Fast

Why this week

Last week you measured what one message costs when it crosses into the kernel: the syscall entry and exit, the socket lock, the copy, the TCP or UDP processing. This week is about the other lever. You can't make the kernel's per-packet work disappear (that's next week), but you can stop paying the crossing once per operation.

The classic Linux server loop is epoll: one syscall to learn which sockets are ready, then one recv or send per socket. At 2,000 sessions and a few hundred thousand messages a second that's a few hundred thousand syscalls a second, and each one is a pipeline flush, a privilege change and, with speculative-execution mitigations enabled, often several hundred nanoseconds of pure overhead. io_uring changes the contract. You describe the operations in shared memory, the kernel performs them and writes the results back into shared memory, and a single io_uring_enter call can submit a hundred operations and wait for a hundred completions. With a kernel polling thread it can be zero calls.

The catch is that batching is never free. Every message in a batch waits for the batch. The real skill this week isn't calling a new API. It's deciding how much work to do per wakeup from a latency budget, and recognising when the answer is "one, and don't sleep at all".

ℹ️
Note

io_uring makes entering the kernel cheaper and rarer. It does not make the kernel's network stack faster: a recv submitted through a ring still runs the same TCP/IP code as a recv syscall. If the stack itself is your bottleneck, that's week 33.

Recall#what-batching-buys

A gateway handles 300k msgs/s with one recv() and one send() per message. Roughly what does moving to batched submission save, and what does it not save?

Day 1 — Readiness vs completion

Day 01

Two ways to ask the kernel for I/O

epoll tells you when you may act; io_uring tells you what happened

epoll is a readiness API. You register interest once with epoll_ctl, then each loop iteration asks "who is ready?" and does the I/O yourself:

// Shape of an edge-triggered epoll loop (pseudo-Rust over libc).
loop {
    let n = epoll_wait(epfd, &mut events, -1);       // syscall 1
    for ev in &events[..n] {
        let s = &mut sessions[ev.token];
        loop {
            match recv(s.fd, &mut s.rx) {            // syscall 2..k
                Ok(0) => { s.close(); break }
                Ok(n) => s.on_bytes(n),
                Err(WouldBlock) => break,            // edge-triggered: drain to EAGAIN
                Err(e) => { s.fail(e); break }
            }
        }
    }
    flush_pending_sends();                           // more syscalls
}

Count the crossings for one wakeup with 10 ready sockets under edge-triggered mode: one epoll_wait, ten successful recvs, ten more recvs that return EAGAIN to prove each socket is drained, plus the sends. Level-triggered mode lets you skip the EAGAIN probe, but then the next epoll_wait reports the socket again if you left bytes behind.

io_uring is a completion API, the model Windows IOCP has used for decades. You hand the kernel the operation and the buffer up front: "receive into this buffer on this socket and tell me when it's done". The kernel queues the operation, completes it when data arrives, and posts a completion record carrying a 64-bit tag you chose (user_data). Your loop stops asking "who is ready?" and starts asking "what finished?":

loop {
    // 0 or 1 syscalls: submit everything queued since last time and wait for >= 1 completion
    ring.submit_and_wait(1)?;
    for cqe in ring.completion() {
        let (session, op) = decode_tag(cqe.user_data());
        match op {
            Op::Recv => { sessions[session].on_bytes(cqe.result()); queue_recv(session) }
            Op::Send => sessions[session].on_sent(cqe.result()),
        }
    }
}

Three consequences follow, and the rest of the week builds on them:

  1. The buffer belongs to the kernel while the operation is in flight. You can't reuse, move or free it until its completion arrives. This is why Rust io_uring runtimes (tokio-uring, glommio, monoio) take buffers by value and hand them back with the result. A borrowed &mut [u8] can't express "the kernel may write here after this future is dropped".
  2. Completions can arrive in any order. Two recvs on two sockets complete when their data arrives, not in submission order. user_data is your only correlation key, so encode the session and operation in it. (If you need ordering between operations, IOSQE_IO_LINK chains them.)
  3. Syscalls become a batching knob. Submission and reaping are memory operations on shared rings. When you actually enter the kernel is now your decision, not a side effect of every I/O.
Paperby Jens Axboe
Efficient IO with io_uring

The design document from io_uring's author (Linux 5.1, 2019). Read the sections on the data structures, the communication channel and memory ordering slowly. That's exactly the SPSC ring you built in weeks 1 and 5, now shared between user space and the kernel. Skim the liburing and polled-I/O sections.

Docs
epoll(7)

Edge- vs level-triggered and the 'possible pitfalls' Q&A at the end. Know why edge-triggered mode requires draining to EAGAIN and what starvation looks like when one busy socket hogs a wakeup.

Recall#syscalls-per-wakeup

Ten sockets are ready, each with one message waiting. How many syscalls does an edge-triggered epoll loop make to read them, and how many does an io_uring loop with recvs already in flight make?

Design note#gateway-syscall-audit

Take your week 13 gateway. For one inbound order that produces an ack and a fill report, list every syscall on the path from bytes arriving to bytes leaving. Which ones could be batched with other sessions' work, and which ones are inherently per message?

0 chars

Day 2 — Inside the rings

Day 02

Two SPSC rings in shared memory

The structure you already know, with the kernel on the other end

io_uring_setup(2) creates the instance and returns a file descriptor. You mmap three regions from it: the submission queue ring, the array of SQEs it points into, and the completion queue ring (on newer kernels the two rings share one mapping). Each ring is a power-of-two array plus a head and a tail:

RingProducerConsumerEntry
SQyour thread writes SQEs, then advances tailthe kernel advances head as it consumes64-byte SQE: opcode, flags, fd, offset, buffer address, length, user_data …
CQthe kernel writes CQEs, then advances tailyour thread advances head after reading16-byte CQE: user_data, res (bytes or -errno), flags

The indices are free-running 32-bit counters. The slot is index & ring_mask, the fill level is tail - head with wrapping arithmetic, and "full" means tail - head == entries. Free-running counters let you tell full from empty without sacrificing a slot, and masking is a single AND because the size is a power of two. io_uring_setup rounds your requested size up to a power of two, and by default the CQ gets twice as many entries as the SQ because more operations can be in flight than you submit at once.

The memory ordering is exactly your week 5 SPSC ring. When you publish SQEs, you fill in the entries and then store the SQ tail with release semantics. The kernel reads tail with acquire semantics, so it sees fully written entries. On the completion side you load the CQ tail with acquire, read the CQEs, then store the CQ head with release so the kernel doesn't overwrite an entry you're still reading. liburing and the io-uring crate do this for you. Write it once by hand anyway, because one day you'll debug it.

// The io-uring crate (tokio-rs/io-uring): one read, submitted and reaped.
let mut ring = IoUring::new(8)?;
let read = opcode::Read::new(types::Fd(file.as_raw_fd()), buf.as_mut_ptr(), buf.len() as _)
    .build()
    .user_data(0x42);
// unsafe: the kernel will write into `buf` later. You promise it outlives the op.
unsafe { ring.submission().push(&read).expect("SQ full") };
ring.submit_and_wait(1)?;                 // one io_uring_enter
let cqe = ring.completion().next().expect("CQ empty");
assert_eq!(cqe.user_data(), 0x42);

What happens when the CQ is full? Older kernels dropped the completion and bumped an overflow counter. Since Linux 5.5 (IORING_FEAT_NODROP) the kernel keeps overflowed completions on an internal list and may refuse new submissions with EBUSY until you reap. Either way, a full CQ means your design is wrong: size the CQ for the maximum number of operations you allow in flight, and reap before you submit more.

Recall#sq-publish-ordering

You write three SQEs and then store the new SQ tail. Which memory ordering does that store need, and what breaks with Relaxed?

You'll now build the model: a generic free-running ring, and an Uring that pairs an SQ with a CQ and counts how many times you "enter the kernel". The kernel is played by a closure. Our model is stricter than the real thing in one way: submit stops consuming SQEs when the CQ has no room, so nothing is ever dropped or buffered behind your back.

Drill

Model an SQ/CQ ring pair

Target: 6/6 casesNot yet run
/// One submission queue entry. The real SQE is 64 bytes; we keep the
/// fields that matter for the model.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Sqe {
    pub opcode: u8,     // 0 = NOP, 1 = READ, 2 = WRITE ...
    pub fd: i32,
    pub len: u32,
    pub user_data: u64, // echoed back in the CQE: your only correlation key
}

/// One completion queue entry (16 bytes in the real ABI).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Cqe {
    pub user_data: u64,
    pub res: i32, // bytes transferred, or -errno
}

/// Single-producer / single-consumer ring with free-running u32 indices,
/// like io_uring's SQ and CQ: the slot is `index & mask`, the fill level
/// is `tail.wrapping_sub(head)`.
pub struct Ring<T: Copy + Default> {
    slots: Vec<T>,
    mask: u32,
    head: u32, // consumer's next read
    tail: u32, // producer's next write
}

impl<T: Copy + Default> Ring<T> {
    /// Capacity rounds up to the next power of two (io_uring_setup does too).
    pub fn new(entries: u32) -> Self {
        Self::with_start(entries, 0)
    }

    /// Start both indices at `start` so tests can exercise u32 wrap-around.
    pub fn with_start(entries: u32, start: u32) -> Self {
        todo!()
    }

    pub fn capacity(&self) -> u32 {
        todo!()
    }

    pub fn len(&self) -> u32 {
        todo!()
    }

    /// Producer side. Returns false when the ring is full.
    pub fn push(&mut self, v: T) -> bool {
        todo!()
    }

    /// Consumer side.
    pub fn pop(&mut self) -> Option<T> {
        todo!()
    }
}

pub struct Uring {
    sq: Ring<Sqe>,
    cq: Ring<Cqe>,
    enters: u32,
}

impl Uring {
    /// SQ gets `entries` (rounded up to a power of two); the CQ gets twice
    /// that, which is the kernel's default.
    pub fn new(entries: u32) -> Self {
        todo!()
    }

    /// Queue an SQE in shared memory. No syscall. False if the SQ is full.
    pub fn prep(&mut self, sqe: Sqe) -> bool {
        todo!()
    }

    /// One io_uring_enter(2): exactly one syscall however many SQEs it
    /// consumes. `exec` plays the kernel and returns `res`. Consume SQEs in
    /// order while the CQ has room for the completion. Returns the number
    /// of SQEs consumed.
    pub fn submit<F: FnMut(&Sqe) -> i32>(&mut self, mut exec: F) -> u32 {
        todo!()
    }

    /// Drain every available CQE into `out`; returns how many were reaped.
    pub fn reap(&mut self, out: &mut Vec<Cqe>) -> usize {
        todo!()
    }

    pub fn enters(&self) -> u32 {
        self.enters
    }

    pub fn sq_pending(&self) -> u32 {
        self.sq.len()
    }
}
Rust
Docs
io_uring_setup(2) and io_uring_enter(2)

The authoritative description of the ring layout, the setup flags (SQPOLL, SQ_AFF, CQSIZE, CLAMP) and the feature bits like NODROP. Then read io_uring_enter(2) for min_complete and the GETEVENTS/SQ_WAKEUP flags.

Day 3 — Making each operation cheaper

Day 03

Registered resources, SQPOLL and multishot

Pay per-op setup costs once, not per message

Batching cuts the number of kernel entries. The next set of features cuts the work the kernel repeats for every operation.

Registered files (IORING_REGISTER_FILES, then IOSQE_FIXED_FILE on each SQE). A normal operation resolves the fd to a struct file and takes and drops a reference count on it, which is an atomic increment and decrement on a cache line other threads may touch. With a registered file table the SQE carries an index into a table the kernel already holds references for.

Registered buffers (IORING_REGISTER_BUFFERS, with READ_FIXED/WRITE_FIXED). Normally the kernel must pin your user pages for the duration of each I/O and unpin them afterwards. Registering a buffer region pins it once. This matters most for storage I/O such as the journal. For network receives the next feature usually matters more.

Provided buffer rings (Linux 5.19). If you post a recv with its own 4 KiB buffer on each of 2,000 mostly idle sessions, 8 MB of memory sits pinned waiting for data that rarely comes. With a provided buffer ring you give the kernel a pool, and it picks a buffer only when data actually arrives, reporting which one in the CQE flags. Memory then scales with active traffic, not open sockets.

Multishot operations. A multishot accept (5.19) or multishot recv (6.0) is one SQE that keeps producing CQEs, each flagged IORING_CQE_F_MORE while the request stays armed. For a recv loop that means one submission for the lifetime of the connection instead of one per message.

SQPOLL (IORING_SETUP_SQPOLL). A kernel thread polls your SQ, so submitting is just writing memory. In steady state your loop makes no syscalls at all: it writes SQEs, advances the tail and spins on the CQ. The costs are a core burned by the poller, which you should pin with IORING_SETUP_SQ_AFF next to your thread, and an idle timeout (sq_thread_idle) after which the thread sleeps and sets IORING_SQ_NEED_WAKEUP. You then have to call io_uring_enter with IORING_ENTER_SQ_WAKEUP to restart it. Very old kernels required elevated privileges for SQPOLL. Check what yours requires.

Newer kernels (6.0/6.1) also added IORING_SETUP_SINGLE_ISSUER and IORING_SETUP_DEFER_TASKRUN, which defer completion processing until your thread enters the kernel instead of interrupting it at arbitrary moments. For a single-threaded, pinned event loop (the shape you want) they cut background interference.

⚠️
Warning

Feature availability is a function of kernel version, and the ones that matter here span 5.1 through 6.x. Check the features field io_uring_setup returns and probe opcodes with IORING_REGISTER_PROBE at start-up, and fail loudly if something you depend on is missing. A silent fallback to a slower path is how a production box ends up 3 µs slower than staging.

Recall#registered-buffers

What per-operation work do registered files and registered buffers remove, and why do provided buffer rings matter more than registered buffers for a gateway with thousands of idle sessions?

Day 4 — Batching is a latency decision

Day 04

How big should a batch be?

Natural batching, Little's law and a budget

Every wakeup has a fixed cost: waiting, entering the kernel, the scheduler, cache refills. It also has a per-message cost: decode, risk check, hand-off to the engine. With F fixed and c per message, a wakeup that handles B messages takes F + B·c, and one loop sustains B / (F + B·c) messages per unit time. As B grows, throughput approaches 1/c and the fixed cost is amortised away.

Latency moves the other way. There are two ways to batch, and they behave very differently:

  • Fixed batching waits until B messages have accumulated, or until a timeout fires. At low load the first message in a batch waits for strangers to arrive. This is Nagle's algorithm again, and it's how you lose milliseconds in a system that should take microseconds.
  • Natural batching, which Martin Thompson calls smart batching, never waits. When the loop wakes it takes whatever is already queued, up to a cap, processes it, flushes all replies with one submission and goes back. At low load batches are size 1 and latency is minimal. Under load, messages pile up while the loop is busy, so batches grow exactly when amortisation is needed. The LMAX Disruptor's batch-consumer design rests on this effect.

With natural batching capped at B, the worst case for one message is: it arrives just after a wakeup began, waits for that batch to finish, then rides as part of the next full batch whose replies flush at the end. That's 2·(F + B·c). Two constraints now define a window of acceptable caps. The cap must be large enough that capacity at the cap covers the offered rate, or the queue grows without bound (Little's law: L = λ·W, so if arrivals outpace service, W explodes). It must also be small enough that the worst case fits the budget. If the window is empty, no batch size saves you, and you have to shrink F (SQPOLL, busy polling, kernel bypass) or shrink c.

Paperby Martin Thompson (Mechanical Sympathy blog)
Smart Batching

A short post on why the batch should be 'whatever is there' rather than a fixed size or a timer, with the latency curve that results. Read it, then reread the batching section of the LMAX Disruptor paper.

Recall#natural-vs-fixed

Why does natural batching add almost no latency at low load while fixed-size batching adds a lot?

In the drill you'll turn that model into the numbers you'd put in a design review. The figures in the tests are illustrative but realistic in proportion: about 1.5 µs of fixed cost per wakeup when it involves a syscall and a sleep/wake, about 0.3 µs when nothing sleeps, about 200 ns of per-message work, and about 700 ns extra per message when every message needs its own recv.

Drill

Choose batch sizes from a latency budget

Target: 6/6 casesNot yet run
/// Time to process one wakeup that handles `batch` messages: fixed
/// per-wakeup overhead plus per-message work.
pub fn service_ns(fixed_ns: u64, per_msg_ns: u64, batch: u64) -> u64 {
    todo!()
}

/// Messages per second one loop sustains if every wakeup carries `batch`
/// messages. Round down. (Use u128 for the intermediate product.)
pub fn capacity_per_sec(fixed_ns: u64, per_msg_ns: u64, batch: u64) -> u64 {
    todo!()
}

/// Smallest batch (at least 1) whose capacity is at least `rate_per_sec`.
/// None if no batch size keeps up (per-message work alone saturates the core).
pub fn min_batch_for_rate(fixed_ns: u64, per_msg_ns: u64, rate_per_sec: u64) -> Option<u64> {
    todo!()
}

/// Worst-case latency with natural batching capped at `batch`: a message that
/// lands just after a wakeup started waits for that batch, then rides in the
/// next full batch, and its reply is flushed when that batch ends.
pub fn worst_latency_ns(fixed_ns: u64, per_msg_ns: u64, batch: u64) -> u64 {
    todo!()
}

/// The batch caps you may configure: from the smallest that keeps up with
/// the rate to the largest whose worst case fits the budget. None if empty.
pub fn batch_window(fixed_ns: u64, per_msg_ns: u64, rate_per_sec: u64, budget_ns: u64) -> Option<(u64, u64)> {
    todo!()
}
Rust

Look at the last two cases once they pass. Moving from a recv per message to completions that are already waiting, or from a sleeping loop to one that polls, doesn't just make things a bit faster. It turns a configuration that cannot meet the budget at any batch size into one with plenty of room. That's the argument you'll make for io_uring in the gateway, and next week for kernel bypass on the market-data path.

Design note#no-batch-zone

Name one stage of your exchange where you would deliberately cap the batch at 1 (or not batch at all) even though it costs throughput, and one where you'd let batches grow into the hundreds. Justify both with a number: what latency does the stage add, and what is its budget?

0 chars

Day 5 — epoll vs io_uring, honestly

Day 05

Picking the I/O model per path

Throughput, tail latency, operations and security

There's no universal winner. Here's how the options compare for the three I/O paths in your exchange:

epoll + non-blocking socketsio_uringBusy-poll recv on a pinned core
Syscalls per message1–2 (+1 per wakeup)amortised to ≪1; 0 with SQPOLL1 per poll attempt
Latency at low loadgoodgood (a sleeping ring adds a wakeup)best of the three in-kernel options
Throughput with many socketsdegrades with syscall costbestneeds one core per hot socket
CPU when idle~0~0, or one core with SQPOLLone core, always
Portabilityevery Linux5.1+, useful features 5.19–6.1+every Linux
Debuggabilitystrace shows everythingops aren't syscalls; use the io_uring tracepoints or bpftracestrace (noisy)

That points to a split:

  • Order-entry gateway (thousands of TCP sessions, bursty): io_uring with multishot recv, provided buffers and natural batching of replies. The per-message syscall savings are real and the batching window is wide.
  • Market-data hot path (one or two multicast sockets, every nanosecond counts): a pinned thread busy-polling a non-blocking socket beats both models on latency, and kernel bypass beats that (week 33). Batching here only helps under bursts.
  • Journal (sequential writes plus durability): io_uring fits naturally. A write linked to an fsync with IOSQE_IO_LINK expresses "durable, in order" as a single submission, and registered buffers plus O_DIRECT remove the per-I/O page pinning.

Then there's the operational side. io_uring gives the kernel a large new attack surface. In 2023 Google's security team reported that most of the kernel exploits submitted to its kCTF vulnerability reward program targeted io_uring, and it restricted io_uring on ChromeOS, for Android apps and on its production servers. Linux 6.6 added a kernel.io_uring_disabled sysctl, and some container runtimes' default seccomp profiles block the io_uring syscalls. Before you design around io_uring, confirm that the boxes you'll deploy on (week 47) allow it, and keep the epoll backend as a working fallback rather than dead code.

Docsby Shuveb Hussain
Lord of the io_uring

A practical guide built on liburing, with worked examples of SQPOLL, registered files and fixed buffers and a complete web server. Read the 'io_uring by example' chapters for the shape of a real event loop.

Code
tokio-rs/io-uring

The low-level Rust bindings most runtimes build on. Notice which calls are unsafe and why: every push hands the kernel a raw pointer whose lifetime the type system can't see.

Recall#uring-not-the-answer

Give two situations where io_uring is not the right tool for an exchange's latency-critical path, and say what you'd use instead.

Day 6 — Design the event loop

Day 06

One trait, two backends

Sketch the interface before you write a backend

The gateway from week 13 reads and writes sockets through Tokio. This week you build an I/O layer that speaks in submissions and completions, implement it twice, and run the same session code on both. That session code must not know which backend it's running on. In the repo it is a framing-layer echo server, echo_loop, and the gateway itself stays on Tokio, so the comparison measures the I/O model and nothing else.

The interface. Model it on io_uring even for the epoll backend. Completion is the more general model, because you can emulate completions on top of readiness but not the other way round. The repo's shape (gateway::io):

pub enum Submission {
    Accept { user_data: u64 },
    Read { conn: ConnId, buf: Vec<u8>, user_data: u64 },  // the backend owns buf until it completes
    Write { conn: ConnId, buf: Vec<u8>, user_data: u64 },
    Close { conn: ConnId, user_data: u64 },
}
pub struct Completion { pub user_data: u64, pub result: i64, pub buf: Option<Vec<u8>> } // result < 0: -errno

pub trait IoBackend {
    fn register(&mut self, stream: TcpStream) -> io::Result<ConnId>;
    fn listen(&mut self, listener: TcpListener) -> io::Result<()>;
    fn push(&mut self, sub: Submission) -> Result<(), Submission>; // no syscall; handed back if full
    fn submit_and_wait(&mut self, min_complete: usize, timeout: Option<Duration>)
        -> io::Result<usize>;                                     // one wakeup
    fn drain(&mut self, out: &mut Vec<Completion>) -> usize;     // no syscall
    fn stats(&self) -> IoStats;                                   // wakeups, syscalls, ...
    fn name(&self) -> &'static str;
}

The epoll backend turns readiness into completions: submit_and_wait tries each pending read and write itself, and when one would block it waits in epoll_wait for the socket and retries, appending completions as operations finish. In the repo that is ReadinessBackend<P>: EpollBackend on Linux, and PollBackend, a portable fallback that rescans every socket. The ring backend can be a simulated io_uring. The repo's RingBackend is the SQPOLL model: the SQ and the CQ are the SPSC rings from weeks 1 and 5, shared with a "kernel" thread that performs the operations with its own poller, so the caller enters the (simulated) kernel only to wake that thread or to block for completions. That keeps the capstone runnable on machines and containers where io_uring isn't available. If you're on Linux 5.19 or later, put a real backend behind a cargo feature using the io-uring crate in your fork; the repo stops at the simulation because Tokio is its only external dependency. A real backend is the version worth talking about in an interview.

The loop. Natural batching: wait for at least one completion, drain all available completions, run the session logic for each, push every resulting send and the replacement recvs, then submit once. Replies to the same session within one batch should coalesce into one send (the writev lesson from week 31). Count submit_and_wait calls and messages, and report messages per wakeup next to throughput. That ratio is the whole story of this week. The repo's echo_loop(io, conns, &EchoOptions) is this loop. EchoOptions::max_batch caps the completions handled per wakeup (usize::MAX for natural batching, 1 for one at a time), and the returned EchoStats has per_wakeup() and msgs_per_sec().

Design note#loop-sketch

Write the event loop for your gateway in fifteen lines of pseudo-code on top of the IoBackend trait: where the batch starts and ends, where replies coalesce, where recvs are reposted and where you count wakeups. Mark the one line that must never run inside the per-completion loop.

0 chars

Capstone

You're building the batched I/O layer in crates/gateway (gateway::io): the IoBackend trait from Day 6, the readiness backend with its epoll poller, the ring backend, and echo_loop running unchanged on every backend. The test client load (EchoLoad) is provided. Both backends promise the same semantics, and the tests compare their completions for a scripted workload, error codes included: a Read completes with whatever bytes are available (a short read is normal, 0 is end of stream), a Write completes only once its whole buffer is written (the backend resumes partial writes itself), operations on one connection complete in submission order (one read in flight per connection, a second gets -EBUSY), and errors are negative errno values, like io_uring's res.

Pitfalls to design out, not debug later:

  • Buffer lifetimes. A buffer referenced by an in-flight op can't be reused. Never hand the backend a borrowed slice. The repo moves an owned Vec<u8> into each Read and Write and gets it back in Completion::buf, and echo_loop re-posts read buffers and pools write buffers. A slab of buffers indexed by id (the week 27 pattern) is the zero-allocation version, and the one a real io_uring backend with registered buffers needs.
  • Cancellation. Closing a session with a recv in flight: the kernel may still complete into that buffer. Submit a cancel (IORING_OP_ASYNC_CANCEL in the real thing) and free the session's buffers only after both completions arrive. In the repo, Submission::Close first completes the connection's pending operations with -ECANCELED (their buffers come back with them), then completes itself with 0. A stale completion for a reused connection id is the other trap; the repo never reuses a ConnId within a backend, and generation tags are the alternative.
  • CQ sizing. Bound in-flight ops (sessions × ops per session + accepts) and size the completion side to hold them all. A full CQ should be impossible by construction. The repo's ring gives the CQ twice the SQ's entries, the kernel's default ratio, and its kernel thread holds any overflow and retries, as Linux does with IORING_FEAT_NODROP.
  • Fairness. One chatty session can fill a batch. Cap the work per session per wakeup so one client's burst doesn't add a full batch of latency to everyone else.
  • Backpressure to the engine. If the engine's input queue is full, stop reposting recvs for the sessions that are sending. Don't buffer unboundedly in the gateway.

Reading the benchmark. The target is in messages per second through the wakeup-batched loop. w32.batched_io runs echo_loop for 8 client connections, each keeping 64 frames (1–32 byte payloads) in flight, and reports the ring backend's messages per second, the median of three runs. It prints the epoll backend and a low-load run (one frame in flight per client) next to it, with messages per wakeup and the syscalls the loop's own thread made. A loopback benchmark measures your loop and the kernel's loopback path, not a NIC, so compare the backends on the same machine rather than against the target alone. Don't expect the ring to win on throughput at high load. Completions and readiness both pile up while the loop works, so both backends drain large batches (over a hundred messages per wakeup in the repo's own runs), and the client threads, not the loop, set the pace. The difference is in the caller's syscalls, and it shows most at low load: the readiness loop pays a read, a write and an epoll_wait for almost every message, while the ring's caller hardly enters the kernel. The tests check both effects: natural batching must cut wakeups at least fourfold against one completion per wakeup, on every backend, and the ring's caller must make fewer than half the readiness loop's syscalls. If your batches stay near 1 while frames are queued, your loop isn't really batching. Look for a submit inside the per-completion loop.

Capstone · in your exchange · crates/gateway

One event loop, two backends

A batched I/O trait in crates/gateway with an epoll backend and a submission/completion-ring backend, the same session code on top of both, and a measured answer to which wins at what load.

cargo xtask test --week 32      # tests named w32_*
cargo xtask bench --week 32     # p50 / p99 / p99.9 on your machine
cargo xtask report --upload  # update this page and your lab
  • All week 32 tests pass w32_*
  • Messages per wakeup-batched loop w32.batched_iotarget ≥ 2.5M msgs/s
Design note#week-reflection

After benchmarking both backends: at what load, if any, did the ring backend start winning, and on which measure (throughput or the caller's syscalls)? What was the average batch size there, and does that match the window your batch-sizes drill predicts for your measured fixed and per-message costs? If not, which cost did you mis-estimate?

0 chars

Feeds into

Feeds into →W33 · Kernel Bypass
  • Week 33 takes the next step. Instead of entering the kernel less often, it removes the kernel from the packet path entirely. The descriptor rings you modelled here are what a NIC exposes directly to a kernel-bypass application.