Most TCP server implementations intertwine protocol handling with their chosen
I/O model: blocking sockets call directly into request handlers, event loops
embed protocol parsing inside readiness callbacks, and io_uring
implementations often duplicate the entire connection pipeline around completion
events. That coupling makes it difficult to compare concurrency models fairly,
because changing the I/O mechanism also changes the application logic.
This repository was designed to isolate exactly one variable: how bytes move between the kernel and the application. Every other part of the server—the HTTP parser, routing, response generation, keep-alive handling, timeout logic, and connection state machine—remains identical.
To achieve that, the project is organized around a frozen sans-IO
core::Connection state machine consumed unchanged by eleven independent TCP
server implementations, ranging from a simple blocking accept loop to a
purpose-built io_uring completion engine.
This document explains the architecture, the contracts between layers, and the design decisions that allow one protocol implementation to drive every concurrency model without modification.
The architecture was designed around four goals.
-
Separate protocol logic completely from operating-system I/O.
-
Allow every concurrency model to reuse exactly the same HTTP implementation.
-
Keep operating-system mechanisms explicit rather than hiding them behind abstractions so each benchmark measures the intended kernel interface.
-
Make adding a new concurrency model require implementing only the transport layer rather than duplicating protocol code.
Four layers, dependencies pointing downward only. The sans-IO boundary is the line above which no code touches a socket.
┌──────────────────────────────────────────────┐
models/ │ iterative forking preforked thread-per- │
(one strategy each, │ conn thread-pool poll epoll-lt epoll-et │
each impl Server) │ event-loop multireactor io-uring │
└───────────────┬───────────────┬───────────────┘
│ │
(event-loop, │ │ (blocking models,
multireactor)│ │ io-uring use sys
▼ │ directly)
reactor.rs ┌──────────────────────────┐ │
(epoll-ET assembly) │ Reactor: epoll loop + │ │
│ ConnTable, drives core │ │
└───────────────┬───────────┘ │
│ │
▼ ▼
sys/ ┌──────────────────────────────────────────────┐
(raw OS I/O) │ socket poll epoll affinity signal │
│ conn_table syscall — every syscall here │
└───────────────┬──────────────────────────────┘
│
─────────────────────────────────── │ ──── SANS-IO BOUNDARY ─────────
nothing below touches a socket ▼
core/ ┌──────────────────────────────────────────────┐
(sans-IO protocol) │ http::{request,response} router asset app │
FROZEN │ Connection (state machine) + ConnAction │
│ Server trait, ServerConfig, bind_listener │
└──────────────────────────────────────────────┘
core depends on nothing in the project. sys depends on libc and core's
types but performs no protocol logic. reactor assembles sys primitives and
drives a core::Connection. Each models/ module selects one concurrency
strategy and reuses everything beneath it. No layer reaches upward.
- Owns: the HTTP request parser (
http::request), response encoder (http::response), trie router (router), in-memory asset cache (asset), the per-connectionConnectionstate machine and itsConnActioncontract (conn), theServertrait andServerConfig(server), bounded-input ceilings (limits), and atomic metrics (metrics). - Must never: call
read,write,accept,epoll, or any other per-connection syscall. It operates only on byte buffers in memory and the monotonic clock (for deadlines). It is frozen — its public contract and behavior do not change. - Public surface: re-exported from
core::lib. The model-facing pieces areConnection,ConnAction,App,Server,ServerConfig, and the setup-only helperbind_listener. - The one sanctioned exception is
bind_listener(server.rs): a setup helper that creates and binds a listening socket viasocket2, including theSO_REUSEPORTpath forpreforkedandmultireactor. Binding andlisten()are connection-setup, not per-connection I/O; noread,write,accept, orepolllives incore.
- Owns: thin
libcwrappers —socket(non-blocking sockets,SO_REUSEPORT),poll,epoll(level- and edge-triggered),affinity(CPU pinning),signal(SIGINT/SIGTERM shutdown),conn_table(fd→connection slab), andsyscall(retry/EINTRhelpers). Every syscall in the project lives here. - Must never: contain protocol or HTTP logic, and must not hide the semantic
differences between mechanisms —
poll,epoll-lt, andepoll-etstay distinct so the models can measure their difference.sysremoves copy-pasted FFI and fd bookkeeping, nothing more. - Public surface: one module per primitive (
affinity,conn_table,epoll,poll,signal,socket,syscall).
- Owns: the epoll-ET readiness loop: an
epollinstance plus aConnTable, arming/disarming interest from eachConnAction, draining each socket toEAGAIN, managingEPOLLOUTfor partial writes, and driving thecore::Connectionfor every fd.Reactor::newbuilds it;Reactor::runruns it until the shared shutdown flag is set. - Must never: select a model or own a concurrency policy. It is one reusable
reactor; the
event-loopmodel runs one of it on one thread, and eachmultireactorworker runs its own pinned instance over its ownSO_REUSEPORTlistener. - Public surface:
Reactor,Reactor::new(...),Reactor::run(&shutdown, &app).
- Owns: one concurrency/I-O strategy per module, each implementing
core::Server. The blocking models share one serve loop (blocking.rs); the event-loop models share thereactor. A model owns only how readiness or completion is obtained and how work is dispatched. - Must never: re-implement HTTP handling or copy-paste the serve loop. The
only thing that varies between
iterativeandio-uringis the I/O strategy, not the protocol — that is what makes the benchmark a controlled comparison. - Public surface: each model is a struct implementing
Server;main.rsdispatches on the--modelname.
A blocking accept-loop model and an io_uring completion model have nothing in
common at the I/O layer: one waits on a blocking read, the other reaps
completions from a ring. The only way one core can serve all eleven models
without modification is if core never touches a socket — so the protocol logic
consumes and produces byte buffers and reports intent (WantRead / WantWrite
/ Close), and the model performs the actual syscalls.
The concrete payoff is that blocking read/write, epoll readiness, and
io_uring completion all reuse one Connection state machine. The legacy repo
coupled parsing to TcpStream (route_client(stream)), which is precisely why
it could not extend past a blocking loop. core reintroduces no such coupling,
and the benchmark confirms the payoff three ways: the same machine drove blocking
I/O, epoll readiness, and io_uring completion, unmodified
(docs/BENCHMARKS.md §7).
The central architectural claim of this project is that changing the I/O model never required changing protocol code.
Throughout development the core crate remained frozen while eleven server
implementations were added above it. Every model below consumes the same
core::Connection; only the mechanism that delivers bytes differs.
| Model | I/O mechanism | Consumes unmodified core::Connection? |
|---|---|---|
| iterative | Blocking serve loop, one connection at a time | Y |
| forking | Blocking serve loop in a per-connection fork() child |
Y |
| preforked | Blocking serve loop in a fixed SO_REUSEPORT worker pool |
Y |
| thread-per-conn | Blocking serve loop on a per-connection OS thread | Y |
| thread-pool | Blocking serve loop on a bounded worker pool | Y |
| poll | poll(2) readiness, non-blocking sockets |
Y |
| epoll-lt | Level-triggered epoll readiness |
Y |
| epoll-et | Edge-triggered epoll readiness, drain to EAGAIN |
Y |
| event-loop | epoll-ET via the reusable reactor |
Y |
| multireactor | Pinned per-core reactor, SO_REUSEPORT, shared-nothing |
Y |
| io-uring | Single-ring completion: multishot accept, provided buffers | Y |
All eleven rows are Y. The blocking models drive Connection through the shared
serve loop, the readiness models through their epoll/poll loops, the event-loop
and multireactor models through the reactor, and io-uring feeds completions
into the identical on_bytes / on_written contract.
core::Connection is the per-connection state machine every model drives. It is
sans-IO: the model performs all reads and writes; the connection consumes and
produces byte buffers and reports the next action via ConnAction.
model: accept() ─▶ Connection::new(read_timeout) [Reading]
│
├─ read bytes ───▶ conn.on_bytes(&buf, &app) ──▶ ConnAction
│ RequestParser.push(bytes)
│ Incomplete ─▶ WantRead [Reading]
│ Complete ─▶ App::handle ─▶ encode ─▶ WantWrite [Writing]
│ Failed ─▶ error Response ─▶ encode ─▶ WantWrite then Close
│
├─ write bytes ──▶ conn.on_written(n) ──▶ ConnAction
│ drained + keep-alive ─▶ WantRead (deadline refreshed) [KeepAlive→Reading]
│ drained + close ─▶ Close [Close]
│
└─ timer tick ───▶ conn.is_expired(now) ─▶ Close if true
ConnAction |
Meaning for the model |
|---|---|
WantRead |
Wait for readability, then call on_bytes(&buf, &app). |
WantWrite |
Write pending_write(), then report progress via on_written(n). |
Close |
Close the fd and drop the Connection. |
The contracts the state machine guarantees:
- Sans-IO. No syscalls; it reads only the monotonic clock for deadlines.
- In-connection error responses. On a parse
Failed,on_bytesbuilds the error response (status fromParseError::status()), encodes it withkeep_alive = false, and the post-write action isClose. The model never sees the error — it getsWantWritethenClose. This is the one-place fix for the legacy "one bad request kills the server" bug: an error is a normal response followed by a close, not a propagated?. - HEAD handling. A
HEADresponse is encoded withinclude_body = false— correct headers andContent-Length, no body bytes. - Keep-alive + deadline refresh. When
on_writtenfully drains the response and the request wanted keep-alive, the connection resets its parser, returns toReading, and refreshes the read deadline. Bytes that arrived past the current request are retained byRequestParser::resetso a pipelined request is not lost. - Timeouts.
is_expired(now)lets event-loop models drop a stalled connection on a timer tick; blocking models additionally rely on socket read/write timeouts. A slow client never pins a worker forever.
Each decision states the alternative it rejected and the tradeoff accepted.
-
No async runtime / no tokio. Rejected: building the models on
tokioorasync-std. Reason: the project measures I/O mechanisms — blocking,poll, level- and edge-triggeredepoll,io_uringcompletion — and an async runtime would hide exactly the mechanism under study behind its own scheduler and reactor.io_uringuses the rawio-uringcrate, nevertokio-uring, for the same reason. Tradeoff accepted: more hand-written event-loop and lifetime code, in exchange for a controlled, mechanism-level comparison. -
Shared-nothing
SO_REUSEPORTover single-acceptor + fd handoff. The kickoff brief sketched "one acceptor + N reactors." Rejected: a shared acceptor thread that accepts and hands fds to reactors over a queue. Reason:SO_REUSEPORTlets the kernel 4-tuple-hash connections directly to a per-reactor listener, removing the acceptor, the fd-handoff queue, and the only shared hot-path state. Tradeoff accepted: the kernel's hash balancing has no work-stealing, so skewed connection lifetimes can imbalance reactors (docs/BENCHMARKS.md§7, themultireactorcaveat) — accepted because the benchmark confirms zero shared-state contention and the best C10K median of any model, p50 = 70 µs (bench/results/c10k_multireactor.csv), at 1.002 ctx-switches/req on disjoint cores (bench/results/profiles/summary.csv). -
Vecheader store overHashMap. Rejected: aHashMapkeyed by header name. Reason: requests carry a handful of headers; a small linear-scannedVecavoids per-request hashing and allocation and is faster at that size. Tradeoff accepted: O(n) header lookup, which is cheaper than hashing for the realistic header count and within the boundedlimits.rsceiling. -
Provided buffer rings over per-read allocation (io-uring). Rejected: posting a freshly allocated buffer with each read SQE. Reason: a provided buffer ring lets the kernel select the read buffer and report it in the CQE, which — with multishot accept — removes the per-accept and per-read submission syscalls and is what drives
io_uringto 2.021 syscalls/req against epoll-et's 4.028 (bench/results/profiles/summary.csv). Tradeoff accepted: ring/buffer bookkeeping and tighter coupling to the kernel ABI, in exchange for the syscall-elimination the model exists to demonstrate. -
Single-ring, single-thread io-uring scope. Rejected: thread-per-core, multi-ring
io_uring(the production form). Reason: a single ring on a single thread isolates syscall-elimination from core count, making the fair comparison single-ringio_uringvs single-threadepoll-et. Tradeoff accepted: thisio_uringuses one core, so absolute-throughput leadership belongs tomultireactoron N cores (docs/BENCHMARKS.md§8). On EPYC the single ring sustains a true C10K without shedding, but the AMD Zen4 pipeline data shows its halved syscall count does not out-execute epoll-ET (0.76 vs 1.20 retired ops/cyc at C10K,bench/results/profiles/perf_io-uring_c10k.txt) — the syscall result it isolates holds on the apples-to-apples axis; the pipeline win does not follow on this frontend-latency-bound workload. Multi-ringio_uringis noted as future work, not built. -
Bounded inputs enforced in the parser. Rejected: trusting client input sizes. Reason: every ceiling in
limits.rs(request-line, header, body, version) is enforced by the parser and mapped to an HTTP status (414,431,413,505), so an attacker cannot make the parser buffer unbounded input. Tradeoff accepted: a fixed refusal point on oversized but legitimate inputs, in exchange for slow-loris and memory-DoS resistance.
The benchmark results reported elsewhere in the repository are a direct consequence of this architecture. Because every model shares the same protocol implementation, differences in throughput, latency, syscall count, and pipeline utilization can be attributed to the concurrency mechanism rather than changes in application logic.
This separation is the primary contribution of the project: eleven TCP server architectures evaluated under one unchanged HTTP implementation.