talaris
Predictable-latency, low-jitter HFT transport toolkit for Linux. 给 HFT 行情 / 下单链路用的可预测低延迟 WebSocket / TCP / TLS low-level toolkit。
名字
talaria(Hermes 的飞翼凉鞋)在 crates.io 已经被占了,所以本 crate 用拉丁文单数talaris。Pool是推荐入口;ws/proactor/http/tls模块也会作为 low-level HFT toolkit 暴露给需要自己拼 transport / framing 的用户。
TL;DR
实际 inbound hot path(WSS / io_uring multishot recv):
NIC / kernel TCP stack
-> socket receive queue // 有序 TLS ciphertext byte stream
-> IORING_OP_RECV_MULTISHOT // kernel 从 socket queue copy 到 provided buffer
-> per-connection BufferRing slot // CQE 携带 conn token + bid + len;slot 内仍是 ciphertext
-> Pool drain CQE batch // 单线程按 CQE 顺序路由到 ConnectionState
-> ConnectionState resolves bid -> &[u8] // 借用 BufferRing slot;处理完立即 recycle
-> rustls.read_tls() // ciphertext 进入 rustls deframer
-> rustls.process_new_packets() // 解出完整 TLS records
-> rustls.reader().fill_buf() // borrowed plaintext slice
-> WsClient::drain_*_from_ingress()
-> direct data fast path // 完整、未分片 Text/Binary:header parse + borrowed payload
-> user sink // payload 生命周期只覆盖本次回调 / batch 回调
-> fallback state machine // partial frame / control frame / fragmented message / close
-> CursorBuf recv_buf // 保存跨 plaintext slice 的未完成 WS frame bytes
-> msg_buf // 仅 fragmented message reassembly 时 copy payload
Plain TCP / WS 路径跳过 rustls:BufferRing slot 里的 bytes 直接作为 plaintext 喂给 WsClient。
talaris 是为一类很狭窄的 workload 量身做的 io_uring WebSocket / TCP toolkit: HFT 行情订阅,单线程吃满 N 条 TCP/TLS WebSocket,要的是 p999 尾延迟 可预测,不是通用 async runtime。
如果你只是写 Web app / 微服务,tokio / tokio-tungstenite 通常是更合适的默认选择。 如果你的 workload 满足下面三条里至少两条,再考虑 talaris:
- 单进程驱动 ≥1 条 WebSocket,收行情是主要负载(订阅类 / 高频 inbound)
- 你愿意 / 已经做了
isolcpus+ 核绑定 + 关 NOHZ 这些运维操作 - p999 / max 抖动是产品要求,不是"nice to have"
心智模型:你需要先理解的 7 件事
1. talaris 不是 runtime,是一个同步 hot-loop transport driver
┌─────────────────────────┐
wire ──TCP──▶ │ Pool (单 OS 线程) │ ──回调──▶ 你的策略 / 解码 / 路由
│ ├─ 1 个 io_uring │
│ ├─ N 条 WS conn │
│ └─ 单线程 hot loop │
└─────────────────────────┘
跟 tokio 的最大区别:没有 executor,没有 future,没有任务调度。整个 Pool 就是一个
死循环:while running { pool.pump(...) }。你的代码在 pump 的回调里同步跑。
这是刻意收窄的同步设计:一个线程、一个 hot loop,用 io_uring 让 kernel 把数据 copy 到你预留的 buffer 里,业务代码在回调里同步消费。
2. Proactor vs Reactor
| Reactor (epoll / tokio) | Proactor (io_uring / talaris) | |
|---|---|---|
| 通知粒度 | "fd 可读了" | "数据已经在你的 buffer 里" |
| 谁干活 | 应用 在 readiness 后调 read() 把数据从 kernel 拷到用户 buffer |
kernel 直接写进 registered provided buffer;recv data copy 不走用户态 read() |
| 主循环 | epoll_wait → 遍历 ready fd → 每个 read() | submit & wait → drain CQE → 数据已就位 |
talaris 用的是 multishot recv:一次 submit,kernel 持续往你 buffer ring 里
塞数据 + 每次塞完 post 一个 CQE 告诉你"buffer 哪一格、有多少字节"。默认阻塞
pump 会用一次 wait_for_cqe(1) 进入 io_uring_enter(GETEVENTS) 等 CQE。
要把 steady-state receive loop 做到不等 CQE syscall,用 busy-poll 版本
pump_spin / pump_data_spin,代价是持续占用一个 CPU。
详见 src/proactor.rs 顶部的注释 —— 它解释了为什么我们叫 Proactor 而不是
跟 tokio-uring 那样还叫 Reactor。
3. Pool 是单线程的、Send/Sync 都不实现
let mut pool = new?;
// pool: !Send, !Sync
这是故意的。io_uring 本身可以跨线程使用,但 talaris 把一个 Pool 设计成
单 OS 线程 owner:SQ/CQ drain、BufferRing recycle、WS parser state 和 sink 回调都在
同一个 hot loop 里完成。跨线程共享 Pool 必然引入同步、ownership 迁移或 SPSC queue,
这些都会改变低延迟语义。我们直接在类型层面禁止:每条独立链路开一个 OS 线程 + 一个 Pool。
需要多 venue 多线程并发?开多个 OS 线程,每个线程自己 Pool::new,互不影响。
4. 一个 WebSocket Binary 帧的完整生命周期
按时间顺序:
[wire] ─ TCP segment 到达 NIC
[kernel] ─ NIC IRQ → kernel TCP stack 处理
[kernel] ─ 数据 copy 到 io_uring 某个 provided buffer ring(conn_id=M) 的某一个 slot (bid=N)
[kernel] ─ 生成 CQE: { user_data: conn_id, result: bytes_written, flags: bid|F_MORE }
[user] ─ 阻塞 pump 且 CQ 为空时在 wait_for_cqe 被唤醒;spin pump 则直接轮询 CQ ring
[user] ─ Pool 从 CQE 解出 conn_id, 路由到对应 ConnectionState
[user] ─ ConnectionState 按 bid/len 借用 BufferRing slot slice, 喂给 rustls 解密
[user] ─ rustls plaintext slice 借给 WsClient::drain_*_from_ingress()
[user] ─ direct path: 完整、未分片 Text/Binary 直接借用 plaintext payload 到 sink,不进 recv_buf
[user] ─ fallback path: partial/control/fragmented 剩余 bytes 进入 CursorBuf recv_buf
[user] ─ fragmented message reassembly 才把 payload copy 到 msg_buf
[user] ─ buf_ring.recycle(bid) 把密文 buffer slot 还给 kernel
阻塞 pump 只有在 CQ 为空、需要等待 completion 时才进入一次 io_uring_enter(GETEVENTS);
busy-poll pump_data_spin 路径只轮询 mmap 出来的 CQ ring,不进 wait_for_cqe。
跟 epoll/tokio 比,talaris 省掉的是 readiness 返回后应用侧 read() syscall 路径,
以及 executor / waker / scheduler 介入;不是承诺“一条 WS frame 对应一次 read syscall”。
5. chunk 是 parser 的 plaintext 输入批次,不是网络包
在 talaris 里,chunk 最准确的理解是:一次交给 WebSocket parser 的连续 plaintext byte slice。它不是协议层天然单位,而是 talaris hot path 里的处理批次单位。
WSS:
socket queue 中的 TLS ciphertext bytes
-> io_uring recv 到 BufferRing slot
-> rustls 解密
-> 产出一段 plaintext slice
-> 这一段 plaintext slice = 一个 plaintext chunk
-> WsClient 在这个 chunk 上流式解析 WS frame/message
Plain WS:
BufferRing slot 里的 bytes 本身就是 plaintext
-> 这段 bytes = 一个 plaintext chunk
它:
- 不是 NIC packet。
- 不是 skb。
- 不是 TCP segment。
- 不一定等于 CQE。
- 不一定等于 TLS record。
- 不一定等于一个 WebSocket message。
- 不一定包含完整 WebSocket frame。
一个 chunk 里可能出现:
case A: [完整 WS message]
case B: [完整 WS msg][完整 WS msg][完整 WS msg]
case C: [半个 WS frame]
case D: [后半个 WS frame][完整 WS msg]
case E: [Ping][Text][Pong][Text]
所以 talaris 必须做流式 WS parse,不能假设 chunk 边界就是 message 边界。 同一个连接内,chunk 内 bytes 有序,chunk 与 chunk 之间也按 TCP stream 顺序推进; 不同连接之间没有全局顺序保证。
observability 里的 chunk_position 就基于这个定义:
first:这个 plaintext chunk 里解析出来的第一条 WS data message。queued:同一个 plaintext chunk 里,第一条之后的 WS data message。chunk_prior_sink_service:当前 queued message 前面那些同 chunk message 已经花在 sink 里的时间。
一句话:chunk 是 talaris 从 TLS/plain ingress 中拿到的一段连续明文字节,并在一次 parser drain 中处理它;它是 parser 的输入批次,不是网络包,也不是 WS message。
6. general events vs data-only dispatch
我们有两个收数据的 API:
// General events — 完整 RFC 6455 状态机,control/data 都交给业务
pool.pump?;
// Data-only dispatch — WS 层仍处理 Ping/Pong/Close,业务只拿 Text/Binary
pool.pump_data?;
pump_data 不是 binary-only fast mode。它走同一套 WsClient 状态机,所以:
- Text JSON feed 可以直接解析 JSON。
- Binary SBE / protobuf feed 可以直接解析二进制 payload。
- WebSocket Ping/Pong/Close、fragmentation、UTF-8 校验和 auto-pong 仍然正常工作。
要自己观察 Ping/Pong/Close 事件时用 pump;行情主循环只关心业务 payload 时用
pump_data。
7. batch data dispatch:同一 plaintext chunk 内批量交付
对 Binance BBO 这类高频小消息,调用方经常需要在 decode 前先看同一个
plaintext chunk 内的多个冗余 message,并只保留最大 seq。pump_data_*_batches
不会替业务做 coalescing;它只把同一 plaintext chunk 内已经 parse 出来的 data message
用固定容量 view 批量交付,方便调用方在 sink 里自己扫描、去重、发布 winner:
pool.pump_data_spin_marked_batches?;
batch 是固定容量的 hot-path view。一个很大的 plaintext chunk 可能拆成多个
batch;除最后一个外,is_chunk_end() 都是 false。非 direct fallback 路径
仍可能退化成 one-message batch,但 control frame、fragmentation、auto-pong
语义保持不变。
一句话术语表(cheat sheet)
| 术语 | 一句话解释 | 在代码里 |
|---|---|---|
| Proactor | io_uring 薄封装:提交 connect/recv/send/close,drain completion queue。 | src/proactor/uring.rs::Proactor |
| CQE | io_uring completion event;recv CQE 告诉用户态哪个 conn、哪个 buffer、多少 bytes。 | src/proactor/op.rs::Completion |
| Pool | 一个 Proactor 驱动 N 条 WS conn 的单线程 owner;负责 CQE 路由和回调。 | src/pool.rs::Pool |
| PoolConfig | Pool 级配置:io_uring SQ/CQ sizing、setup flags、completion batch 等。 | src/pool.rs::PoolConfig |
| ConnHandle | 对外的不透明 conn 引用;包含 slot id + generation,避免 stale handle / late CQE 串到复用 slot。 | src/pool.rs::ConnHandle |
| ConnectionConfig | 单条 conn 的配置:host/path/TLS、recv mode、BufferRing、socket、observability。 | src/connection_meta.rs::ConnectionConfig |
| BufferRing | 每条 conn 自己的 provided-buffer ring;kernel 写 slot,CQE 通过 bid 指回该 slot。 |
src/proactor/buf_ring.rs::BufferRing |
| bid | buffer id;BufferRing 内一个 slot 的编号,处理完必须 recycle 还给 kernel。 | Completion::buffer_id() |
| plaintext chunk | 一段交给 WsClient 的 plaintext slice;TLS 下来自 rustls,plain WS 下来自 recv。 |
ConnectionState inbound path |
| WsClient | RFC 6455 client 状态机;direct data fast path 旁路 copy,fallback 处理控制/分片。 | src/ws/client.rs::WsClient |
| DataEventBatch | 同一 plaintext chunk 内 data messages 的固定容量 view;只批量交付,不替业务 coalesce。 | src/ws/client.rs::DataEventBatch |
| pin | 把当前线程钉死在一个 CPU 上,配合 isolcpus 用,减少 scheduler 迁移抖动。 |
talaris::proactor::pin_current_thread_to |
| pump | 阻塞推进通用 WS event;CQ 为空时会等待 completion,业务可观察 control/data。 | Pool::pump |
| pump_data | data-only dispatch;只把 Text/Binary 交给业务,control frame 仍由 WS 层处理。 | Pool::pump_data |
| pump_data_spin | data-only busy-spin 版本;持续轮询 CQ ring,适合 isolated CPU。 | Pool::pump_data_spin |
30 秒上手
Cargo.toml
[]
= "0.9"
可运行 quickstart: 本地 plain-WS echo
examples/quickstart.rs 在同进程里起一个最小 plain-WS echo server,不依赖外部
公网服务,适合作为发布包里的 smoke test:
# 延迟调优时建议显式给进程父 affinity,并把 user thread 钉到 isolated CPU:
生产配置: pin + data-only dispatch
use ConnectionConfig;
use pin_current_thread_to;
use DataEvent as WsDataEvent;
use ;
#
#
更完整的可运行例子见 examples/quickstart.rs。
API surface(常用入口)
// 1. 起一个单线程 Pool。默认 PoolConfig 已包含默认 ProactorConfig。
let mut pool = new?;
// 2. 建连接。
// 阻塞版会完成 TCP connect + TLS handshake + WS upgrade;适合启动期 / smoke / tests。
let h: ConnHandle = pool.connect_blocking?;
// let h = pool.connect_blocking_to(cfg, addr)?; // 跳过 DNS / IP racing 时使用
// 并发建连用 submit_*:先 reserve slot,再由后续 pump 驱动到 Open。
let h2 = pool.submit_connect?;
// let h2 = pool.submit_connect_to(cfg2, addr2)?; // 已经完成 DNS/IP racing 时使用
// 3. 主动发送。Text/Binary 是业务 data frame;Ping/Pong/Close 是 control frame。
pool.send_text?;
pool.send_binary?;
pool.send_ping?;
pool.send_pong?;
pool.initiate_close?;
// 4. 推进 IO。生产行情主循环通常优先用 data-only 路径。
pool.pump?;
pool.pump_data?;
let progressed = pool.pump_data_spin?;
let progressed = pool.pump_data_spin_batches?;
// 5. 生命周期管理。
pool.remove_conn?; // hard remove:释放 slot/bgid,旧 ConnHandle 立即失效
let h = pool.submit_reconnect_to?; // 非阻塞 diagnostic reconnect
// let h = pool.reconnect_to(h, cfg, addr)?; // 阻塞 convenience,不建议放 hot loop
// 6. 观测和状态。
let state = pool.state; // Option<State>
let active = pool.conn_count; // active conn count
let metrics = pool.prometheus_metrics;
pump / pump_data 是阻塞路径:CQ 为空时会等待 completion。
*_nowait 立即返回,适合 close cleanup 或和外部 loop 集成。
*_spin 不等待 syscall,适合 isolated CPU 上的低抖动 hot loop。
*_marked / *_marked_batches 会携带 observability metadata;未 marked 路径不读时钟。
Pool lifecycle / reconnect 语义
ConnHandle 是 opaque token,不只是 slot id。内部编码了 slot_id + generation:
slot 被 remove_conn / reconnect 回收后 generation 会递增,旧 handle 和旧 generation
的 late CQE 都不会命中新连接。这是长期运行进程能安全诊断重连的核心约束。
connect_blocking/connect_blocking_to:启动期和测试最方便;会自己 drive open。 专用 open-driving path 不会 drain 已有连接的业务 Text/Binary。submit_connect/submit_connect_to:生产启动期推荐;可一次提交多条连接,让 TCP/TLS/WS handshake 并发推进,后续用pump*驱动到State::Open。remove_conn:hard remove,不发送 graceful close。它会 unregister buffer ring、drop fd、 递减 active count,并在安全时回收 slot/bgid。旧 handle 后续state/send/remove都会失败或返回None。submit_reconnect/submit_reconnect_to:先移除旧连接,再提交新连接;用于故障诊断 / 重连。 如果新连接失败,旧连接不会自动恢复,调用方应按策略重新提交。reconnect/reconnect_to:blocking convenience,适合测试 / CLI / smoke;生产 hot loop 更推荐submit_reconnect_to,避免在关键路径里阻塞握手。
Opt-in observability
默认 hot path 不读时钟、不构造 metadata。需要定位瓶颈时显式切到 marked API。 marked API 默认 100% 采样;需要降采样时在连接配置里设置 basis points:
let cfg = new
.with_observability_sample_rate_bps; // 10%; 10_000 = 100%
use MarkedDataEvent;
let got = pool.pump_data_spin_marked?;
source_recv_time_nanos 是用户态观察到 recv CQE 时采样的 Unix epoch nanos;
跨机器使用它需要 chrony/PTP 等时钟同步。*_mono_nanos 只能在本进程内做差,
不要跨机器比较。recv_sequence / message_sequence 是每条连接 marked data-pump
内部维护的 u64 序号;普通未 marked pump 不推进这些序号。tls_plaintext_chunk_index
和 chunk_message_index 是单个 recv / plaintext chunk 内的 u16 索引,极端情况下会
saturate 到 u16::MAX。当前采样在 recv CQE 粒度做确定性选择;被采样 CQE 产生的
data message 会带 sampled = true 和分段时间戳,未采样事件仍正常分发但时间戳为 0,
delta helper 返回 None。
marked pump 是同步 sink 模型:一条 message 交给用户 sink 后,talaris 只有等这个
sink 返回才会继续解析同一 plaintext chunk 内的后续 message。因此
recv_to_ws_nanos() / plaintext_to_ws_nanos() 对 queued message 真实反映
"被 pump 到上层" 的延迟,但其中可能包含同 chunk 前序 message 的 sink 回调耗时。
chunk_prior_sink_service_nanos() 暴露这部分累计耗时;
*_excluding_prior_sink_nanos() 则把它扣除,用于观察 talaris parse/dispatch 本身的净
staging cost。
生产模式下可以让 talaris 用 HdrHistogram 直接维护本地 quantile,并导出 Prometheus text exposition。记录仍只发生在 marked pump 路径里:
let cfg = new
.with_observability_sample_rate_bps
.with_observability_histograms;
let h = pool.connect_blocking?;
pool.pump_data_spin_marked?;
// 在你的 /metrics HTTP handler 中返回这个 body(连接生命周期累计窗口)。
let body = pool.prometheus_metrics;
// 更适合 dashboard / alert 的 interval 窗口:导出后 reset interval histograms。
let interval_body = pool.prometheus_metrics_and_reset_interval;
Observability metrics summary
Prometheus 输出分两类:stage latency histograms 和 ingress counters。 前者用于回答“从 recv 到 WS message 被 pump 到上层到底花了多久”;后者用于回答 “这条连接 inbound 路径到底发生了多少 recv / chunk / WS event / ring pressure”。
Stage latency histograms 只有在 marked pump 路径里才会记录;默认 hot path 不读时钟。
同时开启 .with_observability_histograms(true) 后,talaris 用本地 HdrHistogram 维护
quantile,并导出这些 Prometheus family:
talaris_ws_latency_quantile_ns:本地 HdrHistogram quantile,单位 ns。talaris_ws_latency_samples:该 histogram 的样本数。talaris_ws_latency_sum_ns:样本耗时总和,可用于均值或 sanity check。talaris_ws_latency_max_ns:该窗口内最大样本。
通用 label:
conn_id:Pool 分配的连接 id,只在当前 Pool 生命周期内有意义。window="cumulative" | "interval":连接生命周期累计窗口或 interval 窗口。scope="chunk" | "message":chunk-level 或 message-level latency。stage:具体阶段。chunk_position:message 在 plaintext chunk 中的位置。quantile:只出现在talaris_ws_latency_quantile_ns。
当前 stage 语义:
| scope | stage | chunk_position | 语义 |
|---|---|---|---|
chunk |
recv_to_plaintext |
chunk |
用户态观察到 recv CQE 到 TLS/plain plaintext chunk ready。Plain WS 下基本是 recv 到 parser 输入;WSS 下包含 rustls 解密/deframe。 |
message |
plaintext_to_ws |
all / first / queued |
plaintext chunk ready 到 WS Text/Binary payload ready 并即将进入 sink。queued message 会包含同 chunk 前序 sink service。 |
message |
recv_to_ws |
all / first / queued |
用户态观察 recv CQE 到 WS Text/Binary payload ready 并即将进入 sink。最接近“收到数据到上层可见”的端到端指标。 |
message |
plaintext_to_ws_excluding_prior_sink |
all / first / queued |
从 plaintext_to_ws 中扣除同 chunk 前序 sink 回调累计耗时,观察当前 message 自身 parse/dispatch 净成本。 |
message |
recv_to_ws_excluding_prior_sink |
all / first / queued |
从 recv_to_ws 中扣除同 chunk 前序 sink 回调累计耗时,观察不受前序 sink 排队污染的 recv-to-message 净成本。 |
message |
chunk_prior_sink_service |
queued |
当前 queued message 前面那些同 chunk message 已经花在 sink 里的累计时间;first message 没有这个值。 |
chunk_position 的读法:
chunk:chunk-level histogram,不对应单条 WS message。all:所有 sampled data message。first:plaintext chunk 内第一条 WS data message。queued:同一 plaintext chunk 内第一条之后的 WS data message,用来观察 chunk 内排队。
这些 quantile 是每条连接本地 HdrHistogram 的客户端 quantile,不适合让 Prometheus
跨连接直接聚合成全局 quantile。prometheus_metrics() 导出 cumulative 窗口;
prometheus_metrics_and_reset_interval() 导出 interval 窗口并 reset interval latency
histograms,适合 dashboard / alert。两者都不会 reset ingress counters。
Ingress counters 是长期低成本计数器,也默认关闭,需要按连接开启:
let cfg = new
.with_ingress_stats;
let stats = pool.ingress_stats;
开启后,pool.prometheus_metrics() 会额外导出这些 lifetime counters:
| counter | 语义 |
|---|---|
talaris_ingress_recv_data_cqes_total |
正长度 recv data CQE 数。 |
talaris_ingress_recv_bytes_total |
正长度 recv data CQE 携带的 bytes;TLS 下是 ciphertext bytes,Plain TCP 下是 plaintext bytes。 |
talaris_ingress_recv_multishot_rearms_total |
recv multishot SQE submit / rearm 次数。 |
talaris_ingress_recv_ring_exhaustions_total |
provided-buffer ring exhaustion 导致 multishot recv 停止的次数。 |
talaris_ingress_plain_recv_batches_total |
Plain TCP data-pump batch path 处理的连续 recv CQE run 数。 |
talaris_ingress_plain_recv_batch_cqes_total |
上述 Plain TCP batch runs 包含的 recv CQE 总数。 |
talaris_ingress_plain_recv_copied_batches_total |
Plain TCP batch runs 中走 reusable copy scratch buffer 的次数。 |
talaris_ingress_plain_recv_copied_bytes_total |
copy scratch buffer 累计复制 bytes。 |
talaris_ingress_plaintext_chunks_total |
进入 WebSocket parser 的 plaintext source chunks;TLS 下是 rustls plaintext slices,Plain TCP 下是 recv/provided-buffer slices。 |
talaris_ingress_plaintext_bytes_total |
喂给 WebSocket parser 的 plaintext bytes。 |
talaris_ingress_ws_data_drains_total |
data pump 中到达 WS receive processing 的 plaintext source chunks。 |
talaris_ingress_ws_data_drain_skips_total |
data-pump drain attempt 中没有 plaintext 可处理的次数。 |
talaris_ingress_ws_data_events_total |
emit 到用户 data sink 的 Text/Binary data message 数。 |
talaris_ingress_ws_text_events_total |
emit 到用户 data sink 的 Text message 数。 |
talaris_ingress_ws_binary_events_total |
emit 到用户 data sink 的 Binary message 数。 |
没有开启 with_ingress_stats(true) 时,这些 counter 不更新。ingress counters 成本低于
latency histograms,但仍会在 hot path 更新计数器;benchmark / 排障时可以打开,生产默认
保持关闭,除非目标机器和 feed 已经确认这部分成本可接受。latency histograms 则按需要
打开采样,采样率通过 with_observability_sample_rate_bps() 控制,10_000 表示 100%。
调优参数(按 ROI 排)
先给原则:按 feed 特征调参,不按交易所名字调参。同一类 message size / frequency / burst pattern 的 feed 可以共用一个 Pool;BBO、trade burst、book snapshot 这类特征差异 很大的 feed,要用真实行情或 replay benchmark 单独定参数。
ConnectionConfig::with_buf_ring(slot_size, entries) —— 先定 recv 粒度
slot_size 是单个 provided-buffer slot 的字节数。WSS 下 slot 内是 TLS ciphertext;
plain WS 下是 plaintext。它决定 kernel 一次 recv CQE 最多搬多少 bytes 到用户态,也决定
同一个 CQE / plaintext chunk 里可能包含多少 WS messages。
经验起点:slot_size 约为常见 payload 的 8-20 倍,然后用真实 feed A/B。
| feed / payload 形态 | 建议起点 |
|---|---|
| BBO / quote 小消息 100-300 B | 2-4 KiB |
| trade / quote burst 100-500 B | 4 KiB |
| L2 book delta 300-800 B | 4-8 KiB |
| book snapshot 1-4 KiB | 16-32 KiB |
| 大 snapshot 4-16 KiB | 32-64 KiB+ |
entries 默认 256,必须是非零 2 的幂。整池字节数是 entries × slot_size;太小会让
multishot recv 在 burst 期撞 provided-buffer ENOBUFS,然后等下一轮 pump re-arm,尾部
会跳。太大则增加 locked memory 和 cache footprint。
let cfg = new
.with_buf_ring;
ProactorConfig::with_cq_entries + PoolConfig::with_completion_batch_capacity
这两个不是一回事:
cq_entries是 kernel io_uring CQ 容量,防止 multishot recv burst 把 CQ 撑爆。completion_batch_capacity是 Pool 内部Vec<Completion>初始容量,避免 hot loop 第一轮 grow, 也决定一轮 drain 后按 conn 分组处理时的 scratch 起点。
经验起点:
cq_entries >= max(2 * sq_entries, buf_ring_entries)
completion_batch_capacity = 64 起步;高 fanout / burst 可试 128 或 256
cq_entries 必须大于 sq_entries,并保持 2 的幂。
use ProactorConfig;
use ;
let proactor = default
.with_cq_entries;
let pool_cfg = new
.with_completion_batch_capacity;
let mut pool = new?;
PoolConfig::with_post_progress_spin_iters(iters) —— progress 后额外短 spin
这个参数只影响 busy-spin data pump:一次 pump 已经取得进展后,是否继续短暂 drain 附近 刚到的 CQE。它可能提高同轮聚合能力和 burst 吞吐,但也可能让第一条 message 在返回到 外层业务 loop 前多等一点。
默认是 0。只有在明确观察到“多 CQE 批内聚合不足”时才试,例如 64/128/256 矩阵。
不要把它当成通用低延迟开关。
let pool_cfg = default
.with_completion_batch_capacity
.with_post_progress_spin_iters;
ConnectionConfig::with_recv_mode(mode) —— bundle 仍是实验项
默认 RecvMode::Multishot 是当前推荐。RecvMode::MultishotBundle 依赖较新的 kernel
能力,一个 CQE 可能覆盖多个 provided buffers;它更偏吞吐/burst 实验,不是小消息 BBO 的
默认低延迟配置。
use RecvMode;
let cfg = cfg.with_recv_mode;
ProactorConfig::with_setup_flags(flags) —— 高级 taskrun 控制
可选暴露 IORING_SETUP_COOP_TASKRUN、IORING_SETUP_TASKRUN_FLAG、
IORING_SETUP_SINGLE_ISSUER、IORING_SETUP_DEFER_TASKRUN。默认全部关闭。
推荐只按明确假设打开,并用 benchmark 验证:
use ;
use PoolConfig;
let flags = SINGLE_ISSUER
| DEFER_TASKRUN
| TASKRUN_FLAG;
let proactor = default.with_setup_flags;
let pool_cfg = new;
约束:DEFER_TASKRUN 要求 SINGLE_ISSUER;TASKRUN_FLAG 要求 COOP_TASKRUN 或
DEFER_TASKRUN;COOP_TASKRUN 和 DEFER_TASKRUN 是不同 taskrun 模式,不能一起开。
长时间只做 userspace spin/drain 的 loop 不应无脑开启。
ConnectionConfig::with_ingress_stats(true) —— 临时量化 recv/CQE 结构
默认关闭。调 buf ring、CQ sizing、chunk/CQE 聚合时可临时开启,并通过
pool.ingress_stats(h) 读取:
recv_data_cqes, recv_bytes, recv_ring_exhaustions,
plaintext_source_chunks, plaintext_bytes,
ws_data_drains, ws_data_drain_skips, ws_data_events
生产默认保持关闭;需要排障或已验证成本可接受时再按连接开启。
ConnectionConfig::with_plain_recv_batch_copy_max_bytes(bytes) —— 只针对 plain WS
这个参数只影响 plain TCP / plain WS 的 unmarked data pump:把同一轮连续 recv CQE copy 进可复用 scratch buffer,再作为一个更大的 WS input slice 解析。它是吞吐向参数,会 copy bytes, 也会让第一条 message 等待这一轮 copy 完成。
WSS/TLS 和 marked observability 路径会保留 per-CQE / per-plaintext staging;生产 WSS 默认不靠这个参数调延迟。
ConnectionConfig::with_socket_busy_poll_usecs(usecs) —— 只作为实验开关
SO_BUSY_POLL 是 Linux per-socket busy polling budget。talaris 暴露它是为了让特定
kernel / NIC / feed 组合做 A/B。它不是默认低延迟开关,也不会降低 CPU;当用户态已经
busy-spin 时,它只是把部分等待挪到 kernel/NIC poll path。
生产默认保持关闭。只有在目标机器、目标 kernel、目标 feed 的 live/replay benchmark 证明有稳定 ROI 后,才应该把它写进生产配置。
pin_current_thread_to(cpu) —— 砍尾抖动
isolcpus=N-M 把 CPU 从普通 scheduler 摘出来 + 钉线程到那个 CPU,主要目标是减少
scheduler migration 和普通 OS noise 对 p999 / max 的影响。它通常不改变 p50,
收益取决于目标机器的 CPU 拓扑、IRQ 绑定和隔离质量;HFT 要看的是 tail,不是 mean。
CPU 拓扑建议(8 vCPU 机器为例,isolcpus=1-5)
CPU 0 ← OS noise (IRQ / kthread / cron)
CPU 1 (iso) ← talaris user thread (pin here)
CPU 2,3,4 (iso)← 备用 / 第二条 Pool / tokio 对照组
CPU 5 (iso) ← CPU 1 的 SMT sibling;谨慎用于同一条 hot loop 的对照实验
CPU 6, 7 ← OS noise
优先把 talaris user thread 放在独立 physical core。SMT sibling 会共享执行资源; 如果要在同一物理核上跑策略解析或对照 worker,必须用真实行情 burst 做 A/B。
心智模型对比:talaris vs tokio
| tokio + tokio-tungstenite | talaris | |
|---|---|---|
| 抽象层 | Future / Stream / async fn / executor / waker | 同步函数调用 + pump loop |
| IO 模型 | epoll / kqueue (Reactor) | io_uring multishot recv (Proactor) |
| 线程模型 | 默认 multi-thread runtime + work stealing | 单线程持 Pool, 跨线程要多开几个 Pool |
| IO 进展 cost | readiness event + read() + waker poll + Stream::poll_next |
CQE drain + TLS/WS parse + sink |
| schedule jitter | executor 调度 + work stealing 漂移 | 无 executor 调度;仍受 OS / IRQ / CPU 拓扑影响 |
| 依赖 | tokio (~20+ transitive) + tokio-tungstenite + futures + ... | rustls / ring / io-uring + 小型 codec deps;无 async runtime |
| 何时选 tokio | web server / 通用 microservice / mixed IO | |
| 何时选 talaris | HFT 数据流 / latency-sensitive subscribe loop |
什么时候不要用 talaris
- macOS / Windows 部署:talaris Linux only(io_uring 是 Linux 独有)
- kernel < 6.0:multishot recv + buffer ring 要 5.19+,生产建议 6.x; talaris 不提供 epoll fallback,低版本应升级内核或选择其它 WebSocket 客户端
- 业务里 IO 不是热点:你的 hot path 是策略计算 / DB / 跨进程通信而不是 WS 收发,framing / transport 优化会被其它开销淹没
- 不想做 CPU 隔离运维:不 isolcpus 不 pin,talaris 大部分优势消失
- WS server:talaris 是 client-only,没 listener 实现
常见坑
1. 同一线程必须独占一个 Pool
let pool1 = new?;
let pool2 = new?;
// 同一线程持两个 Pool 也行但意义不大 (一个 Pool 就能驱动 N conn)
// 跨线程share 一个 Pool?编译就过不了 —— Pool: !Send
2. pump 是阻塞的(除非用 pump_nowait)
pool.pump(...) 以 wait_nr=1 推进:如果 CQ 里已经有 completion,会先直接 drain;
如果 CQ 为空,才调用 wait_for_cqe(1) 等到至少 1 个 CQE。你不希望阻塞
(譬如要在同一 loop 里做别的事)时,用 pool.pump_nowait(...)。
如果你愿意在 isolated CPU 上 busy-spin,pool.pump_spin(spin_iters, ...) /
pool.pump_data_spin(spin_iters, ...) 会只轮询 CQ ring,不调用 wait_for_cqe(1)。
返回的 bool 表示这一轮是否处理到了 CQE / frame;返回 false 时可以继续 spin,
或降级到阻塞 pump。
3. 业务只想要行情 payload 时用 pump_data
交易所 WebSocket 通常会混合 Text JSON、Binary SBE 和 Ping/Pong/Close control
frame。pump_data 会完整处理 control frame,只把 Text/Binary data 交给业务;
如果你需要记录 Pong 延迟或 Close reason,改用 pump。
4. pump_data_spin 只在愿意烧 isolated CPU 时用
对行情订阅客户端来说,steady-state 的 submit 很少,真正影响尾部的是 CQE 到达后
user thread 多快看到它。pump_data_spin 只轮询 CQ ring,不进入
io_uring_enter(GETEVENTS) 等待 completion,适合一条 isolated CPU 专门喂策略的
部署形态。低负载或同机 CPU 紧张时用阻塞 pump_data。
5. taskset / isolcpus / pin 三件套必须一致
# 运维层
isolcpus=1-5 nohz_full=1-5 rcu_nocbs=1-5 # kernel cmdline
# 启动时
# 代码里
);
少了 taskset → pin_current_thread_to(1) 会 fail(CPU 1 不在进程 affinity 里)。
少了 isolcpus → CPU 1 上有其它任务抢,pin 失去意义。
6. buf_ring 太小会 ENOBUFS
burst 期 N 个 buffer 还没来得及 recycle,下一次 multishot recv 找不到空 slot →
整条 multishot recv 停止 → Pool 下一轮 pump 才 re-arm。表现:burst 头几条
数据延迟跳一下。
解决:调大 entries 或 slot_size,让 entries × slot_size 覆盖 burst 期
in-flight ciphertext bytes,并给 recycle 留余量。
What's in the box
- io_uring proactor — configurable SQ/CQ sizing, taskrun setup
flags, pin-to-core, multishot
recvover a registeredBufferRing,IO_LINKchains, owned-fdclose. - WebSocket client (RFC 6455) — frame codec, masking (AVX2 + 8-byte chunked scalar fallback), streaming parser, fragment reassembly, close handshake, auto-pong, CSPRNG mask keys (RFC §10.3 compliant).
- TLS —
rustls0.23 driven by raw bytes (notokio/ noasync-std), ALPNhttp/1.1requested and verified,close_notifysurfaced to the caller. - HTTP/1.1 codec — minimal, sized for WS Upgrade. Header size cap (16 KiB)
/ count cap (64) / explicit
Transfer-Encodingreject for DoS hardening. - Pool — single io_uring drives N WebSocket connections. CQE routing is
O(1) generation-guarded slot-table lookup.
submit_connectreturns a handle immediately so N connections can hand-shake concurrently;remove_conn/submit_reconnect_torecycle slots without letting stale handles or late CQEs alias a new connection.
Platform
Linux only at runtime. The crate compiles cleanly on macOS / Windows (a stub
proactor keeps types in scope so non-Linux IDEs can type-check the full
codebase), but the hot path is not implemented there: affinity helpers return
UnsupportedPlatform and io_uring operations are stubbed. CI / production
builds must target Linux.
Tested on Linux 6.x with io_uring features: SETUP_CQSIZE, SETUP_COOP_TASKRUN,
SETUP_SINGLE_ISSUER, SETUP_DEFER_TASKRUN, REGISTER_PBUF_RING,
OP_RECV_MULTISHOT, IOSQE_IO_LINK.
Benchmark suite
benches/ 现在保留 Linux-only pipeline、tuning 和 strict-compare benches。它们不使用 Criterion
sampling:talaris 的 hot path 是长生命周期 io_uring recv_multishot、PBUF
recycle、TLS/WS staging 和 CQE drain,不适合把一次 pump_data 包进短采样
iteration。非 Linux 只构建并打印 skipped,用于保持本地
cargo check --benches 可用。
本地 loopback bench 默认绑核口径:
- bench 进程
taskset -c 0-2。 - local bench pin user thread 到 CPU 1、server thread 到 CPU 2。
- live bench 必须随结果记录目标机器、kernel、CPU governor、IRQ 绑定和
taskset/ pinning;没有环境上下文的 live 数字不要作为结论引用。
| bench | 测什么 |
|---|---|
local_pipeline |
loopback plain WS,真实 Pool + io_uring + PBUF + WS pump。用于比较 unmarked、marked、采样和 HdrHistogram 记录的 hot-path 成本 |
local_tuning |
loopback plain WS talaris 参数矩阵:扫 payload × frames-per-write × buf_size × buf_entries × completion_batch × spin_iters,输出 CSV 和 top variants |
local_compare |
loopback plain WS strict A/B:同一个 stream server、payload、frames-per-write、sink checksum 和 CPU pinning,比较 talaris baseline 与 tungstenite |
live_pipeline |
live TLS WebSocket,使用生产 Pool::pump_data_spin_marked 和当前 observability / Prometheus 导出口径 |
live_compare |
live Binance USD-M BBO strict A/B:talaris 与 tungstenite 同时订阅相同 combined streams,记录同类 socket/read-to-message 延迟 |
local_redundancy |
loopback BBO redundant-connection race simulation:同一 seq stream 多连接输入,评估去重前 duplicate 放大成本 |
live_redundancy |
live Binance BBO 多冗余连接观测:记录 fastest-copy / duplicate / stale 分类和 duplicate lag |
跑法示例:
Feed placement / tuning principle
talaris 的低延迟优势来自 single Pool / proactor hot loop 在热路径上 inline 完成
CQE drain、TLS decrypt、WS parse 和 dispatch;对应风险是不同 feed 混跑时会产生
head-of-line blocking。因此,benchmark 和生产配置都应该按 feed class 隔离,
而不是把不同交易所、不同消息形态混在同一个 Pool / io_uring 中调一个平均值。
推荐原则:
- 一个
Pool/ io_uring 对应一个 latency class / feed class。 - 同一个 feed class 内可以包含多个 symbol 和冗余连接;例如 Binance USD-M
Perpetual BBO 多 symbol、4 路冗余可以放在同一个
Pool中联合调参。 - 不同
message size、消息频率、burst pattern、冗余路数、parser 成本或 latency SLO 的 feed 应该分到不同Pool,分别 benchmark。 - 每个 feed class 单独寻找最优
buf_size、buf_entries、completion_batch、spin_iters、冗余路数、CPU pinning 和采样率。 - 引入新交易所或新 feed 时,先归类为 BBO / trade / depth delta / snapshot / large JSON 等 workload,再跑专项 bench;不要直接复用其它 feed class 的最优参数。
这条原则用于指导本 crate 在上层项目中按交易所和 feed 类型做针对性基准测试: 先定义 feed class,再为该 class 建立参数矩阵和 latency envelope,最后把最优 参数固化到对应生产配置。
local_pipeline --mode 当前支持:
baseline:unmarkedpump_data_spin,不构造 metadata。marked_0_nohist:marked pump,采样率 0%,不写 histograms,用于观察 metadata 分发成本。marked_100_nohist:marked pump,采样率 100%,不写 histograms,用于观察 timestamp 成本。hist_1pct/hist_10pct/hist_100pct:marked pump + HdrHistogram, 分别用 1%、10%、100% 采样率记录 observability histograms。
--prom-out PATH 会写 Prometheus text exposition snapshots。每个 interval
snapshot 调用 Pool::prometheus_metrics_and_reset_interval();最后额外写一次
final interval 和 cumulative snapshot。输出格式与上文 Observability metrics summary
一致。
License
GPL-3.0-or-later. See LICENSE.