Skip to content

czhao-dev/io-uring

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

43 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

io_uring_engine

License: MIT C++23 Platform: Linux

A zero-allocation, coroutine-based (C++23) Proactor engine over Linux io_uring -- built to see how far the "no heap allocations in steady state, no thread-per-connection" event-loop design goes with modern liburing features (multishot accept, registered buffers/files, SQPOLL), and benchmarked against epoll and thread-per-connection baselines to find out where that actually pays off and where it doesn't.

See docs/ARCHITECTURE.md for how the pieces fit together and docs/ASSUMPTIONS.md for the design decisions and known limitations worth reading before you build on this.

Highlights

  • Zero-allocation steady state -- coroutine frames come from a size-class-keyed pool allocator (PromisePool), not malloc; I/O goes through kernel-registered ("fixed") buffers and files. The benchmark below measures this directly: 12 heap allocations across ~826,000 requests served.
  • Graceful kernel-feature negotiation -- Ring::init() requests the full SQPOLL | SINGLE_ISSUER | COOP_TASKRUN flag set and falls back tier-by-tier on -EINVAL, so the same binary runs (degraded) on a 5.10 kernel and gets the full feature set on 6.x+ -- verified against a real kernel's actual -EINVAL behavior, not just reasoned about; see the flag-negotiation finding below.
  • Multishot accept with re-arm detection -- one submitted SQE yields many accepted connections; falls back to single-shot accept on older kernels/liburing versions.
  • Correct, order-preserving async cancellation -- EventLoop's cancel-then-drain shutdown issues IORING_ASYNC_CANCEL_ANY and keeps pumping CQEs until every outstanding op's coroutine has actually run its RAII teardown, rather than tearing the ring down out from under in-flight kernel ops.
  • Benchmarked, not just built -- a three-way harness (io_uring vs. epoll vs. thread-per-connection) measuring latency percentiles, context switches, and heap allocations, run end-to-end on a real GCP VM (results below).
  • Honest about what's actually been verified -- authored on macOS, which can't build or run any of this; the callout below and docs/ASSUMPTIONS.md are explicit about what was tested where, and what hasn't been exercised yet.

Architecture

io_uring_engine wraps io_uring in a Proactor: application code writes ordinary- looking sequential coroutines (co_await ring.read_fixed(...)), and the engine handles SQE submission, completion dispatch, and buffer/file lifetime underneath. Public headers live under include/io_uring_engine/; consumers never need #include <liburing.h> directly.

flowchart LR
    Client([Client socket])

    subgraph Kernel["Kernel space"]
        IOU[("io_uring<br/>SQ / CQE rings")]
    end

    subgraph Loop["EventLoop (one per thread/core)"]
        Acceptor["Acceptor<br/>multishot accept"]
        Ring["Ring<br/>submit_and_wait → dispatch_cqe"]
        Pool["BufferPool<br/>registered buffers"]
    end

    Coro["handle_connection(fd)<br/>one task&lt;void&gt; per connection"]

    Client -->|connect| Acceptor
    Acceptor -->|"accepted fd, spawn()"| Coro
    Coro -->|"co_await pool.acquire()"| Pool
    Coro -->|"co_await read_fixed() / write_fixed()"| Ring
    Ring -->|SQE| IOU
    IOU -->|CQE| Ring
    Ring -->|"resume() on completion"| Coro
Loading
Component Role
Ring Owns the io_uring instance; negotiates setup flags at runtime, mediates every kernel interaction
task<T> Lazy, single-owner coroutine type with pooled frame allocation and symmetric-transfer resumption
Awaitables ReadAwaiter/WriteAwaiter; defer io_uring_prep_* until an SQE slot is actually available
BufferPool Arena of registered ("fixed") buffers; acquire() suspends instead of failing when exhausted
FileRegistry Table of registered file descriptors, addressed by index instead of raw fd
Acceptor Multishot accept with automatic re-arm if the kernel silently deactivates the request
EventLoop The pump: submit → process CQEs → dispatch → resume, plus cancel-then-drain shutdown

One echo round trip: Acceptor::next() yields an accepted fd → EventLoop::spawn() starts a long-lived per-connection coroutine → it loops pool.acquire() / read_fixed() / write_fixed() / pool.release() -- one coroutine per connection, not per message, which is what keeps steady-state echoing allocation-free.

See docs/ARCHITECTURE.md for the full writeup (lifetime contracts, why deferred arm() matters, the shutdown sequence in detail) and docs/ASSUMPTIONS.md for design decisions, what's been verified where, and known limitations.

Project layout

include/io_uring_engine/          Public headers (Ring, task<T>, BufferPool, FileRegistry, Acceptor, EventLoop)
src/                     Implementation
examples/
  echo_server/           Main demo: a fixed-buffer io_uring echo server
  thread_per_core_stub/  One EventLoop per pinned core, SO_REUSEPORT
  sstable_demo/          Toy async file-read pattern (LSM read-path shape)
  http_echo_server/      HTTP/1.1 echo server: epoll / io_uring / io_uring+fixed-buffers
bench/                   Three-backend raw-echo benchmark harness + analysis scripts
  scripts/               run_bench.sh/analyze_latency.py (raw echo) + run_wrk_bench.sh/
                         wrk_post.lua/summarize_wrk_results.py (HTTP, via wrk)
tests/                   Unit tests (task, BufferPool) + a Ring/EventLoop integration test
docs/
  ARCHITECTURE.md        Component-by-component design writeup
  ASSUMPTIONS.md         Design decisions, what's verified where, known limitations

Contents

Requirements

  • Linux kernel 5.10+ (6.1+/6.6+ recommended to get SINGLE_ISSUER, COOP_TASKRUN, and multishot accept -- Ring degrades gracefully on older kernels, see docs/ASSUMPTIONS.md #3)
  • GCC 14+ (C++23 coroutines, std::print/std::println); Clang on Linux needs libstdc++ headers from GCC 14+ or -stdlib=libc++ with a <print>-capable libc++ (LLVM 18+)
  • CMake 3.20+
  • liburing 2.3+ recommended (2.2+ minimum for multishot accept); older versions work with reduced functionality via the fallback paths in Ring/Acceptor/FileRegistry
  • Ninja (or Make) and pkg-config
  • wrk (optional, runtime-only) -- needed to benchmark the HTTP echo server; see HTTP echo server

Installing dependencies

# Debian/Ubuntu
sudo apt install build-essential cmake ninja-build pkg-config liburing-dev

# Fedora/RHEL
sudo dnf install gcc-c++ cmake ninja-build pkgconf-pkg-config liburing-devel

# Arch
sudo pacman -S base-devel cmake ninja pkgconf liburing

If your distro doesn't package liburing-dev, configure with -DIO_URING_ENGINE_FETCH_LIBURING=ON to build it from source (cmake/FindLiburing.cmake drives liburing's own configure && make, since it has no native CMake build).

Building

cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
    -DBUILD_EXAMPLES=ON -DBUILD_BENCHMARKS=ON -DBUILD_TESTS=ON
cmake --build build -j

CMake options: BUILD_EXAMPLES (default ON), BUILD_BENCHMARKS (default OFF), BUILD_TESTS (default OFF), IO_URING_ENGINE_FETCH_LIBURING (default OFF).

Running the tests

ctest --test-dir build --output-on-failure

test_task and test_buffer_pool are pure C++23 with no liburing dependency and were actually run (via a standalone clang++ invocation, independent of this CMake project) during development -- see docs/ASSUMPTIONS.md #1 for what that caught. test_ring_integration only builds on Linux (it needs a live io_uring instance) and has not been run anywhere; treat it as a starting point.

Running the echo server

./build/examples/echo_server/echo_server --port 7000

Smoke-test it:

printf 'hello\n' | nc localhost 7000

Ctrl-C triggers the cancel-then-drain shutdown path (EventLoop's request_stop() / drain_and_cancel_all()) -- confirm it prints echo_server shut down cleanly and actually exits rather than hanging.

Other examples: examples/thread_per_core_stub (one EventLoop per pinned core, SO_REUSEPORT) and examples/sstable_demo (toy async file-read integration pattern) -- see docs/ASSUMPTIONS.md #2 for what these illustrate and why they exist.

Running the benchmarks

The benchmark suite compares this engine's echo server against an edge-triggered epoll baseline and a thread-per-connection blocking-I/O baseline, at a configurable connection count, reporting P50/P90/P99/P99.9 latency plus heap-allocation and context-switch counts during a measurement window (after a warmup period).

At meaningful connection counts (thousands+) you'll need to raise limits beyond ulimit -n:

ulimit -n 200000
# As root, widen the ephemeral port range and listen backlog:
sudo sysctl -w net.ipv4.ip_local_port_range="10000 65535"
sudo sysctl -w net.core.somaxconn=65535

SQPOLL with a pinned polling CPU (--sqpoll plus a configured sqpoll_cpu) needs CAP_SYS_NICE.

./build/bench/scripts/run_bench.sh build 10000 20

This starts each of the three backends in turn, drives load against it with client_load_gen, and prints latency percentiles via analyze_latency.py. Beyond a few tens of thousands of connections, a single client process/machine is likely to become the bottleneck before the server does -- client_load_gen does not coordinate across multiple client machines, so scaling toward the spec's 100k-connection goal may require running it from more than one host.

Watch BufferPool::exhausted_count() (surfaced by the io_uring backend) if latency degrades at high connection counts: a climbing count means the pool is undersized relative to concurrent in-flight ops, which can look like a deadlock-adjacent stall rather than an obvious "out of buffers" error. run_bench.sh sizes the io_uring backend's buffer pool to the requested connection count automatically (with 20% headroom) via --buf-count, so this shouldn't come up unless you invoke bench_io_uring_echo directly with a small pool against a large connection count.

Benchmark results (GCP e2-standard-4, Ubuntu 24.04, kernel 6.17)

The numbers below are from bench/scripts/run_bench.sh build 2000 15 on a single 4-vCPU / 16GB GCP VM, client and server sharing the same box (localhost) -- see docs/ASSUMPTIONS.md for why this is a moderate-scale validation run rather than an attempt at the spec's 100k-connection goal. Ring negotiated down to SQPOLL | SINGLE_ISSUER (flags 0x1002) on this kernel -- see the note on that below.

Latency

Echo round-trip latency by percentile, log scale, three backends compared

Empirical CDF of per-request latency, three backends compared

At the median, thread-per-connection is fastest (96µs vs. io_uring's 138µs and epoll's 148µs): with only 2,000 connections on 4 cores, a dedicated thread per connection means a request is usually serviced the instant it arrives, with no batching or event-loop-turn delay. The CDF makes this concrete -- the orange (threaded) curve sits clearly left of the other two through roughly the 90th percentile.

That ordering reverses in the tail: at p99.9, io_uring (434µs) is worse than epoll (386µs) and roughly on par with threaded (367µs); at max, threaded spikes to 61ms against io_uring's 23ms and epoll's 14ms. Thread-per- connection's latency is bimodal -- fast when the OS scheduler gives a connection's thread the CPU promptly, occasionally very slow when 2,000 runnable threads contend for 4 cores and one gets descheduled for a while. io_uring and epoll, both single-threaded event loops, don't have that scheduling lottery, so their tails are shorter and more predictable relative to their own median -- the entire point of an event-driven design at scale, even though this particular run is too small (2,000 connections, 4 cores) for that advantage to show up as a better median too.

System overhead

Context switches and heap allocations during the measurement window, three backends compared

This is where the architectural difference is unambiguous. Thread-per- connection racked up 871,066 voluntary + 130,256 involuntary context switches during the 20-second measurement window -- the kernel scheduler constantly juggling 2,000 threads. io_uring, funneling all I/O through one ring on one thread, needed 186,019 voluntary switches (roughly 4.7x fewer) and only 291 involuntary ones. epoll needed almost none (40 voluntary, 62 involuntary): a single-threaded, continuously-busy event loop essentially never blocks or gets preempted.

Heap allocations during the window tell a similar story from a different angle: epoll and threaded show 0, and io_uring shows 12 (not strictly zero, but close, out of ~826,000 requests served in the window -- each request in this workload does two heap-free round trips: one BufferPool::acquire()/release() pair and one read/write through the registered buffers, all backed by PromisePool's pooled coroutine-frame allocator). Those 12 are plausibly frames for a handful of coroutine size classes that hadn't stabilized by the start of the measurement window, or connections that were mid-handshake when AllocTracker::arm() fired -- worth a closer look with a longer warmup if you're chasing literal zero, but directionally this confirms the pooled-allocator design is doing what it's supposed to.

A real finding from the flag-negotiation ladder

Ring reported applied flags 0x1002 = IORING_SETUP_SQPOLL (0x2) | IORING_SETUP_SINGLE_ISSUER (0x1000) -- IORING_SETUP_COOP_TASKRUN was requested but silently dropped. Despite this kernel (6.17) supporting COOP_TASKRUN on its own, requesting it together with SQPOLL returned -EINVAL, and Ring::init()'s fallback ladder (see docs/ASSUMPTIONS.md #3) correctly dropped it and retried rather than failing outright. This is exactly the scenario that design was built for, confirmed against a real kernel rather than just reasoned about.

What this run doesn't tell you

This was a single-VM, single-run, 2,000-connection pass meant to validate correctness and get directionally real numbers -- not a rigorous benchmark. Before trusting these numbers for a real decision: run more than once (no variance/error bars here), test at multiple connection counts (not just 2,000), separate client and server onto different machines (localhost avoids real network latency and lets the client and server compete for the same 4 cores, which arguably flatters epoll/io_uring's single-threaded design and penalizes threaded's need for more cores to shine), and push toward the spec's 100k-connection target to see whether io_uring's and epoll's advantages widen as threaded's scheduler contention gets worse.

HTTP echo server (epoll vs. io_uring vs. io_uring + fixed buffers)

examples/http_echo_server/ implements the same idea as the raw-echo bench above -- echo the request back to the client -- one layer up the stack, in HTTP/1.1, so the same epoll/io_uring/io_uring+fixed-buffers comparison can be driven with wrk instead of a custom client:

  • server_epoll -- single-threaded, edge-triggered epoll baseline, no io_uring_engine/liburing dependency at all. Same edge-triggered accept/read mechanics as bench/bench_epoll_echo.cpp, plus HTTP/1.1 framing and EPOLLOUT-driven backpressure for responses too large to write in one call.
  • server_io_uring -- the same EventLoop/Acceptor plumbing as examples/echo_server, but plain (non-fixed) read()/write() against a per-connection heap buffer instead of raw echo.
  • server_io_uring_fixed -- adds the registered BufferPool (read_fixed/write_fixed) on top, structurally almost identical to examples/echo_server.

All three share a hand-rolled HttpRequestParser (examples/http_echo_server/http_parser.{hpp,cpp}) that accumulates bytes across reads, frames a request on Content-Length (chunked Transfer-Encoding is rejected -- out of scope for an echo server), and supports HTTP/1.1 keep-alive/pipelining, plus a shared response builder (http_response.{hpp,cpp}). See docs/ASSUMPTIONS.md #5 for the SQPOLL default and request-size-cap decisions specific to these three binaries.

Building and testing

cmake --build build --target server_epoll server_io_uring server_io_uring_fixed
examples/http_echo_server/functional_test.sh build/examples/http_echo_server/server_epoll 8000
examples/http_echo_server/functional_test.sh build/examples/http_echo_server/server_io_uring 8001
examples/http_echo_server/functional_test.sh build/examples/http_echo_server/server_io_uring_fixed 8002

functional_test.sh runs curl-based correctness checks (plain GET, POST echo, keep-alive reuse, boundary-sized bodies around the default 4096-byte buffer, modest concurrency, graceful shutdown) against whichever binary you point it at.

Benchmarking with wrk

Requires wrk on PATH (optional, runtime-only -- install via your package manager or build from wg/wrk):

bench/scripts/run_wrk_bench.sh build 30 3
python3 bench/scripts/summarize_wrk_results.py <results_log_path_printed_above>

Sweeps concurrency {100, 1000, 10000} x body size {64, 1024, 8192} bytes, 3 runs per cell averaged, reporting RPS and p50/p99/p99.9 latency (via wrk's own latency:percentile(), not a scraped summary) plus CPU utilization (sampled from /proc/<pid>/stat) per cell. For syscall/profiling-level detail (perf record -g, strace -c), run them manually against one representative cell rather than scripting a full sweep across every cell -- that's a documented procedure, not an automated one.

Results (GCP e2-standard-4, Ubuntu 24.04, kernel 6.17)

Run via bench/scripts/run_wrk_bench.sh build 15 1 on the same VM class as the raw-echo benchmark above (client and server sharing the same 4 vCPUs) -- one run per cell rather than 3 averaged, for time/cost reasons; treat these as directionally real, not statistically rigorous (see the caveats below).

Requests/sec at body size 1024B (the middle of the sweep):

Concurrency server_epoll server_io_uring server_io_uring_fixed
100 65,480 99,046 98,630
1,000 56,114 68,039 65,489
10,000 56,153 68,173 62,192

At small-to-medium bodies and low-to-moderate concurrency, both io_uring variants beat epoll by 20-50% (99k/98.6k vs 65.5k rps at c=100) -- exactly where per-syscall overhead should dominate. That gap narrows at c=10,000 (68k/62k vs 56k) as all three become more data-copy- and scheduler-bound.

The full 27-cell matrix (concurrency x body size x server) surfaced a genuinely surprising result at the largest body size (8192B): server_epoll (34.9k-43.4k rps) beats both io_uring variants at every concurrency level, and server_io_uring_fixed (15.9k-30.9k rps) is consistently the worst of the three, not the best -- worse even than plain (non-fixed) server_io_uring (36.0k-61.6k rps). Per the spec's own advice ("if your results contradict this pattern, investigate -- it usually means batching is not working correctly"): the default fixed-buffer size is 4096 bytes, smaller than an 8192-byte body, so every such request/response needs two acquire/memcpy/ release round trips through BufferPool instead of one -- exactly the per-buffer copy overhead documented in server_io_uring_fixed.cpp's header comment, now visible in real numbers rather than just reasoned about. Raising --buf-size above the largest expected body would be the natural next experiment to confirm this closes the gap; not yet done here.

A real bug this process found: the first full benchmark run crashed server_io_uring_fixed outright (exit code 141 = SIGPIPE) between the c=1,000 and c=10,000 cells -- writing to a socket whose peer had already closed raised SIGPIPE, whose default disposition kills the whole process. All three servers had this latent bug; fixed by ignoring SIGPIPE in each main(). See docs/ASSUMPTIONS.md #5 for the full story, including a second, smaller bug this same process found in functional_test.sh itself.

Syscall counts (strace -c, c=1,000, body=1024B, ~10s window):

total syscalls requests served syscalls/request
server_epoll 192,528 61,000 ~3.16
server_io_uring_fixed 3,410 641,030 ~0.005

Roughly a 590x reduction in syscalls per request, almost entirely io_uring_enter calls batching many connections' reads and writes into one syscall -- epoll's count is dominated by one read/write pair (plus epoll_wait) per event, exactly the per-operation syscall cost io_uring is designed to eliminate. A side effect worth noting: server_epoll's throughput collapsed under strace (70k to 6k rps, an ~11x slowdown) since ptrace traps every syscall, while server_io_uring_fixed barely noticed (65k to 63.8k rps) -- because it makes so few syscalls to begin with, there's much less for strace to trap.

perf record -g (software task-clock event -- this VM's hypervisor doesn't expose hardware PMU counters for the default cycles event) on server_io_uring_fixed under the same load shows top self-time almost entirely in the kernel: _raw_spin_unlock_irqrestore (8.2%), memcpy_orig (6.1%), fget, TCP/IP stack functions (__tcp_transmit_skb, tcp_sendmsg_locked, tcp_clean_rtx_queue), and io_uring internals (io_req_io_end, io_import_fixed) dominate; this project's own handle_connection coroutine accounts for only ~1.4% of samples. For an echo server this small, essentially all the cost is the kernel networking stack and io_uring's own bookkeeping, not application logic -- confirming there isn't meaningful HTTP-parsing or buffer-management overhead left to optimize away at this scale.

What this run doesn't tell you: single-run-per-cell, not the spec's 3-runs-averaged (no variance/error bars); client (wrk) and server sharing the same 4 vCPUs, which likely flatters whichever side is idler at a given moment rather than reflecting a real network path; and the --buf-size investigation above is a hypothesis, not yet confirmed by a follow-up run.

License

MIT -- see LICENSE.

References

  • Efficient IO with io_uring -- Jens Axboe's design paper for the interface this project wraps.
  • io_uring(7) man page -- the syscalls, setup flags, and CQE/SQE semantics Ring builds on.
  • liburing -- the C library Ring/Acceptor/FileRegistry call into.
  • cppcoro -- the lazy-task/coroutine style task<T> follows (symmetric transfer, single-owner lifetime).
  • lewissbaker.github.io -- Lewis Baker's C++ coroutine internals series; background for task<T>'s scheduling model and PromisePool's frame allocator.

About

Zero-allocation, coroutine-based (C++20) Proactor engine over Linux io_uring — SQPOLL, fixed buffers/files, multishot accept, and benchmarks vs epoll/thread-per-connection.

Topics

Resources

License

Stars

1 star

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors