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 {
3247pub 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 {
93109mod 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
0 commit comments