compio-pool
[ NOTE: Pre-1.0 and under active development. The pool core, its metrics, maintenance and graceful shutdown are in place and tested; the API may still change before 1.0. ]
A bb8-style connection pool for compio on Linux
io_uring.
You build one Pool โ a manager plus a handful of Builder knobs โ then clone that handle onto
each thread of your own thread-per-core runtime. On every thread you call Pool::local once to
get that thread's LocalPool, and LocalPool::get to lease a connection. The pool is the
product; spawning threads, pinning them and running an accept loop are yours (the crate ships the
cpu and listener helpers for that half).
[]
= "0.1"
๐ API documentation on docs.rs
Design notes โ architecture ยท design decisions ยท operating guide ยท performance ยท testing
Why it is shaped this way
A compio connection is bound to the io_uring ring of the thread that opened it: its buffers
are registered with that ring and its completions are delivered there. It cannot be used from
another thread. So, unlike bb8 on tokio, the pool of connections cannot be a single shared,
work-stealing store. What is shared is only the blueprint:
PoolโSend + Sync + Clone. The manager and the configuration, behind oneArc. Cloning is anArcbump. This is the only thing that crosses threads.LocalPoolโ!Send, one per thread. The idle connections and the slot count, allRc/Cell, no lock and no atom on the hot path. The compiler keeps it โ and the ring-bound connections it holds โ on its own thread.
Every size is per thread: max_size caps one thread's LocalPool, not the process. A
48-core server with max_size(16) can hold up to 48 ร 16 connections, 16 of them to any one
thread's ring.
How it works internally
Everything on the lease path is thread-local and lock-free. A LocalPool is an Rc<Inner> over
three pieces of state, all Cell / RefCell:
- an idle free-list โ a
Vecused LIFO, sogethands back the most recently returned connection (warmest in cache, least likely to have aged out); - a slot count โ connections this thread has open right now (idle + leased), capped at
max_size; - a waiter list โ tasks parked inside
getbecause the thread is atmax_size.
LocalPool::get does one of three things, in order:
- Reuse. Pop the newest idle connection. With
test_on_check_outset it runsis_validfirst; a connection that fails is dropped (its slot freed) and the next one tried. - Open. If none is idle and the slot count is below
max_size, claim a slot andawaitthe manager'sconnect. - Wait. Otherwise register one waker and park up to
connection_timeout, then retry; a timeout returnsRunError::TimedOut.
The returned PooledConnection derefs to the connection. On drop the pool asks the manager
has_broken and checks idle_timeout / max_lifetime: a healthy, in-date connection is pushed
back onto the idle list; anything else is dropped and its slot freed. Either way one parked waiter
is woken. None of this touches an atomic or a lock โ it is a Vec push/pop and a counter bump on
a single thread.
The only atomics in the crate are the per-thread metric counters behind State /
Statistics, kept relaxed so an off-thread scrape can sum every worker's view without disturbing
the hot path. Expiry is lazy โ a connection's age is checked only as it is handed out or
returned โ so a pool that goes quiet keeps its connections until you run
maintain on a timer.
Quickstart
Define a manager, build the pool once, and lease connections on each worker:
use io;
use ;
/// A pool of reusable 16 KiB scratch buffers. A real manager would open a
/// backend connection (Redis, Postgres, an upstream socket) in `connect`.
;
// Build once; `Pool` is `Send + Clone`, so hand a clone to each thread.
let pool = builder.max_size.build;
// On each compio thread you spawn (see `examples/echo.rs` for the full
// pinned, SO_REUSEPORT thread-per-core setup):
let local = pool.local; // this thread's own pool, `!Send`
let mut buf = local.get.await?; // PooledConnection, returned on drop
buf.clear;
One trait is all you implement:
ManageConnectionteaches the pool how toconnect, how tois_valida reused connection as it is checked out, and how to tell if onehas_brokenas it is returned. The manager value isSend + Sync(it is shared behind anArc); the connections it makes are not, and never leave their thread.
LocalPool::get reuses a live idle connection, or opens one while under max_size, or waits up
to connection_timeout for a slot. The returned PooledConnection derefs to the connection and
returns it to this thread's pool when dropped.
Runtime designs
The pool is a blueprint (Send) plus one !Send LocalPool per thread, so it drops into any
compio runtime shape without change: you call pool.local() once per runtime and lease from it.
compio owns the ring; you choose how many rings there are and how concurrency spreads across them.
Four native shapes, smallest to largest โ each is a runnable example.
1. One ring, the macro โ #[compio::main]
The least ceremony: a single io_uring on the current thread. Good for CLIs, tests and
single-tenant tools.
async
2. One ring, built by hand โ Runtime::builder
Same single ring, but you own main (and can tune the ring โ see below). This is what most
examples do.
use Runtime;
let runtime = builder.build?;
runtime.block_on;
3. One ring, many tasks โ task-per-connection
Concurrency comes from compio tasks, not threads: one ring serves thousands of connections,
each a cheap task spawned with compio::runtime::spawn. No pinning, no SO_REUSEPORT.
See examples/task_echo.rs,
fanout_requests,
scatter_gather, http_proxy.
let local = pool.local;
loop
4. One ring per core โ thread-per-core + SO_REUSEPORT
The design that scales: one pinned OS thread per core, each with its own runtime, its own
SO_REUSEPORT listener (the kernel splits connections by flow hash), and its own LocalPool
carved from the one shared Pool. See examples/echo.rs and
udp_echo, file_server,
tcp_pool, wal, unix_echo.
use ;
use ;
for core in cores
For a thread pool that dispatches work onto a set of background rings rather than owning an
accept loop, compio also ships compio::dispatcher โ build
one Pool and call pool.local() on each dispatched runtime exactly as above.
Tuning the ring (any design)
The ring is configured through a ProactorBuilder handed to Runtime::builder().with_proactor(โฆ).
All of these are off by default:
use ProactorBuilder;
let mut proactor = new;
proactor
.capacity // SQ/CQ entries
.single_issuer // only this thread submits โ fits thread-per-core
.coop_taskrun // batch completions, fewer wakeups
.taskrun_flag; // let the kernel signal pending completions
// .sqpoll_idle(Duration::from_millis(100)) // kernel-side submission polling
let runtime = builder
.with_proactor
.build?;
single_issuer and the defer_taskrun-family flags need newer kernels than compio's 5.19
baseline โ see Requirements. They are a per-thread win for the thread-per-core
design, where exactly one thread ever touches each ring.
Configuration
Every knob is per thread. Defaults match bb8.
| knob | default | meaning |
|---|---|---|
max_size |
10 | most connections one thread keeps open at once (idle + checked out) |
min_idle |
0 | connections each thread opens up front and keeps idle (warm / maintain) |
connection_timeout |
30 s | how long get waits for a slot and a live connection before TimedOut |
idle_timeout |
10 min | drop an idle connection unused for this long (None = never) |
max_lifetime |
30 min | drop a connection older than this, idle or not (None = never) |
test_on_check_out |
true | call ManageConnection::is_valid on a reused connection as it is checked out |
let pool = builder
.max_size
.min_idle
.connection_timeout
.build;
Running it in production
Three things a long-lived server needs are built on the same Send / !Send split.
Metrics
Each LocalPool keeps its own lock-free counters and registers them with the Pool. Read one
thread's, or sum every thread's for a process-wide view to feed a metrics endpoint:
let here = local.state; // this thread: State { connections, idle_connections, pending_waiters }
let all = pool.state; // summed across every thread
let s = pool.statistics; // cumulative Statistics across every thread
// s.get_direct, s.get_waited, s.get_timed_out,
// s.connections_created,
// s.connections_closed_broken / _idle_timeout / _max_lifetime,
// s.total_wait_time (and s.average_wait_time())
Pool::state / Pool::statistics take a lock to walk the registry, so call them off the hot
path (a scrape, a health check). LocalPool::state / LocalPool::statistics are lock-free.
Maintenance
Expiry is lazy โ a connection is only checked as it is handed out or returned โ so a pool that goes quiet would hold dead backend connections open. Thread-per-core has no shared reaper thread, so you drive maintenance per worker on a timer:
spawn
.detach;
maintain = reap (drop idle connections past idle_timeout / max_lifetime, free their
slots) + warm (top back up to min_idle). LocalPool::clear drains a thread's idle
connections unconditionally โ useful after a backend failover.
Shutdown
Pool::close makes every thread's get fail with RunError::Closed, stops recycling (a
returned connection is dropped, not parked), and turns warm / maintain into no-ops.
is_closed reports the flag. For a prompt drain, stop each worker's accept loop and call
LocalPool::clear on its thread โ the shutdown flag is shared, but the wakers that unpark a
parked get live on each worker's own thread.
The thread-per-core half
The crate ships the two helpers that building your own accept loop needs, so the pool and the runtime around it come from one place:
cpuโcpu::cores()returns the affinity mask (honourstaskset/ cgroup cpusets),cpu::pin_current(core)pins a thread,cpu::affinity_mask(cpu)formats an IRQ mask.listener::bind_reuseportโ a raw TCP socket withSO_REUSEADDR+SO_REUSEPORTset beforebind(so every thread binds the same address and the kernel splits connections by flow hash), and optionalSO_INCOMING_CPU.
Worked examples:
# Pool reusable buffers behind a thread-per-core SO_REUSEPORT echo server:
# The canonical use โ pool upstream TCP client connections to a backend:
UPSTREAM=127.0.0.1:7001 \
# Watch State snapshots as a single pool warms, lends and reaps:
# Drive any of the above:
Porting from tokio: sharp edges
compio is completion-based (io_uring), not readiness-based (epoll), and thread-per-core, not
work-stealing. Most of the friction moving code over comes from those two facts. The ones that
bite:
-
Connections are
!Send. Acompio::net::TcpStream(and anything holding one, including aPooledConnection) is bound to the ring that made it and cannot move to another thread. There is notokio::spawn-style work-stealing: you cannot accept on one thread and serve on another, and you cannot stash a connection in aSendglobal. This is why the pool is per-thread โ a single shared, work-stealing pool is impossible, so don't reach for one. -
Buffers are moved, not borrowed. I/O takes ownership of the buffer for the duration of the op and gives it back in a
BufResult(result, buf):let BufResult(n, buf) = stream.read(buf).await;. There is no&mut [u8]read. Code ported from tokio has to thread the buffer through every call and take it back out โ hence thestd::mem::take(&mut *lease)/*lease = bufdance in the echo examples. -
Dropping a future with I/O in flight is not free. The kernel owns a submitted buffer until the op completes. compio keeps it alive and cancels on drop, but a connection whose
get()/op you cancel may not free its slot until the in-flight op resolves. Don't assumetokio-style instant cancellation of a half-finished read. -
tokio::is notcompio::.tokio::timeโcompio::time,tokio::spawnโcompio::runtime::spawn(returns a!Sendtask;.detach()or await it on the same thread). There is notokio::select!; usefuturescombinators orcompio::time::timeout. compio'sAsyncRead/AsyncWritetraits are its own (ownership-passing), not tokio's, so tokio-based middleware and most of thetower/hyperecosystem will not compile against compio streams without a bridge. Budget for a thin adapter layer or compio-native crates. -
Blocking still blocks the core. With one ring per thread there is no second worker to absorb a blocking call or a long CPU loop โ it stalls every connection on that core. Keep handlers
await-ing; push CPU-heavy work off the ring deliberately. -
Shutdown during a panic can abort the process. compio's executor aborts if a task waker runs on a thread that is already unwinding. Drive shutdown explicitly (close the pool, stop accept loops, drain) rather than relying on drop order while panicking โ see decision 0006.
-
Maintenance is yours. tokio pools (bb8/deadpool) run a background reaper thread. Thread-per-core has none, so expiry is lazy and you must call
maintainon a timer per worker, or idle connections never reap.
Host tuning
The script sizes the NIC queues to the worker count, masks irqbalance, pins each RX queue's
IRQ to its worker's core, enables ntuple filters for accelerated RFS and sizes the flow tables.
It refuses nothing it cannot do: on a single-queue virtual NIC it reports the gap and configures
software RFS only. SO_INCOMING_CPU (via listener::bind_reuseport) is worth turning on only
after the NIC is tuned โ see decision 0005 and the
operating guide.
Numbers
Benchmarked against tokio + deadpool on cloud VMs; full method, tables and caveats live under
docs/results/ and performance.md.
ARM โ compio-bench-arm-36
GCP t2a-standard-32 (Ampere Altra, Neoverse-N1, 32 vCPU, 1 thread/core, Ubuntu 24.04 /
kernel 7.0, rustc 1.99), 2026-10-04. This run isolates the echo path โ the one the pool sits
directly on โ against two tokio baselines: the work-stealing default and a pinned
current_thread-per-core, both on epoll.
| 512 B echo | tokio-default | tokio-per-core | compio-pool | vs per-core |
|---|---|---|---|---|
| throughput, W=4 | 330 k | 348 k | 436 k req/s | 1.25ร |
| throughput, W=8 | 633 k | 688 k | 871 k req/s | 1.27ร |
| throughput, W=12 (peak) | 716 k | 1.08 M | 1.20 M req/s | 1.11ร |
| p99 latency, W=8 | 834 ยตs | 482 ยตs | 365 ยตs | โ |
| server CPU per request | 11.4 ยตs | 11.4 ยตs | 9.1 ยตs | ~20 % less |
In short: 1.25โ1.29ร the throughput of tokio-per-core at 512 B (and 1.5โ1.67ร the
work-stealing default), with ~20 % less server CPU per request and the tightest tail everywhere
(p99 365 ยตs vs 834 ยตs at W=8). While saturated, compio issues ~0.03โ0.13 syscalls/request
against ~2.0 for both tokio variants (io_uring batches submit+complete; epoll pays a read and a
write each time), holds 5โ9 MB RSS vs ~11โ12 MB, and records zero cpu-migrations where
work-stealing bounced tasks ~50,000 times in the saturated 32-worker run. The edge narrows as
payloads grow โ by 8 KiB the per-core baseline draws level, because memcpy, not the runtime,
dominates. The bb8 pool itself adds no measurable hot-path cost. Full tables:
docs/results/gcp-t2a-standard-32/.
x86 โ n2-standard-48
GCP n2-standard (48 vCPU), 2026-10-04: wins file I/O 1.8โ4ร, ties small echo, loses 64 K echo and connection churn, and scales close to linearly thread-per-core (work-stealing collapses at the top end). docs/results/gcp-n2-standard-48/, plus a Redis proxy run under gcp-n2-standard-48-redis/.
Requirements
- Linux.
SO_REUSEPORTload balancing andio_uringare Linux semantics. The crate type-checks on other Unixes so editors work; nothing is tested there. - Kernel: 5.19+ for compio's default
io_uringflags; newer forsingle_issuer/defer_taskrunif you enable them in the runtime. - Rust 1.95+. compio 0.19's executor uses
cfg_select!, stable since 1.95; every toolchain from 1.88 to 1.94 was tried in the VM and fails to build the dependency tree. Enforced by CI.
Development
All building, testing and tuning happens on Linux (io_uring and SO_REUSEPORT are Linux
semantics); a Linux VM works fine when the host is not. See testing.md for the
exact loop.
License
MIT OR Apache-2.0.