Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ergon-io

Network I/O for an exchange client on one busy-spinning thread: WebSocket and HTTP/1.1 over TLS 1.3.

  • The driver is Linux-only; the protocol code builds everywhere.
  • Written with heavy AI assistance (disclosure). Not production-ready.
cargo add ergon-io --features webpki-roots
  • Features: uring (on by default) adds the io_uring backends; webpki-roots adds Mozilla’s root store (tls::webpki_roots); curl adds libcurl as an HttpClient implementation.
  • API reference: docs.rs/ergon-io.
  • Every measured figure, with its max and its data file: io/benches/BENCHMARK.md (its first table answers the common questions).

Glossary

Terms this chapter uses. Each links to the section that explains it.

termwhat it ispros / cons, when it matters
busy-spin threadA thread that loops on its own core and never sleeps.Pro: no wake-up delay. Con: uses a whole core all the time.
pollOne non-blocking pass of Driver::poll: receive, decrypt, decode, deliver, send.A message can wait up to one whole poll for its turn.
isolated coreA CPU the kernel keeps other work off. The rest are housekeeping CPUs.Pin the loop to one. Helper threads (connect_background) run on housekeeping CPUs — in a pod, on its other CPUs (Kubernetes).
hyperthread (SMT) siblingThe other logical CPU on the same physical core.Can host the SQPOLL thread, at a cost in p50 and p99 against a core of its own (data).
cpusetThe CPUs a process may run on: its cgroup’s cpuset.cpus.effective. A pod’s is what the kubelet gave it.Helper threads and SQPOLL must land inside it; the kernel refuses a mask outside (EINVAL).
static CPU managerThe kubelet policy that gives a Guaranteed pod whole CPUs of its own.What makes a second CPU the loop does not share possible; x86 with SMT: also full-pcpus-only.
GravitonAWS’s Arm CPUs.Each vCPU is a whole core, so there is no hyperthread sibling. The fair backend comparison ran on it.
SCHED_FIFOReal-time scheduling: the thread runs ahead of every normal one.One A/B for the loop: no consistent change (data).
syscallA call from the program into the kernel.Each costs CPU. uring-spin makes far fewer than epoll-spin at the same message rate.
interruptThe NIC’s signal to the CPU that packets arrived.epoll-spin, uring-spin and mio wait for it. Compared fairly with NIC polling on Graviton only.
NIC polling (busy polling)The reading thread checks the NIC queue itself instead of waiting for an interrupt.Pro: skips the interrupt path. Con: CPU; plain recv-busy pays it per socket.
NAPIThe kernel code that takes packets off a NIC queue; busy polling calls it directly.uring-sqpoll and uring-napi register their queues with it: Linux 6.9+.
NIC queueOne of the NIC’s receive queues (c7i.2xlarge: 8); each connection lands on one.per_queue and the SQPOLL thread poll queues, not sockets, so their cost stops growing with sockets.
adaptive RXENA (the AWS NIC) re-tunes how long it delays interrupts to batch packets (adaptive receive moderation).On by default. Set on the node: ethtool -C <nic> adaptive-rx off.
backendHow the Driver waits for and reads data; set in config: Config::parse("recv-busy").Pick by socket count and spare CPUs: choosing a backend.
epoll-spinAsks epoll (the kernel’s list of ready sockets) every poll, without waiting; reads only those.Pro: a cheap poll even at 256 sockets. Con: waits for the interrupt.
mio, tokioRust’s usual async I/O. On Linux both use epoll, so they wait for the interrupt.reqwest on tokio parks its thread between requests.
recv-busyReads every socket every poll; SO_BUSY_POLL makes each read poll the NIC too.The pick at 1–4 sockets. Each socket adds to every poll.
per_queueA recv-busy option: one read per NIC queue, then only the ready sockets.Poll cost stops at the queue count. The one-CPU pick past 4 sockets.
io_uringA Linux interface: requests and results pass through rings shared with the kernel.Needs feature uring (on by default) and Linux 6.1+ (6.9+ for NAPI).
uring-spinio_uring with DEFER_TASKRUN: enters the kernel only when it flags work.Pro: few syscalls, low CPU. Con: waits for the interrupt, like epoll-spin.
uring-sqpollA kernel thread (SQPOLL) spins for the ring: submits, completes, polls the NIC.No per-socket scan, so its cost does not grow with sockets. Needs a second CPU or the loop’s sibling.
uring-napi, epoll-busyWait in the kernel up to wait_us (100 µs): poll the NIC up to busy_poll_us (50 µs), then sleep.No run shows them saving CPU.
crossoverThe socket count where one backend stops beating another.Depends on the NIC, its queue count, the interrupt path and the traffic: measure it on your host.
TCP_NODELAYTurns off Nagle’s algorithm, which holds a small send until earlier data is ACKed.On for every socket: each send goes out at once.
quick ACKThe kernel ACKs at once instead of delaying (tcp(7), TCP_QUICKACK).A peer using Nagle’s algorithm waits for our ACK before its next small send. Effect not measured here.
rto_minThe shortest time TCP waits before resending lost data; a route attribute (ip-route(8)).The lab routes set 5 ms; effect not measured here.
TCP keepaliveThe kernel probes an idle connection to find a dead peer.Default: 10 s idle, then every 5 s, 3 probes. One kernel timer per socket; WebSocket Pings already do this.
TCP_USER_TIMEOUTHow long sent data may stay unacknowledged before the kernel drops the connection.25 s here; the kernel default is about 15 minutes.
TLS 1.3The encryption on every connection.TLS 1.2 is refused with a clear error.
TLS handshakeThe key exchange when a connection opens.Makes one poll of the connecting thread long (data). Keep it off the loop: connect_background.
TLS recordTLS’s unit of encrypted data.A native HTTP request is one record; a large WebSocket message goes out over several.
in placeDecrypted and parsed in the buffer the kernel wrote into: one copy per byte.Pro: no extra copy. Con: the slice lives only during the call; copy what you keep.
WebSocketA two-way message stream over one TCP connection, opened by an HTTP upgrade (status 101).No compression here: it would break in-place parsing.
frameWebSocket’s unit on the wire; a message is one frame or several (fragmented).Every frame must fit buf_size (1 MiB). Fragmented messages are received, never sent.
Ping / PongControl frames that check the other side is alive.Ping after 10 s with nothing received; nothing back in 5 s: timeout. Any byte received counts as the answer.
close handshakeEach side sends a Close frame, then TCP closes.Waits up to 1 s (close_timeout_ms). Driver::close skips it.
HTTP/1.1 keep-aliveOne connection reused for many requests.No handshake per request. One request in flight per connection; a timed-out request closes its connection.
connection poolThe warm keep-alive connections an HttpClient keeps.All busy: Error::Busy. Each one is one more socket the loop polls.
libcurlThe common C HTTP library: HttpImpl::Curl (feature curl).Pro: redirects, proxies, HTTP/2, compression. Con: slower than native at the median on the same interrupt path.
rate limitA venue’s cap on requests, orders or connects per window: per IP, account, instrument or connection.Break it: refused (429). Keep going: your IP is banned.
budget, bucketlimit::Budget: one bucket per venue limit, checked before each request.Never sleeps or queues: a refusal says when to try again. Sees only this process.
rolling windowA count of what went out in the last window, kept in sub-windows.Safe whatever window edges the venue uses; costs at most one sub-window of budget.
draining counter (GCRA)A level that drains at a fixed rate (Kraken, Deribit).Wrong for window limits: it lets twice the limit through in one window.
reserveUnits only cancels and reduce-only orders may use.Keeps cancels possible when a bucket is nearly full; not during a hold.
hold, haltHold: a bucket refuses everything until the venue’s wait ends (a 429). Halt: until a person clears it (a ban).A halt never ends by itself: alert on Budget::halted().
verdictWhat became of a request: done, rejected, not run, throttled, banned, clock skew, unknown.Only the “did not run” kinds may be sent again, inside their deadline.
Sent::No, Sent::MaybeFor a request with no answer: it never left, or it may have run.Only the transport knows. Maybe: look the order up.
client order idYour unique id on each order.The only way to find out what became of an order with an unknown outcome.
connect gateA bucket every connect attempt must pass.Keeps 50 links reconnecting at once inside a venue’s connect limit.
round tripRequest sent to answer received, on one clock.x86: 1 connection. Graviton: 1–64 sockets on the loop and an HTTP pool of 20.
p50 / p99 / maxpN: N % of samples are at or below it (p50: the median). max: the worst.In µs. Every figure in BENCHMARK.md keeps its max.
kernel arrival timestampWhen the kernel stamped a packet’s arrival; in RxMeta, with the NIC’s stamp where it has one.Starts arrival → decoded. Key rx_timestamps, on by default.
arrival → decodedKernel arrival timestamp to decoded message, on the receiver’s clock only.Leaves out the interrupt wait and the wait for the next queue poll: compares polling backends only.
whole pathOne-way time, sender → decoded, on two hosts’ clocks.Includes everything, but the clocks drift: compare backends within one round only.

When to use it

Use it whenUse something else when
one thread owns 1 to a few hundred exchange connections and spins on its own corethe thread may sleep: tokio
you want one copy per byte: TLS records decrypted and messages parsed in placeyou need HTTP/2 or 3, proxies, redirects, cookies or compression: libcurl or reqwest
Linux on x86-64 or Graviton (both measured)macOS or Windows in production

How it works

  • Driver<E> owns the connections. Driver::poll makes one non-blocking pass (receive, decrypt, decode, deliver, send) and returns.
  • Your handler gets Handler::on_open, on_closed, and the protocol’s callback, such as WsHandler::on_message. A payload is a slice of the receive buffer, valid during the call.
  • One failure closes one connection. It goes to on_closed; the others keep running. poll fails only when the backend fails.
  • Resources are owned. A socket closes when its connection drops. Dropping a Driver cancels in-flight io_uring work before freeing its buffers.

Pages: WebSocket (a complete client), HTTP client, Backends and configuration, Rate limits and errors, Kubernetes (where the helper threads run inside a pod).

Choosing a backend

The backend is configuration: Config::parse("recv-busy"). Count every socket the loop thread polls: WebSocket feeds, order connections and every HttpClient pool connection (each Driver and HttpClient on the thread adds its own). Then pick by what CPUs you have:

sockets on the loop threadone CPUa spare CPU for the SQPOLL thread (or the loop’s sibling)the NIC’s interrupts on a core of their own (node-level)
1–4recv-busyrecv-busy or uring-sqpollrecv-busy
5–64recv-busy,per_queue=trueuring-sqpolluring-sqpoll, epoll-spin or uring-spin
65–256recv-busy,per_queue=trueuring-sqpolluring-sqpoll (interrupt backends not measured past 64)

Evidence: fair run, Graviton (all six backends, 1–64 sockets, HTTP pool of 1 and 20) and polling backends, x86 (1–256 connections).

  • Plain recv-busy reads every socket on every poll. Its poll grows with sockets, so a message waits longer for its socket’s turn. It leads at 1 socket and falls behind from about 16.
  • per_queue reads one socket per NIC queue, then the ready ones. Its poll stops growing at the queue count. It trails uring-sqpoll at 16–64 sockets. Not the default: set it when the count can pass 4.
  • The interrupt backends (epoll-spin, uring-spin, mio) matched uring-sqpoll from 4 sockets in the fair run, which had moderation off and their interrupts on a core of their own. Both are node settings; a pod cannot set them. With the interrupts on the loop’s CPU their p99 rose from 4 sockets.
  • One physical core with two hyperthreads: uring-sqpoll with the SQPOLL thread on the loop’s sibling (the default in a cpu: 2 x86 pod: Kubernetes).
  • The loop’s CPU time in the kernel matters: uring-spin. It equals epoll-spin’s latency with thousands of times fewer syscalls (data).
  • Many streams on one exchange: pack them onto a few combined-stream connections; fewer sockets decode sooner.
  • uring-napi, epoll-busy poll the NIC inside a timed wait, then sleep. In the one run they trailed uring-sqpoll and spent most of the loop’s time in the kernel (data).
  • How each one waits, every key, and how to find the crossover on your own host: Backends and configuration.

Connecting

callwhere the handshake runsuse it for
Driver::add(WsConn::connect(…)?)the caller’s thread, which waitstools, tests, start-up
Driver::connect(dial)the loop, a step per poll; the handshake makes one poll longa loop that can take a long poll per connect
Driver::connect_background(dial)a helper thread on the helper CPUs (connect_cpu; in a pod: Kubernetes), not the polling CPU when it is knownreconnects on a live loop

The loop’s worst poll per connect, both ways: connection set-up.

  • TLS 1.3 only. TLS 1.2 is refused with a clear error.
  • Trust is the rustls configuration you pass (tls::client_config(roots)); feature webpki-roots adds Mozilla’s roots.
  • Which TLS stack: the in-place tls::RecordLayer, on every native connection. rustls does the handshake (UnbufferedClientConnection, then dangerous_into_kernel_connection); the custom layer then does the record cryptography in place in the receive buffer, no allocation per record. Native connections (TlsStream, WsConn) take it unconditionally — there is no stock-rustls record path to choose. The layer owns the record cryptography that rustls would own after the handshake: post-handshake messages, key updates and the cipher’s confidentiality limit. Its effect on a whole request is not measured (only a decrypt-only microbench exists).

Defaults

settingdefaultchange with
TCP_NODELAYon, every socketalways on
TCP keepaliveon: 10 s idle, then a probe every 5 s, 3 probeskeepalive_idle_s (0: off), keepalive_interval_s, keepalive_count
TCP_USER_TIMEOUT25 stcp_user_timeout_ms (0: kernel default, about 15 minutes)
SO_RCVBUF4 MiB, fixed; needs net.core.rmem_max ≥ 4 MiB (warned at start-up)rcvbuf (0: kernel autotuning)
TCP quick ACKassumed: quickack 1 on each routechecked once per route (below)
WebSocket timersPing after 10 s quiet, Pong timeout 5 s, idle timeout off, close wait 1 sWsOptions (WebSocket)
ReconnectoffReconnect (WebSocket)
  • TCP settings apply on Driver::connect, connect_background and add. HttpClient’s libcurl connections get TCP_NODELAY, keepalive and TCP_USER_TIMEOUT too.
  • Keepalive adds one kernel timer per socket. For a WebSocket-only loop, Pings already find a dead server: keepalive_idle_s=0 is an option.

What it checks for you

  • Driver::new checks the loop’s CPU (ergon-tune): isolation (T1), transparent hugepages (T3), scheduling policy (T7), SMT sibling (T8), and a CPU quota on the process’s cgroup (T9).
  • Each egress device, from a warning thread: NIC interrupts on the loop’s CPU (T2), ENA adaptive moderation (N1), deferred interrupts with no busy poller (N2), an Azure VF that is down (N3).
  • Each route, once, for quick ACK. Default: one warning with the exact ip route change that fixes it. quickack_check=require refuses the connection before its TLS handshake. The latency effect of quick ACK is not measured here.
  • Each finding prints once, with its fix, never from the polling thread.

Best practice

Node-level means the setting is on the host, not in the pod spec (deployment: Kubernetes).

practicewhereevidence
Pin the loop to an isolated CPU; give it whole CPUs (static CPU manager)node and podKubernetes
Reconnect off the loop: connect_background (the Reconnect default)podconnection set-up
AWS: ENA adaptive moderation off (ethtool -C <nic> adaptive-rx off), again after each NIC resetnodehost and NIC settings
Interrupt backends: also rx-usecs 0, and the NIC’s interrupts on a core of their ownnodeinterrupts on a core of their own: fair run (irqlocal against irqcore). rx-usecs 0: the fair run’s setting, not A/B tested for these backends; moderation delays the interrupt they wait for (kernel Documentation/networking/device_drivers/ethernet/amazon/ena.rst). That run also had idle CPUs polling (nohlt)
Keep the NIC’s interrupts off the loop’s CPUnodethe irqlocal rows in host and NIC settings and in the fair run
Past 4 sockets: recv-busy,per_queue=true, or uring-sqpoll with a spare CPUpodchoosing a backend
Pack many streams onto a few connectionsapppolling backends

Measured with no consistent effect: SCHED_FIFO for the loop, one NIC queue, capped idle states. Not measured here: route quickack and rto_min, prefer_busy_poll, busy_poll_budget.

Verify

cargo test -p ergon-io -- --test-threads=1
cargo clippy -p ergon-io --all-targets -- -D warnings

Measured results

Every figure, with its max and its data file: io/benches/BENCHMARK.md. WebSocket and HTTP round trips: WebSocket, HTTP client.