socketengine: plaintext reactor pool — an acceptor round-robins connections across N worker reactors (io_threads, auto=cores) feeding the single lock-free core, so per-connection I/O scales across cores

This commit is contained in:
Jean Chevronnet 2026-08-12 13:42:08 +00:00
parent 4ba8d5bce6
commit 7c166e3aae
2 changed files with 207 additions and 93 deletions

View file

@ -58,6 +58,8 @@ fn main() {
}; };
let max_line = raw_num("max_line", socketengine::DEFAULT_MAX_LINE); let max_line = raw_num("max_line", socketengine::DEFAULT_MAX_LINE);
let max_sendq = raw_num("max_sendq", socketengine::DEFAULT_MAX_SENDQ); let max_sendq = raw_num("max_sendq", socketengine::DEFAULT_MAX_SENDQ);
// plaintext reactor-pool size (0 = auto: one worker per core, capped)
let io_threads = raw_num("io_threads", 0);
// trusted PROXY-protocol source globs (reactor rewrites the client IP from them) // trusted PROXY-protocol source globs (reactor rewrites the client IP from them)
let proxy_trust: Vec<String> = cfg.raw.get("proxy").cloned().unwrap_or_default(); let proxy_trust: Vec<String> = cfg.raw.get("proxy").cloned().unwrap_or_default();
@ -163,7 +165,15 @@ fn main() {
// client plaintext connections: one mio reactor thread drives them all // client plaintext connections: one mio reactor thread drives them all
thread::spawn(move || { thread::spawn(move || {
socketengine::run_reactor(client_listener, tx, counter, max_line, max_sendq, proxy_trust) socketengine::run_reactor_pool(
client_listener,
tx,
counter,
max_line,
max_sendq,
proxy_trust,
io_threads,
)
}); });
let _ = core.join(); let _ = core.join();
} }

View file

@ -1,9 +1,11 @@
//! The socket engine: the I/O edge. Two coexisting models feed the one core: //! The socket engine: the I/O edge. Two coexisting models feed the one core:
//! //!
//! - **Client plaintext** connections run on a single **mio epoll reactor** //! - **Client plaintext** connections run on a **pool of mio epoll reactors**
//! ([`run_reactor`]) — one thread drives tens of thousands of sockets, so the //! ([`run_reactor_pool`]) — one acceptor round-robins connections across N worker
//! daemon scales to ~50k users without a thread per connection. The core stays //! threads (one per core by default), each driving tens of thousands of sockets, so
//! single-threaded and there is no async runtime. //! the daemon scales to hundreds of thousands of users without a thread per
//! connection. The state core stays single-threaded and there is no async runtime;
//! workers only frame lines and feed it Events, so the parallel I/O needs no locks.
//! - **TLS** and **server links** keep a thread per connection (few of them, and //! - **TLS** and **server links** keep a thread per connection (few of them, and
//! a TLS session can't be split across reader+writer threads). //! a TLS session can't be split across reader+writer threads).
//! //!
@ -166,23 +168,70 @@ fn set_interest(poll: &mut Poll, c: &mut Conn, t: usize) {
let _ = poll.registry().reregister(&mut c.stream, Token(t), interest); let _ = poll.registry().reregister(&mut c.stream, Token(t), interest);
} }
/// Run the client plaintext reactor on this thread. `listener` is an already-bound
/// mio listener (bound in `main` so a bind failure is fatal and fails fast).
/// Largest PROXY header we'll buffer before giving up (v1 ≤ 107, v2 header ≤ ~232). /// Largest PROXY header we'll buffer before giving up (v1 ≤ 107, v2 header ≤ ~232).
const PROXY_MAX: usize = 256; const PROXY_MAX: usize = 256;
pub fn run_reactor( /// A freshly accepted client the acceptor hands to a reactor worker to adopt.
struct Accepted {
stream: MioStream,
uid: Uid,
addr: SocketAddr,
local_port: u16,
via_proxy: bool,
}
/// The acceptor's handle to one reactor worker: its handoff queue and the waker that
/// nudges the worker to adopt whatever was queued.
struct ReactorHandle {
handoff: Sender<Accepted>,
waker: Arc<Waker>,
}
/// Resolve the reactor-pool size. An explicit `io_threads` wins; 0 means auto — one
/// worker per CPU, floored at 1 and capped at 4 so a many-core box doesn't over-thread
/// the plaintext path (set `io_threads` explicitly to raise it).
fn resolve_io_threads(io_threads: usize) -> usize {
if io_threads > 0 {
return io_threads;
}
thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
.clamp(1, 4)
}
/// Drive the client plaintext listener with a pool of reactor threads. One acceptor
/// (this thread) owns the listener and round-robins each new connection to a worker;
/// each worker runs its own poll and connection map on its own core. The state core
/// stays single-threaded — workers only frame lines and feed it Events — so the
/// per-connection I/O scales across cores with no shared locking.
pub fn run_reactor_pool(
mut listener: MioListener, mut listener: MioListener,
core: Sender<Event>, core: Sender<Event>,
counter: Arc<AtomicU64>, counter: Arc<AtomicU64>,
max_line: usize, max_line: usize,
max_sendq: usize, max_sendq: usize,
proxy_trust: Vec<String>, proxy_trust: Vec<String>,
io_threads: usize,
) { ) {
let workers = resolve_io_threads(io_threads);
let mut reactors: Vec<ReactorHandle> = Vec::with_capacity(workers);
for _ in 0..workers {
match spawn_reactor(core.clone(), max_line, max_sendq) {
Ok(h) => reactors.push(h),
Err(e) => eprintln!("reactor: cannot start a worker: {e}"),
}
}
if reactors.is_empty() {
eprintln!("reactor: no worker threads started; plaintext clients disabled");
return;
}
eprintln!("echoircd plaintext reactor pool: {} thread(s)", reactors.len());
let mut poll = match Poll::new() { let mut poll = match Poll::new() {
Ok(p) => p, Ok(p) => p,
Err(e) => { Err(e) => {
eprintln!("reactor: cannot create poll: {e}"); eprintln!("acceptor: cannot create poll: {e}");
return; return;
} }
}; };
@ -191,18 +240,88 @@ pub fn run_reactor(
.register(&mut listener, LISTENER, Interest::READABLE) .register(&mut listener, LISTENER, Interest::READABLE)
.is_err() .is_err()
{ {
eprintln!("reactor: cannot register listener"); eprintln!("acceptor: cannot register listener");
return; return;
} }
let waker = match Waker::new(poll.registry(), WAKE) { let mut events = Events::with_capacity(64);
Ok(w) => Arc::new(w), let mut rr: usize = 0;
Err(e) => { loop {
eprintln!("reactor: cannot create waker: {e}"); if poll.poll(&mut events, None).is_err() {
return; continue;
} }
// the listener is the only source registered here, so drain the accept queue
for _ in events.iter() {
loop {
match listener.accept() {
Ok((stream, _addr)) => {
let _ = stream.set_nodelay(true);
let uid = counter.fetch_add(1, Ordering::Relaxed);
let addr = stream
.peer_addr()
.unwrap_or_else(|_| "0.0.0.0:0".parse().unwrap());
let local_port = stream.local_addr().map(|a| a.port()).unwrap_or(0);
// a connection from a trusted proxy leads with a PROXY header;
// the worker holds its Connect until that header is consumed so
// the core sees the real client IP.
let via_proxy = proxy_trust.iter().any(|g| {
crate::modules::connclass::ip_matches(g, &addr.ip().to_string())
});
let idx = rr % reactors.len();
rr = rr.wrapping_add(1);
let accepted = Accepted {
stream,
uid,
addr,
local_port,
via_proxy,
}; };
let (out_tx, out_rx) = mpsc::channel::<Out>(); if reactors[idx].handoff.send(accepted).is_ok() {
let _ = reactors[idx].waker.wake();
}
}
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => break,
Err(_) => break,
}
}
}
}
}
/// Spawn one reactor worker and return the acceptor's handle to it. The worker owns
/// its poll, connection map, and token space; tokens are reactor-local (each write is
/// routed to the owning worker by the `out_tx` baked into that connection's OutSink),
/// while uids come from the shared counter and stay globally unique.
fn spawn_reactor(
core: Sender<Event>,
max_line: usize,
max_sendq: usize,
) -> io::Result<ReactorHandle> {
let poll = Poll::new()?;
let waker = Arc::new(Waker::new(poll.registry(), WAKE)?);
let (handoff_tx, handoff_rx) = mpsc::channel::<Accepted>();
let (out_tx, out_rx) = mpsc::channel::<Out>();
let handle = ReactorHandle {
handoff: handoff_tx,
waker: waker.clone(),
};
thread::spawn(move || {
reactor_loop(poll, waker, handoff_rx, out_tx, out_rx, core, max_line, max_sendq)
});
Ok(handle)
}
/// One reactor worker's event loop: adopt handed-off connections, drive their reads
/// and writes, and apply the output the core queued for them.
fn reactor_loop(
mut poll: Poll,
waker: Arc<Waker>,
handoff_rx: Receiver<Accepted>,
out_tx: Sender<Out>,
out_rx: Receiver<Out>,
core: Sender<Event>,
max_line: usize,
max_sendq: usize,
) {
let mut conns: HashMap<usize, Conn> = HashMap::new(); let mut conns: HashMap<usize, Conn> = HashMap::new();
let mut next_token = FIRST_CONN; let mut next_token = FIRST_CONN;
let mut events = Events::with_capacity(1024); let mut events = Events::with_capacity(1024);
@ -213,12 +332,13 @@ pub fn run_reactor(
} }
for event in events.iter() { for event in events.iter() {
match event.token() { match event.token() {
LISTENER => loop { WAKE => {
match listener.accept() { // first adopt any connections the acceptor handed us, then apply
Ok((mut stream, _addr)) => { // the output the core queued and flush the connections it touched.
let _ = stream.set_nodelay(true); while let Ok(a) = handoff_rx.try_recv() {
let token = next_token; let token = next_token;
next_token += 1; next_token += 1;
let mut stream = a.stream;
if poll if poll
.registry() .registry()
.register(&mut stream, Token(token), Interest::READABLE) .register(&mut stream, Token(token), Interest::READABLE)
@ -226,17 +346,6 @@ pub fn run_reactor(
{ {
continue; continue;
} }
let uid = counter.fetch_add(1, Ordering::Relaxed);
let addr = stream
.peer_addr()
.unwrap_or_else(|_| "0.0.0.0:0".parse().unwrap());
let local_port = stream.local_addr().map(|a| a.port()).unwrap_or(0);
// a connection from a trusted proxy leads with a PROXY
// header; hold the Connect event until it's consumed so
// add_conn sees the real client IP.
let via_proxy = proxy_trust
.iter()
.any(|g| crate::modules::connclass::ip_matches(g, &addr.ip().to_string()));
let out = OutSink::Reactor { let out = OutSink::Reactor {
token, token,
tx: out_tx.clone(), tx: out_tx.clone(),
@ -246,9 +355,9 @@ pub fn run_reactor(
token, token,
Conn { Conn {
stream, stream,
uid, uid: a.uid,
addr, addr: a.addr,
local_port, local_port: a.local_port,
rbuf: Vec::new(), rbuf: Vec::new(),
wbuf: Vec::new(), wbuf: Vec::new(),
wpos: 0, wpos: 0,
@ -259,24 +368,24 @@ pub fn run_reactor(
recvq: max_line, recvq: max_line,
hardsendq: max_sendq, hardsendq: max_sendq,
softsendq: max_sendq, softsendq: max_sendq,
proxy_pending: via_proxy, proxy_pending: a.via_proxy,
pending_out: Some(out), pending_out: Some(out),
}, },
); );
// non-proxy: announce the connection immediately (a proxy // non-proxy: announce immediately (a proxy conn is announced
// one is announced from read_conn once its header lands) // from read_conn once its header lands)
if !via_proxy { if !a.via_proxy {
let out = conns.get_mut(&token).and_then(|c| c.pending_out.take()); let out = conns.get_mut(&token).and_then(|c| c.pending_out.take());
if let Some(out) = out { if let Some(out) = out {
if core if core
.send(Event::Connect { .send(Event::Connect {
uid, uid: a.uid,
addr, addr: a.addr,
out, out,
sock: None, sock: None,
secure: false, secure: false,
certfp: None, certfp: None,
local_port, local_port: a.local_port,
link: false, link: false,
outbound: false, outbound: false,
websocket: false, websocket: false,
@ -288,11 +397,6 @@ pub fn run_reactor(
} }
} }
} }
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => break,
Err(_) => break,
}
},
WAKE => {
// drain everything the core queued, then flush the touched conns // drain everything the core queued, then flush the touched conns
let mut touched: HashSet<usize> = HashSet::new(); let mut touched: HashSet<usize> = HashSet::new();
while let Ok(msg) = out_rx.try_recv() { while let Ok(msg) = out_rx.try_recv() {