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.
- Zero-allocation steady state -- coroutine frames come from a
size-class-keyed pool allocator (
PromisePool), notmalloc; 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 fullSQPOLL | SINGLE_ISSUER | COOP_TASKRUNflag 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-EINVALbehavior, 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 issuesIORING_ASYNC_CANCEL_ANYand 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.mdare explicit about what was tested where, and what hasn't been exercised yet.
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<void> 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
| 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.
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
- Architecture
- Project layout
- Requirements
- Building
- Running the tests
- Running the echo server
- Running the benchmarks
- Benchmark results
- HTTP echo server
- License
- References
- Linux kernel 5.10+ (6.1+/6.6+ recommended to get
SINGLE_ISSUER,COOP_TASKRUN, and multishot accept --Ringdegrades gracefully on older kernels, seedocs/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+
liburing2.3+ recommended (2.2+ minimum for multishot accept); older versions work with reduced functionality via the fallback paths inRing/Acceptor/FileRegistry- Ninja (or Make) and
pkg-config wrk(optional, runtime-only) -- needed to benchmark the HTTP echo server; see HTTP echo server
# 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 liburingIf 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).
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DBUILD_EXAMPLES=ON -DBUILD_BENCHMARKS=ON -DBUILD_TESTS=ON
cmake --build build -jCMake options: BUILD_EXAMPLES (default ON), BUILD_BENCHMARKS (default
OFF), BUILD_TESTS (default OFF), IO_URING_ENGINE_FETCH_LIBURING (default OFF).
ctest --test-dir build --output-on-failuretest_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.
./build/examples/echo_server/echo_server --port 7000Smoke-test it:
printf 'hello\n' | nc localhost 7000Ctrl-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.
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=65535SQPOLL with a pinned polling CPU (--sqpoll plus a configured
sqpoll_cpu) needs CAP_SYS_NICE.
./build/bench/scripts/run_bench.sh build 10000 20This 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.
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.
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.
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.
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.
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.
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, noio_uring_engine/liburingdependency at all. Same edge-triggered accept/read mechanics asbench/bench_epoll_echo.cpp, plus HTTP/1.1 framing andEPOLLOUT-driven backpressure for responses too large to write in one call.server_io_uring-- the sameEventLoop/Acceptorplumbing asexamples/echo_server, but plain (non-fixed)read()/write()against a per-connection heap buffer instead of raw echo.server_io_uring_fixed-- adds the registeredBufferPool(read_fixed/write_fixed) on top, structurally almost identical toexamples/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.
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 8002functional_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.
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.
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.
MIT -- see LICENSE.
- 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
Ringbuilds on. - liburing -- the C library
Ring/Acceptor/FileRegistrycall 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 andPromisePool's frame allocator.


