compio-pool
A connection pool for compio, the completion-based,
thread-per-core Rust runtime.
[]
= "0.0.2"
Connections live in per-thread shards, never behind a shared mutex. The acquire fast path
touches no atomics and takes no lock, Manage::Connection is never required to be Send, and
every connection is driven by the compio driver that created it.
π API documentation on docs.rs β every public type, with the cancellation points marked.
Design notes β architecture Β· design decisions Β· operating guide Β· performance Β· testing
Companion crates β compio-redis
(docs), a poolable Redis client living in
crates/compio-redis.
Why not just use bb8 / deadpool / r2d2?
Because they cannot hold a compio connection at all.
Every general-purpose Rust pool is built on the same assumption: connections are Send, so the
pool can keep one Arc<Mutex<Vec<Conn>>> and let any worker take any connection. That assumption
holds for tokio, where IO is readiness-based β a tokio::net::TcpStream is a registered fd plus a
waker, and any worker thread can poll it.
compio is completion-based. Each thread runs its own driver (io_uring, IOCP, or a poll fallback),
IO handles are bound to the driver that created them, and buffers are handed to the kernel by
ownership for the duration of an operation. The consequence is that compio::net::TcpStream is
!Send. It cannot go into an Arc<Mutex<_>>, which means bb8::ManageConnection's
Connection: Send bound rules it out at the type level. This is not a missing feature in those
crates; it is a different runtime model.
So "share one connection across threads" is not the thing to build. Two different ideas hide behind that phrase:
- Concurrent use of a single connection β only meaningful for protocols that multiplex (HTTP/2, Redis pipelining). Otherwise a connection is an exclusive resource and "sharing" means taking turns.
- A pool every thread can check out of β any thread can get some connection. That is what this crate provides.
The shape of the difference
tokio pools (bb8, deadpool, r2d2) |
compio-pool |
|
|---|---|---|
| Connection bound | Send required |
none β !Send is the normal case |
| Storage | one shared Mutex/semaphore |
one shard per thread, in a thread-local |
| Fast-path cost | lock + atomics, contended by every worker | no lock, no atomics |
max_size |
per process | per thread (see below) |
| Work stealing | implicit: any worker, any connection | explicit and opt-in (Reservoir) |
| Cancelling mid-operation | readiness-based: safe to drop the future | completion-based: the op is still running β the connection must be destroyed |
| Thread affinity | none | a connection is owned by one driver |
Quickstart
use Duration;
use ;
;
let pool = builder
.max_size // per thread
.min_idle
.acquire_timeout
.build;
// Clone `pool` onto every compio thread; each transparently gets its own shard.
let mut conn = pool.acquire.await?;
let op = conn.begin_op;
// ... use `conn` ...
op.complete_op;
Pool is Send + Sync and cheap to clone, but holds no connections itself. Clone it onto every
compio thread; the shard is created on first touch.
Design in one page
Full detail in docs/architecture.md; the reasoning, alternative by alternative, in docs/decisions/.
Per-thread shards
The Pool handle is an Arc<Inner> containing the manager, the config and counters β no
connections. The connections live in a thread-local shard keyed by pool id. That is what lets a
Send + Sync handle own !Send resources: a value in a thread-local is, by construction, only
reachable from its own thread. At thread exit the shard drops and closes its connections on the
thread whose driver owns them.
β decision 0001
max_size is per shard, not per process
A pool with max_size = 8 on 4 compio threads can hold up to 32 connections. A process-wide cap
requires a cross-thread semaphore on the acquire fast path, which is exactly the contention
thread-per-core runtimes exist to avoid. Size your backend for max_size Γ threads. If your
database has a hard connection limit, this is the number you have to do arithmetic on, and it is
the most common way to get this crate wrong.
β decision 0002 Β·
sizing guide
recycle runs on acquire, not on return
Returning a connection happens in Drop, which cannot await. So validation and protocol-state
reset (roll back an open transaction, drain a pipeline) happen at the next checkout. Returning
Err from recycle discards the connection and the pool transparently tries the next idle one or
dials a fresh connection.
β decision 0003
Cancellation is the sharp edge
With completion-based IO, dropping a future with an operation in flight does not unwind the operation. The kernel still owns the buffer and the peer's response is still coming. compio keeps the buffer sound, but your protocol is now out of step: the next reader on that connection sees the tail of someone else's response. A connection cancelled mid-operation must be destroyed, never reused.
let op = conn.begin_op;
let n = conn.read.await?; // if this await is cancelledβ¦
op.complete_op; // β¦this never runs, and the conn is poisoned on drop
If you never cancel β no select!, no timeouts, no early return between submit and completion β
you can skip it. Everyone believes that about their code right up until they add a timeout.
β decision 0004
Letting connections migrate between threads
Pure sharding wastes connections when load is skewed: a quiet thread holds idle sockets a busy
thread could use. The optional Reservoir fixes that with a bounded, lock-free ArrayQueue
shared by every thread.
thread A shared ArrayQueue thread B
-------- ----------------- --------
local free list
over min_idle
|
| Detach::detach push (never blocks)
+--- fd out of ------------> [ fd, fd, fd ] ---+
A's driver | pop, then
| Detach::attach
+---> re-wrapped in
B's driver
Only idle connections take this path, and it is opt-in because its soundness is
platform-dependent: under IOCP a handle binds to one completion port for life, so on Windows
do not implement Detach. See examples/steal.rs for a full implementation over a real
TcpStream.
β decision 0005 Β·
decision 0006
When this crate is the right choice β and when it is not
Situational advantages
- It works at all with
!Sendconnections. If you are on compio, this is not a performance argument, it is the only argument that matters:bb8anddeadpoolcannot compile againstcompio::net::TcpStream. - The fast path is a thread-local
Vec::pop. No mutex, no semaphore, no atomic RMW on a cache line every core is writing to. It measures in the tens of nanoseconds. - Per-thread cost stays flat as cores are added. There is nothing shared and mutable on the hit path to ping-pong between caches. A globally-locked pool degrades here; this is the whole reason thread-per-core runtimes exist.
- Connections are always driven by the driver that created them, including at shutdown β thread exit closes that thread's connections on that thread.
- Cache and NUMA locality. A connection, its buffers and its driver stay on one core.
- Cancellation correctness is enforceable.
begin_opmakes "a cancelled checkout must die" a type-level obligation rather than a comment in a design doc. - Work stealing is a policy you choose, with a tunable split point (
min_idle) between local caching and sharing, rather than something the pool does implicitly on every checkout.
Situational disadvantages
Be honest with yourself about these before adopting it.
- No global connection cap.
max_size Γ threadsis your real ceiling. Against a backend with a strictmax_connections(Postgres, a rate-limited API), you must do that arithmetic yourself, and a pool sized for one thread will over-dial on sixteen. - Skewed load wastes connections unless you install a
Reservoirβ and that requiresDetach, which is unsound on Windows/IOCP. On Windows you are stuck with pure sharding. - More connections overall. Per-thread
min_idlemeans warm sockets multiply by thread count. Sixteen threads atmin_idle = 2is 32 idle connections against a backend that may have been sized for 8. - Cancellation discipline is on you. Forget
begin_oparound an operation you cancel, and you will return a desynchronized connection to the pool. The failure mode is a protocol corruption that surfaces on some later request, which is a miserable thing to debug. recycleruns at checkout, so it is on the latency path. APINGthere is paid by the request, not by a background task.- Waiters are not FIFO-fair. Under oversubscription a waiter can be repeatedly overtaken by
later arrivals that race the free list directly, producing an unbounded tail rather than
fair queueing.
acquire_timeoutmasks this as a timeout instead of a hang. See Known limitations. - Per-thread state complicates a global view. Gauges are summed across threads without a lock,
so a
Metricssnapshot can be momentarily internally inconsistent. - The ecosystem is small. There is no
compio-postgresorcompio-rediswaiting for you.Manageis easy to implement, but you are implementing it. - Version 0.1, no published crate, one author. Treat it accordingly.
Rules of thumb
Use it when you are already on compio, when your workload is many short requests over long-lived connections, and when per-core scaling is the thing you are buying.
Do not reach for it when your backend imposes a hard global connection limit you cannot express as
per-thread Γ threads, when you are on Windows and need connections to migrate, or when you are
on tokio β there, bb8 and deadpool are the right tools and this crate has nothing to offer you.
Configuration
| Setting | Default | Notes |
|---|---|---|
max_size |
16 | per thread |
min_idle |
0 | idle connections kept thread-locally; also the local/shared split point |
acquire_timeout |
30s | None waits forever |
max_lifetime |
30min | hard age cap |
idle_timeout |
10min | reaped while idle |
max_uses |
None |
retire after N checkouts |
reap_interval |
30s | how often each shard's background reaper runs |
With a Reservoir installed, min_idle decides how much stays local: leaving it at 0 means
every return crosses the shared queue. Set it to your steady-state per-thread concurrency so the
hot path stays lock-free and only the surplus is shared.
Other pool operations: warm() pre-opens up to min_idle on the current thread, invalidate()
retires every connection created before the call (credential rotation, failover), close() shuts
the pool down, and metrics() returns a snapshot of gauges (live, idle, parked) and counters
(created, closed, acquires, waits, timeouts, poisoned, recycle_failures, unparked).
Tuning these in anger, and what each metric is telling you: docs/operations.md.
Benchmarks
Reproduce with cargo bench. Figures are from the author's machine β treat them as shape, not as
numbers to quote.
Acquire fast path: ~65 ns/op with acquire_timeout = None, ~127 ns/op with a timeout armed β
the difference is the timer, not the pool.
The exchange was measured A/B against the Mutex<Vec<_>> it replaced, both designs compiled
into one binary. The lock-free queue is up to 2.1Γ slower on the mean when hammered with
nothing between operations, and 5.0Γ better at p99 / 5.8Γ better at p99.9. The justification
for the design is tail latency and behaviour as threads are added, not raw throughput.
Full tables, methodology and the end-to-end runs against a real server: docs/performance.md.
Examples
All runnable with cargo run --example <name>.
| Example | What it shows |
|---|---|
tcp |
pooling real !Send TcpStreams across several compio threads |
std_tcp |
interop both ways: adopting a std::net::TcpStream, and borrowing a pooled one back out to std-only APIs without closing it |
unix_socket |
pooling UnixStream over a real line protocol, with per-phase latency percentiles and counter deltas: why recycle has to be a round trip here and what that probe costs, a synchronous disconnect goodbye, and a server restart no caller sees |
steal |
a full Detach implementation over a real socket, and four threads feeding a fifth with zero dials |
ncat_bench |
the pool against a real external ncat server: steady state, oversubscribed, and no pool at all β including the TIME_WAIT/ephemeral-port exhaustion the unpooled path hits |
ncat_steal_bench |
the exchange on and off against a real server: cold-start acquire, connections dialled, and what the exchange costs on the hot path |
Testing
cargo test # unit, integration, property and limit suites
cargo test --test fuzz # the randomized suites on their own
The randomized suites are seeded, so a failure names the seed and step that produced it and replays exactly. They double as a soak run:
COMPIO_POOL_FUZZ_SEEDS=2000 COMPIO_POOL_FUZZ_STEPS=5000 cargo test --test fuzz
fuzz/ holds two libFuzzer targets sharing the same invariant oracle, on nightly:
cargo +nightly fuzz run pool_ops
cargo +nightly fuzz run reservoir_ops
The central invariant, asserted after every step of every randomized workload, is that no
connection is ever lost: created == closed + live + parked + taken + cleared.
Suite-by-suite breakdown, the oracle, and the CI matrix: docs/testing.md.
Known limitations
- Waiter fairness. The acquire loop checks the free list before the wait queue, so a late
arrival can barge past a parked waiter. Under sustained oversubscription this yields an unbounded
tail rather than FIFO-fair queueing. Tracked for a follow-up;
acquire_timeoutbounds it in the interim. β decision 0007 Detachis unsound under IOCP. Cross-thread migration is a Unix-only capability today. β decision 0005- No global cap. By design, but see the disadvantages above. β decision 0002
- Metrics gauges are lock-free sums and can be momentarily inconsistent with each other. Counters are monotonic. β decision 0008
Requirements
Rust edition 2024, Rust 1.88+, compio 0.18. The library itself depends only on compio
(runtime, time) and crossbeam-queue. CI covers Linux, macOS and Windows on stable, plus beta
on Linux.
License
MIT OR Apache-2.0, at your option.