Skip to content

Commit 329de76

Browse files
ELaresclaude
andauthored
perf(runtime): userspace connection distributor - make throughput scale across shards (#264)
Replace per-shard SO_REUSEPORT accept with a central acceptor that round-robins connections to shards (macOS SO_REUSEPORT does not load-balance, capping IronCache at ~1 shard). IronCache now scales: 85k flat -> 158k; matched vs redis 8.8 N=2 1.11x win, N=4 parity. From 0.5x to throughput parity-to-winning. 840 tests green, 0 review defects, perf-gate: qps flat on Linux + memory unchanged (no regression). Shared-nothing model preserved. Signed-off-by: Zeke <ezequiel.lares@outlook.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 15713df commit 329de76

2 files changed

Lines changed: 175 additions & 58 deletions

File tree

crates/ironcache-runtime/src/bootstrap.rs

Lines changed: 167 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,24 @@
22
//! Thread-per-core bootstrap (RUNTIME.md "topology", ADR-0002).
33
//!
44
//! [`run_shards`] spawns one OS thread per shard, each with its own current-thread
5-
//! tokio runtime and `LocalSet` (the shard executor) and its own `SO_REUSEPORT`
6-
//! accept loop. A connection accepted on a shard is served entirely on that shard,
7-
//! so per-connection state is core-local with no shared hot-path structure.
5+
//! tokio runtime and `LocalSet` (the shard executor). A single dedicated ACCEPTOR
6+
//! thread owns the one listening socket and round-robins each accepted connection
7+
//! to a shard over a per-shard channel; the receiving shard adopts the connection
8+
//! onto ITS reactor and serves it entirely on that shard, so per-connection state
9+
//! is core-local with no shared hot-path structure.
10+
//!
11+
//! ## Why a userspace acceptor instead of per-shard `SO_REUSEPORT`
12+
//!
13+
//! Earlier each shard bound its own `SO_REUSEPORT` listener and ran its own accept
14+
//! loop, relying on the KERNEL to load-balance accepts across the listeners. That
15+
//! balances on Linux but NOT on macOS/BSD, where accepts concentrate on a single
16+
//! listener, so N shards behaved like one shard for I/O (all connections funneled
17+
//! to one core). Distributing accepts in USERSPACE (one acceptor, round-robin to
18+
//! per-shard channels) is portable: it balances identically on every platform and
19+
//! makes throughput scale with shard count. The acceptor only does `accept()` +
20+
//! hand-off; the connection still lives its whole life on the shard that adopts it
21+
//! (which shard accepts a connection does not affect correctness, only I/O spread,
22+
//! because keyspace ops route by key through the coordinator).
823
//!
924
//! The per-connection serve logic is supplied by the caller as an async closure
1025
//! over the shard's [`crate::Runtime`], keeping this layer free of any protocol or
@@ -32,7 +47,8 @@ pub struct ShardId {
3247
pub struct ShardConfig {
3348
/// Number of shards (one OS thread / current-thread runtime each).
3449
pub shards: usize,
35-
/// The address every shard binds with `SO_REUSEPORT`.
50+
/// The single address the acceptor thread binds and accepts on; connections
51+
/// are then round-robined to the shards in userspace.
3652
pub bind: SocketAddr,
3753
}
3854

@@ -93,7 +109,7 @@ pub fn available_shards() -> usize {
93109
mod tokio_bootstrap {
94110
use super::{Arc, AtomicBool, DRAIN_GRACE, Duration, Ordering, ShardConfig, ShardId, ShardSet};
95111
use crate::TokioRuntime;
96-
use crate::tokio_rt::{bind_reuseport, bind_reuseport_std};
112+
use crate::tokio_rt::bind_reuseport_std;
97113
use std::cell::Cell;
98114
use std::future::Future;
99115
use std::rc::Rc;
@@ -113,12 +129,18 @@ mod tokio_bootstrap {
113129
}
114130
}
115131

116-
/// Run the shard set. For each shard this spawns an OS thread that:
132+
/// Run the shard set. ONE listener is bound up front and a single dedicated
133+
/// ACCEPTOR thread round-robins accepted connections to the shards over a
134+
/// per-shard channel (userspace load-balancing, portable across platforms; see
135+
/// the module docs for why this replaces per-shard `SO_REUSEPORT`). For each
136+
/// shard this spawns an OS thread that:
117137
/// 1. builds a current-thread tokio runtime (NOT multi-thread; ADR-0002),
118-
/// 2. binds the shared address with `SO_REUSEPORT`,
119-
/// 3. accepts connections in a loop, spawning `serve` per connection on the
120-
/// shard-local `LocalSet`,
121-
/// 4. stops accepting when the shutdown flag is set.
138+
/// 2. awaits ITS connection channel for inbound `std::net::TcpStream`s handed
139+
/// over by the acceptor, adopting each onto THIS shard's reactor with
140+
/// `tokio::net::TcpStream::from_std` (the connection now lives on this core),
141+
/// 3. spawns `serve` per connection on the shard-local `LocalSet`,
142+
/// 4. stops taking new connections and drains in-flight tasks when the
143+
/// shutdown flag is set.
122144
///
123145
/// `serve` is cloned per shard and invoked per connection with the shard's
124146
/// [`TokioRuntime`], the accepted [`tokio::net::TcpStream`], and the
@@ -159,21 +181,50 @@ mod tokio_bootstrap {
159181
inboxes.len()
160182
);
161183

162-
// Pre-flight bind probe so a bind failure (e.g. port in use) surfaces as
163-
// an error from this synchronous call rather than silently inside a shard
164-
// thread. The probe uses the std (reactor-free) binder; shards re-bind
165-
// with SO_REUSEPORT inside their own tokio runtimes.
166-
let probe = bind_reuseport_std(cfg.bind)?;
167-
drop(probe);
184+
// Bind the ONE listening socket up front, in this synchronous call, so a
185+
// bind failure (e.g. port in use) surfaces as an error here rather than
186+
// inside a spawned thread. This std listener is owned by the acceptor
187+
// thread; the shards never bind. `set_reuse_port` on it is harmless and
188+
// kept only so a fast restart can rebind the address; it no longer does
189+
// any load-balancing (that is now the acceptor's job).
190+
let listener = bind_reuseport_std(cfg.bind)?;
191+
192+
// One connection channel per shard: the acceptor sends accepted
193+
// `std::net::TcpStream`s, the shard receives them. Unbounded so the
194+
// (synchronous, non-async) acceptor can hand off without blocking on a
195+
// shard's reactor; the hand-off is just a queued pointer, and a shard
196+
// that is momentarily busy buffers a few connections rather than stalling
197+
// the acceptor (and thus every other shard). These channels carry only the
198+
// raw socket, no per-key/hot-path data (shared-nothing ADR-0002 intact).
199+
let mut conn_senders = Vec::with_capacity(total);
200+
let mut conn_receivers = Vec::with_capacity(total);
201+
for _ in 0..total {
202+
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<std::net::TcpStream>();
203+
conn_senders.push(tx);
204+
conn_receivers.push(rx);
205+
}
206+
207+
let mut handles = Vec::with_capacity(total + 1);
208+
209+
// The ACCEPTOR thread: owns the single listener, accepts in a loop, and
210+
// round-robins each connection to the next shard's channel. It is a plain
211+
// OS thread (no tokio runtime): a blocking `std` accept loop with a
212+
// shutdown-aware poll. `tokio::sync::mpsc::UnboundedSender::send` does not
213+
// require a runtime, so the hand-off is valid from this sync context.
214+
{
215+
let shutdown = Arc::clone(&shutdown);
216+
let acceptor = std::thread::Builder::new()
217+
.name("ironcache-acceptor".to_string())
218+
.spawn(move || acceptor_loop(&listener, &conn_senders, &shutdown))?;
219+
handles.push(acceptor);
220+
}
168221

169-
let mut handles = Vec::with_capacity(total);
170222
// Hand each shard its own inbox by moving items OUT of the vec by index. The
171223
// vec is consumed (into_iter) so each `I` is owned by exactly one shard thread.
172-
for (index, inbox) in inboxes.into_iter().enumerate() {
224+
for ((index, inbox), conn_rx) in inboxes.into_iter().enumerate().zip(conn_receivers) {
173225
let shutdown = Arc::clone(&shutdown);
174226
let serve = serve.clone();
175227
let drain = drain.clone();
176-
let bind = cfg.bind;
177228
let shard = ShardId { index, total };
178229
let handle = std::thread::Builder::new()
179230
.name(format!("ironcache-shard-{index}"))
@@ -189,30 +240,22 @@ mod tokio_bootstrap {
189240
}
190241
};
191242
let local = tokio::task::LocalSet::new();
192-
// Catch a panic escaping the accept loop so the thread logs it
243+
// Catch a panic escaping the serve loop so the thread logs it
193244
// (and bumps a per-shard shard_died counter for future
194245
// OBSERVABILITY wiring) before it exits, instead of unwinding
195246
// silently. The panic is then resumed so `join()` still surfaces
196247
// it to `shutdown_and_join` (which no longer discards it).
197248
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
198249
local.block_on(&rt, async move {
199-
let listener = match bind_reuseport(bind) {
200-
Ok(l) => l,
201-
Err(e) => {
202-
eprintln!("shard {index}: bind {bind} failed: {e}");
203-
return;
204-
}
205-
};
206250
// Spawn the cross-shard DRAIN LOOP on this shard's LocalSet
207-
// BEFORE the accept loop (COORDINATOR.md #107): a shard can
251+
// BEFORE the serve loop (COORDINATOR.md #107): a shard can
208252
// own keys and must service remote work even if it never
209-
// accepts a connection. The accept loop below then runs for
253+
// accepts a connection. The serve loop below then runs for
210254
// the shard's lifetime; the drain loop runs concurrently on
211255
// the same single-threaded LocalSet (interleaved, never
212256
// parallel, so the shard-local RefCells stay single-threaded).
213257
tokio::task::spawn_local(drain(inbox));
214-
let runtime = TokioRuntime::new();
215-
accept_loop(&listener, &runtime, &serve, shard, &shutdown).await;
258+
serve_loop(conn_rx, &serve, shard, &shutdown).await;
216259
});
217260
}));
218261
if let Err(panic) = result {
@@ -221,7 +264,7 @@ mod tokio_bootstrap {
221264
// wiring (OBSERVABILITY.md #152) reads it later.
222265
let shard_died: u64 = 1;
223266
eprintln!(
224-
"shard {index}: accept loop panicked (shard_died={shard_died}); \
267+
"shard {index}: serve loop panicked (shard_died={shard_died}); \
225268
shard thread exiting"
226269
);
227270
std::panic::resume_unwind(panic);
@@ -233,29 +276,102 @@ mod tokio_bootstrap {
233276
Ok(ShardSet { shutdown, handles })
234277
}
235278

236-
async fn accept_loop<S, Fut>(
237-
listener: &tokio::net::TcpListener,
238-
runtime: &TokioRuntime,
279+
/// The single acceptor's loop: accept on the one listener and round-robin each
280+
/// connection to a shard's channel. Runs on a dedicated OS thread with NO tokio
281+
/// runtime (plain blocking `std` accept).
282+
///
283+
/// The listener is set non-blocking so the loop can observe the shutdown flag
284+
/// between polls instead of parking forever in `accept()` while no connection
285+
/// arrives: on `WouldBlock` it sleeps briefly (a 1ms poll, not a hot spin) and
286+
/// re-checks shutdown. On shutdown it stops accepting and returns, which drops
287+
/// every shard sender; each shard's `recv()` then observes channel-closed and
288+
/// proceeds to drain (SHUTDOWN.md).
289+
fn acceptor_loop(
290+
listener: &std::net::TcpListener,
291+
conn_senders: &[tokio::sync::mpsc::UnboundedSender<std::net::TcpStream>],
292+
shutdown: &Arc<AtomicBool>,
293+
) {
294+
// Non-blocking so a quiet listener cannot keep us from seeing shutdown.
295+
if let Err(e) = listener.set_nonblocking(true) {
296+
eprintln!("acceptor: set_nonblocking failed: {e}; shutdown may be delayed");
297+
}
298+
let poll = Duration::from_millis(1);
299+
let mut next: usize = 0;
300+
let n = conn_senders.len().max(1);
301+
while !shutdown.load(Ordering::Relaxed) {
302+
match listener.accept() {
303+
Ok((stream, _peer)) => {
304+
// Disable Nagle here so it is set regardless of which shard
305+
// adopts the socket; request/reply caches want low latency.
306+
let _ = stream.set_nodelay(true);
307+
// Round-robin to the next shard. A plain integer counter (NOT
308+
// rand): deterministic spread, no entropy needed.
309+
let target = next % n;
310+
next = next.wrapping_add(1);
311+
// If a shard thread is gone (its receiver dropped) the send
312+
// fails; skip that connection rather than crash the acceptor.
313+
if let Err(e) = conn_senders[target].send(stream) {
314+
eprintln!("acceptor: shard {target} channel closed: {e}");
315+
}
316+
}
317+
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
318+
// No pending connection: nap briefly, then re-check shutdown.
319+
std::thread::sleep(poll);
320+
}
321+
Err(e) => {
322+
// Transient accept errors (e.g. EMFILE) should not kill the
323+
// acceptor; back off briefly and continue.
324+
eprintln!("acceptor: accept error: {e}");
325+
std::thread::sleep(Duration::from_millis(10));
326+
}
327+
}
328+
}
329+
// Returning drops `conn_senders`, closing every shard channel so the shard
330+
// serve loops observe channel-closed and move on to drain.
331+
}
332+
333+
/// The shard's serve loop: instead of accepting, it AWAITS its connection
334+
/// channel for `std::net::TcpStream`s handed over by the acceptor, adopts each
335+
/// onto THIS shard's tokio reactor, and spawns `serve` per connection on the
336+
/// shard-local `LocalSet`. Runs concurrently with the drain loop on the same
337+
/// single-threaded executor.
338+
async fn serve_loop<S, Fut>(
339+
mut conn_rx: tokio::sync::mpsc::UnboundedReceiver<std::net::TcpStream>,
239340
serve: &S,
240341
shard: ShardId,
241342
shutdown: &Arc<AtomicBool>,
242343
) where
243344
S: Fn(TokioRuntime, tokio::net::TcpStream, ShardId) -> Fut + Clone + 'static,
244345
Fut: Future<Output = ()> + 'static,
245346
{
246-
let _ = runtime;
247347
// Core-local count of in-flight connection tasks, for the bounded drain.
248348
let live: LiveTasks = Rc::new(Cell::new(0));
249349

250350
while !shutdown.load(Ordering::Relaxed) {
251-
// Race the accept against a short timer so a shutdown is observed even
252-
// when no new connection arrives (no blocking accept that ignores the
253-
// flag).
351+
// Race the channel recv against a short timer so a shutdown is observed
352+
// even when no new connection arrives (the acceptor also closes the
353+
// channel on shutdown, which `recv()` reports as `None`).
254354
tokio::select! {
255-
res = listener.accept() => {
256-
match res {
257-
Ok((stream, _peer)) => {
258-
let _ = stream.set_nodelay(true);
355+
maybe = conn_rx.recv() => {
356+
match maybe {
357+
Some(std_stream) => {
358+
// Adopt the connection onto THIS shard's reactor: the
359+
// socket must be non-blocking for tokio, then `from_std`
360+
// registers it with this thread's runtime so all of its
361+
// I/O readiness lives on this core (ADR-0002). That
362+
// registration is the whole point of the userspace
363+
// hand-off: it distributes connections across cores.
364+
if let Err(e) = std_stream.set_nonblocking(true) {
365+
eprintln!("shard {}: set_nonblocking failed: {e}; dropping connection", shard.index);
366+
continue;
367+
}
368+
let stream = match tokio::net::TcpStream::from_std(std_stream) {
369+
Ok(s) => s,
370+
Err(e) => {
371+
eprintln!("shard {}: from_std failed: {e}; dropping connection", shard.index);
372+
continue;
373+
}
374+
};
259375
let fut = serve(TokioRuntime::new(), stream, shard);
260376
// Track this connection for the drain: bump the live
261377
// count, and decrement via a drop guard when the task
@@ -269,22 +385,21 @@ mod tokio_bootstrap {
269385
fut.await;
270386
});
271387
}
272-
Err(e) => {
273-
// Transient accept errors (e.g. EMFILE) should not kill
274-
// the shard; back off briefly and continue.
275-
eprintln!("shard {}: accept error: {e}", shard.index);
276-
tokio::time::sleep(Duration::from_millis(10)).await;
388+
None => {
389+
// The acceptor dropped its sender (shutdown). Stop taking
390+
// new connections and fall through to the drain.
391+
break;
277392
}
278393
}
279394
}
280395
() = tokio::time::sleep(Duration::from_millis(100)) => {}
281396
}
282397
}
283398

284-
// Shutdown observed: stop accepting (loop exited) and drain in-flight
285-
// connection tasks up to the grace deadline. We poll the live count on a
286-
// short tick rather than collecting JoinHandles, which keeps this O(1) in
287-
// bookkeeping and works with the fire-and-forget spawn_local model.
399+
// Shutdown observed: stop taking new connections (loop exited) and drain
400+
// in-flight connection tasks up to the grace deadline. We poll the live
401+
// count on a short tick rather than collecting JoinHandles, which keeps this
402+
// O(1) in bookkeeping and works with the fire-and-forget spawn_local model.
288403
drain_live_tasks(&live, shard).await;
289404
}
290405

crates/ironcache-runtime/src/lib.rs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@
1212
//! (RUNTIME_ABSTRACTION.md). PR-1 ships exactly one backend (tokio+epoll/kqueue);
1313
//! monoio/glommio are future Cargo features behind the same trait.
1414
//!
15-
//! 2. The [`bootstrap`] layer: spins up one OS thread per shard, each pinned to a
16-
//! core with its own current-thread tokio runtime and its own `SO_REUSEPORT`
17-
//! accept loop, so a connection lives its whole life on one core with no shared
18-
//! hot-path state. The multi-thread work-stealing scheduler is deliberately NOT
19-
//! used: work-stealing forces `Send + Sync` and re-introduces cross-core
20-
//! atomics, the opposite of shared-nothing (ADR-0002, RUNTIME.md).
15+
//! 2. The [`bootstrap`] layer: spins up one OS thread per shard, each with its own
16+
//! current-thread tokio runtime, plus a single acceptor thread that binds the
17+
//! one listening socket and round-robins accepted connections to the shards in
18+
//! userspace (portable load-balancing; kernel `SO_REUSEPORT` does not balance on
19+
//! macOS/BSD). A connection lives its whole life on the shard that adopts it,
20+
//! with no shared hot-path state. The multi-thread work-stealing scheduler is
21+
//! deliberately NOT used: work-stealing forces `Send + Sync` and re-introduces
22+
//! cross-core atomics, the opposite of shared-nothing (ADR-0002, RUNTIME.md).
2123
//!
2224
//! ## Freeze point
2325
//!

0 commit comments

Comments
 (0)