compio-pool 0.1.0

A bb8-style connection pool for compio: build once, clone the handle onto each thread-per-core io_uring runtime, and lease ring-local connections that never cross threads
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
# 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. ]

[![crates.io](https://img.shields.io/crates/v/compio-pool.svg)](https://crates.io/crates/compio-pool)
[![docs.rs](https://img.shields.io/docsrs/compio-pool)](https://docs.rs/compio-pool)
[![CI](https://github.com/artech-git/compio-pool/actions/workflows/ci.yml/badge.svg)](https://github.com/artech-git/compio-pool/actions/workflows/ci.yml)
[![MSRV](https://img.shields.io/badge/rustc-1.95+-blue.svg)](#requirements)
[![license](https://img.shields.io/crates/l/compio-pool.svg)](#license)

A bb8-style connection pool for [`compio`](https://github.com/compio-rs/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).

```toml
[dependencies]
compio-pool = "0.1"
```

๐Ÿ“– **[API documentation on docs.rs](https://docs.rs/compio-pool)**

**Design notes** โ€” [architecture](docs/architecture.md) ยท [design decisions](docs/decisions/) ยท
[operating guide](docs/operations.md) ยท [performance](docs/performance.md) ยท
[testing](docs/testing.md)

---

## 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 one `Arc`.
  Cloning is an `Arc` bump. This is the only thing that crosses threads.
* **`LocalPool` โ€” `!Send`, one per thread.** The idle connections and the slot count, all
  `Rc`/`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 `Vec` used LIFO, so `get` hands 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 `get` because the thread is at `max_size`.

`LocalPool::get` does one of three things, in order:

1. **Reuse.** Pop the newest idle connection. With `test_on_check_out` set it runs `is_valid`
   first; a connection that fails is dropped (its slot freed) and the next one tried.
2. **Open.** If none is idle and the slot count is below `max_size`, claim a slot and `await` the
   manager's `connect`.
3. **Wait.** Otherwise register one waker and park up to `connection_timeout`, then retry; a
   timeout returns `RunError::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`](#maintenance) on a timer.

---

## Quickstart

Define a manager, build the pool once, and lease connections on each worker:

```rust
use std::io;

use compio_pool::{ManageConnection, Pool};

/// A pool of reusable 16 KiB scratch buffers. A real manager would open a
/// backend connection (Redis, Postgres, an upstream socket) in `connect`.
struct Buffers;

impl ManageConnection for Buffers {
    type Connection = Vec<u8>;
    type Error = io::Error;

    async fn connect(&self) -> io::Result<Vec<u8>> {
        Ok(Vec::with_capacity(16 * 1024))
    }
    async fn is_valid(&self, _buf: &mut Vec<u8>) -> io::Result<()> {
        Ok(())
    }
    fn has_broken(&self, _buf: &mut Vec<u8>) -> bool {
        false
    }
}

// Build once; `Pool` is `Send + Clone`, so hand a clone to each thread.
let pool = Pool::builder().max_size(1024).build(Buffers);

// 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:

* **`ManageConnection`** teaches the pool how to `connect`, how to `is_valid` a reused connection
  as it is checked out, and how to tell if one `has_broken` as it is returned. The manager value
  is `Send + Sync` (it is shared behind an `Arc`); 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.

```rust
#[compio::main]
async fn main() -> std::io::Result<()> {
    let pool = Pool::builder().max_size(16).build(Buffers);
    let local = pool.local();          // this runtime's pool
    let mut buf = local.get().await?;  // lease, returned on drop
    buf.clear();
    Ok(())
}
```

### 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.

```rust
use compio::runtime::Runtime;

let runtime = Runtime::builder().build()?;
runtime.block_on(async {
    let pool = Pool::builder().max_size(16).build(Buffers);
    let local = pool.local();
    // ... accept loop / client work on this one ring ...
});
```

### 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`](examples/task_echo.rs),
[`fanout_requests`](examples/fanout_requests.rs),
[`scatter_gather`](examples/scatter_gather.rs), [`http_proxy`](examples/http_proxy.rs).

```rust
let local = pool.local();
loop {
    let (stream, _peer) = listener.accept().await?;
    // each connection is a task on this same ring; clone the LocalPool (Rc bump)
    compio::runtime::spawn(serve(local.clone(), stream)).detach();
}
```

### 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`](examples/echo.rs) and
[`udp_echo`](examples/udp_echo.rs), [`file_server`](examples/file_server.rs),
[`tcp_pool`](examples/tcp_pool.rs), [`wal`](examples/wal.rs), [`unix_echo`](examples/unix_echo.rs).

```rust
use compio::{net::TcpListener, runtime::Runtime};
use compio_pool::{bind_reuseport, cpu};

for core in cpu::cores() {
    let pool = pool.clone();           // Send blueprint; one clone per worker
    std::thread::spawn(move || {
        cpu::pin_current(core);
        let runtime = Runtime::builder().build().expect("ring");
        runtime.block_on(async move {
            let local = pool.local();  // this thread's !Send pool
            let std = bind_reuseport(addr, 1024, None).expect("SO_REUSEPORT");
            let listener = TcpListener::from_std(std).expect("wrap in ring");
            loop {
                let Ok((stream, _)) = listener.accept().await else { continue };
                compio::runtime::spawn(serve(local.clone(), stream)).detach();
            }
        })
    });
}
```

For a thread pool that *dispatches* work onto a set of background rings rather than owning an
accept loop, compio also ships [`compio::dispatcher`](https://docs.rs/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**:

```rust
use compio::driver::ProactorBuilder;

let mut proactor = ProactorBuilder::new();
proactor
    .capacity(1024)          // SQ/CQ entries
    .single_issuer(true)     // only this thread submits โ€” fits thread-per-core
    .coop_taskrun(true)      // batch completions, fewer wakeups
    .taskrun_flag(true);     // let the kernel signal pending completions
// .sqpoll_idle(Duration::from_millis(100)) // kernel-side submission polling

let runtime = compio::runtime::Runtime::builder()
    .with_proactor(proactor)
    .build()?;
```

`single_issuer` and the `defer_taskrun`-family flags need newer kernels than compio's 5.19
baseline โ€” see [Requirements](#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 |

```rust
let pool = Pool::builder()
    .max_size(16)
    .min_idle(4)
    .connection_timeout(Duration::from_secs(5))
    .build(manager);
```

---

## 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:

```rust
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:

```rust
compio::runtime::spawn(async move {
    loop {
        compio::time::sleep(Duration::from_secs(30)).await;
        let _ = local.maintain().await; // reap expired idle, then warm back to min_idle
    }
})
.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 (honours `taskset` / cgroup cpusets),
  `cpu::pin_current(core)` pins a thread, `cpu::affinity_mask(cpu)` formats an IRQ mask.
* **`listener::bind_reuseport`** โ€” a raw TCP socket with `SO_REUSEADDR` + `SO_REUSEPORT` set
  before `bind` (so every thread binds the same address and the kernel splits connections by flow
  hash), and optional `SO_INCOMING_CPU`.

Worked examples:

```sh
# Pool reusable buffers behind a thread-per-core SO_REUSEPORT echo server:
cargo run --release --example echo -- 0.0.0.0:7000

# The canonical use โ€” pool upstream TCP client connections to a backend:
cargo run --release --example echo    -- 127.0.0.1:7001          # a backend
UPSTREAM=127.0.0.1:7001 \
cargo run --release --example tcp_pool -- 0.0.0.0:7000 64        # the pooling front

# Watch State snapshots as a single pool warms, lends and reaps:
cargo run --example warmup

# Drive any of the above:
cargo run --release --example load -- 127.0.0.1:7000 --conns 64 --seconds 5
```

---

## 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`.** A `compio::net::TcpStream` (and anything holding one, including a
  `PooledConnection`) is bound to the ring that made it and cannot move to another thread. There is
  no `tokio::spawn`-style work-stealing: you cannot accept on one thread and serve on another, and
  you cannot stash a connection in a `Send` global. 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 the `std::mem::take(&mut *lease)` / `*lease = buf` dance 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 assume `tokio`-style
  instant cancellation of a half-finished read.

* **`tokio::` is not `compio::`.** `tokio::time` โ†’ `compio::time`, `tokio::spawn` โ†’
  `compio::runtime::spawn` (returns a `!Send` task; `.detach()` or await it on the same thread).
  There is no `tokio::select!`; use `futures` combinators or `compio::time::timeout`. compio's
  `AsyncRead` / `AsyncWrite` traits are its own (ownership-passing), *not* tokio's, so tokio-based
  middleware and most of the `tower` / `hyper` ecosystem 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]docs/decisions/0006-shutdown-and-the-unwinding-thread.md.

* **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 [`maintain`]#maintenance on a timer per worker, or
  idle connections never reap.

---

## Host tuning

```sh
scripts/tune-nic.sh --check                       # what the NIC can do; exits 2 if the recipe cannot apply
sudo scripts/tune-nic.sh --cpus 0,1,2,3,4,5,6,7   # same order as your worker cores
```

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](docs/decisions/0005-incoming-cpu-opt-in.md) and the
[operating guide](docs/operations.md).

---

## Numbers

Benchmarked against `tokio` + `deadpool` on cloud VMs; full method, tables and caveats live under
[docs/results/](docs/results/) and [performance.md](docs/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/](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/](docs/results/gcp-n2-standard-48/), plus a Redis
proxy run under [gcp-n2-standard-48-redis/](docs/results/gcp-n2-standard-48-redis/).

---

## Requirements

* **Linux.** `SO_REUSEPORT` load balancing and `io_uring` are Linux semantics. The crate
  type-checks on other Unixes so editors work; nothing is tested there.
* **Kernel:** 5.19+ for compio's default `io_uring` flags; newer for `single_issuer` /
  `defer_taskrun` if 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](docs/testing.md) for the
exact loop.

## License

MIT OR Apache-2.0.