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 theio_uringbackends;webpki-rootsadds Mozilla’s root store (tls::webpki_roots);curladds libcurl as anHttpClientimplementation. - 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.
| term | what it is | pros / cons, when it matters |
|---|---|---|
| busy-spin thread | A thread that loops on its own core and never sleeps. | Pro: no wake-up delay. Con: uses a whole core all the time. |
| poll | One non-blocking pass of Driver::poll: receive, decrypt, decode, deliver, send. | A message can wait up to one whole poll for its turn. |
| isolated core | A 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) sibling | The 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). |
| cpuset | The 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 manager | The 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. |
| Graviton | AWS’s Arm CPUs. | Each vCPU is a whole core, so there is no hyperthread sibling. The fair backend comparison ran on it. |
SCHED_FIFO | Real-time scheduling: the thread runs ahead of every normal one. | One A/B for the loop: no consistent change (data). |
| syscall | A call from the program into the kernel. | Each costs CPU. uring-spin makes far fewer than epoll-spin at the same message rate. |
| interrupt | The 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. |
| NAPI | The 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 queue | One 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 RX | ENA (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. |
| backend | How 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-spin | Asks 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, tokio | Rust’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-busy | Reads 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_queue | A 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_uring | A 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-spin | io_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-sqpoll | A 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-busy | Wait 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. |
| crossover | The 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_NODELAY | Turns 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 ACK | The 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_min | The 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 keepalive | The 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_TIMEOUT | How long sent data may stay unacknowledged before the kernel drops the connection. | 25 s here; the kernel default is about 15 minutes. |
| TLS 1.3 | The encryption on every connection. | TLS 1.2 is refused with a clear error. |
| TLS handshake | The key exchange when a connection opens. | Makes one poll of the connecting thread long (data). Keep it off the loop: connect_background. |
| TLS record | TLS’s unit of encrypted data. | A native HTTP request is one record; a large WebSocket message goes out over several. |
| in place | Decrypted 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. |
| WebSocket | A two-way message stream over one TCP connection, opened by an HTTP upgrade (status 101). | No compression here: it would break in-place parsing. |
| frame | WebSocket’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 / Pong | Control 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 handshake | Each side sends a Close frame, then TCP closes. | Waits up to 1 s (close_timeout_ms). Driver::close skips it. |
| HTTP/1.1 keep-alive | One connection reused for many requests. | No handshake per request. One request in flight per connection; a timed-out request closes its connection. |
| connection pool | The warm keep-alive connections an HttpClient keeps. | All busy: Error::Busy. Each one is one more socket the loop polls. |
| libcurl | The 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 limit | A 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, bucket | limit::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 window | A 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. |
| reserve | Units only cancels and reduce-only orders may use. | Keeps cancels possible when a bucket is nearly full; not during a hold. |
| hold, halt | Hold: 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(). |
| verdict | What 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::Maybe | For a request with no answer: it never left, or it may have run. | Only the transport knows. Maybe: look the order up. |
| client order id | Your unique id on each order. | The only way to find out what became of an order with an unknown outcome. |
| connect gate | A bucket every connect attempt must pass. | Keeps 50 links reconnecting at once inside a venue’s connect limit. |
| round trip | Request 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 / max | pN: 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 timestamp | When 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 → decoded | Kernel 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 path | One-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 when | Use something else when |
|---|---|
| one thread owns 1 to a few hundred exchange connections and spins on its own core | the thread may sleep: tokio |
| you want one copy per byte: TLS records decrypted and messages parsed in place | you 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::pollmakes 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 asWsHandler::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.pollfails only when the backend fails. - Resources are owned. A socket closes when its connection drops.
Dropping a
Drivercancels in-flightio_uringwork 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 thread | one CPU | a spare CPU for the SQPOLL thread (or the loop’s sibling) | the NIC’s interrupts on a core of their own (node-level) |
|---|---|---|---|
| 1–4 | recv-busy | recv-busy or uring-sqpoll | recv-busy |
| 5–64 | recv-busy,per_queue=true | uring-sqpoll | uring-sqpoll, epoll-spin or uring-spin |
| 65–256 | recv-busy,per_queue=true | uring-sqpoll | uring-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-busyreads 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_queuereads one socket per NIC queue, then the ready ones. Its poll stops growing at the queue count. It trailsuring-sqpollat 16–64 sockets. Not the default: set it when the count can pass 4.- The interrupt backends (
epoll-spin,uring-spin, mio) matcheduring-sqpollfrom 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-sqpollwith the SQPOLL thread on the loop’s sibling (the default in acpu: 2x86 pod: Kubernetes). - The loop’s CPU time in the kernel matters:
uring-spin. It equalsepoll-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-busypoll the NIC inside a timed wait, then sleep. In the one run they traileduring-sqpolland 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
| call | where the handshake runs | use it for |
|---|---|---|
Driver::add(WsConn::connect(…)?) | the caller’s thread, which waits | tools, tests, start-up |
Driver::connect(dial) | the loop, a step per poll; the handshake makes one poll long | a 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 known | reconnects 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)); featurewebpki-rootsadds Mozilla’s roots. - Which TLS stack: the in-place
tls::RecordLayer, on every native connection. rustls does the handshake (UnbufferedClientConnection, thendangerous_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
| setting | default | change with |
|---|---|---|
TCP_NODELAY | on, every socket | always on |
| TCP keepalive | on: 10 s idle, then a probe every 5 s, 3 probes | keepalive_idle_s (0: off), keepalive_interval_s, keepalive_count |
TCP_USER_TIMEOUT | 25 s | tcp_user_timeout_ms (0: kernel default, about 15 minutes) |
SO_RCVBUF | 4 MiB, fixed; needs net.core.rmem_max ≥ 4 MiB (warned at start-up) | rcvbuf (0: kernel autotuning) |
| TCP quick ACK | assumed: quickack 1 on each route | checked once per route (below) |
| WebSocket timers | Ping after 10 s quiet, Pong timeout 5 s, idle timeout off, close wait 1 s | WsOptions (WebSocket) |
| Reconnect | off | Reconnect (WebSocket) |
- TCP settings apply on
Driver::connect,connect_backgroundandadd.HttpClient’s libcurl connections getTCP_NODELAY, keepalive andTCP_USER_TIMEOUTtoo. - Keepalive adds one kernel timer per socket. For a WebSocket-only loop,
Pings already find a dead server:
keepalive_idle_s=0is an option.
What it checks for you
Driver::newchecks 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 changethat fixes it.quickack_check=requirerefuses 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).
| practice | where | evidence |
|---|---|---|
| Pin the loop to an isolated CPU; give it whole CPUs (static CPU manager) | node and pod | Kubernetes |
Reconnect off the loop: connect_background (the Reconnect default) | pod | connection set-up |
AWS: ENA adaptive moderation off (ethtool -C <nic> adaptive-rx off), again after each NIC reset | node | host and NIC settings |
Interrupt backends: also rx-usecs 0, and the NIC’s interrupts on a core of their own | node | interrupts 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 CPU | node | the 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 CPU | pod | choosing a backend |
| Pack many streams onto a few connections | app | polling 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.