跳到主要内容

Rust 高并发实战(5):阻塞隔离、Tracing 与 Tokio 性能诊断

Rainy
雨落无声,代码成诗 —— 致力于技术与艺术的极致平衡
Rainy
7 MIN READ... VIEWS

async worker 上一次 200 ms 同步阻塞,影响的可能不是一个请求,而是同线程上的整批 task。

诊断架构图:四类执行资源必须分开观察

如果 CPU work 进入 async worker,整个 I/O 调度受影响;如果无界进入 blocking pool,会生成大量线程/排队;如果 CPU pool 自身无界,仍会过载。图中的三种执行资源需要独立 gate、queue wait 和利用率指标。

一、隔离同步阻塞与 CPU work

async fn parse(input: Vec<u8>, gate: Arc<Semaphore>) -> Result<Document, Error> {
let permit = gate.acquire_owned().await?;
tokio::task::spawn_blocking(move || {
let _permit = permit;
parse_document(input)
})
.await??
}

Tokio blocking pool 上限很大,适合有界同步 I/O,不等于适合无界 CPU 并行。CPU 任务使用接近核数的独立 semaphore、Rayon 或专用线程池。

已经开始的 spawn_blocking 普通 abort 无法强制停止;timeout 只让调用方停止等待。长任务需要协作取消、切块或幂等副作用。长期循环使用专用线程,不长期占 blocking pool。

二、识别 worker starvation

故障代码:

async fn handler() {
std::thread::sleep(Duration::from_millis(200));
expensive_sync_parse();
}

症状:CPU 可能未满,但 scheduled-to-poll 延迟和全站 P99 上升;线程栈停在 sleep、同步 I/O 或计算函数。修复后同时监控 blocking queue wait,避免只是转移排队。

三、结构化 Tracing

#[tracing::instrument(skip(client, permit_pool), fields(queue_wait_ms))]
async fn call_backend(client: &Client, permit_pool: Arc<Semaphore>) -> Result<Response, Error> {
let started = Instant::now();
let permit = permit_pool.acquire_owned().await?;
tracing::Span::current().record("queue_wait_ms", started.elapsed().as_millis() as u64);
let response = client.send().await;
drop(permit);
response
}

span 至少包含 route、tenant、deadline、queue wait、downstream、attempt 和 result class。避免记录 token、Cookie、完整 query 和敏感载荷。

四、tokio-console

console-subscriber = "0.5"
tokio = { version = "1", features = ["full", "tracing"] }
cargo install --locked tokio-console
RUSTFLAGS='--cfg tokio_unstable' cargo run --release
tokio-console

观察长时间 busy、poll 次数异常、长时间未唤醒、task 数增长,以及 mutex/semaphore 等资源。tokio_unstable 无稳定保证,优先在预发布或单诊断副本使用。

五、CPU 与系统层

perf record -F 99 -g -- ./target/release/service
perf report
pidstat -t -p "$SERVICE_PID" 1
strace -f -c -p "$SERVICE_PID"

同时检查 cgroup throttling、run queue、context switch、page fault、文件描述符与 socket。应用 CPU 低可能是所有 task 都在等锁/池,也可能进程被 quota throttle。

六、内存诊断

  • task/channel 数持续增长:先找 owner 和有界性;
  • allocation 高但 RSS 稳定:关注短命对象与 allocator CPU;
  • live bytes 增长:检查 queue、cache、Arc 环、buffer 和 detached task;
  • Rust 可避免引用环的部分场景,但 Arc 环仍会泄漏,必要时使用 Weak

七、从 Span 判断“运行”还是“等待”

一个请求总耗时 300 ms,可能只运行了 2 ms。给关键阶段分别建 span:admission、pool acquire、remote I/O、decode、serialize。指标记录 queue wait,span 用于抽样还原因果链。

若 CPU profile 没有业务热点而 P99 很高,先看等待;若 task busy duration 很高,再看 perf。这样避免在 I/O 饱和时优化 JSON,也避免在 CPU 忙循环时盲目扩大 semaphore。

八、Release Profile 与符号

性能结论必须来自 release build。为 profile 保留足够调试符号,Linux 上根据 unwinding 方案启用 frame pointer 或 DWARF。确认容器里的二进制、源 commit、feature flags 与本地符号一致。

[profile.release]
debug = 1
lto = "thin"
codegen-units = 1

LTO/codegen 设置影响构建时间和 profile 可读性,需用实际二进制验证,不应为了“最佳实践”一次性更改全部生产参数。

九、Allocator、复制与序列化

高 allocation rate 会消耗 CPU 并扩大 RSS。排查 Vec/String 重分配、临时 JSON、无界 collect、每请求重建 client/config。优化手段包括合理 with_capacity、借用切片、流式 decode 和复用 buffer。

不要池化所有小对象:池本身有同步成本,长期保留峰值 buffer 可能让 RSS 更差。只对 profile 证明的大对象/高频路径做池化,并限制回池最大容量。

十、Benchmark 的陷阱

  • async benchmark 是否包含 runtime 创建;
  • 数据是否被编译器优化掉;
  • 单线程微基准是否遗漏争用;
  • 平均吞吐是否掩盖 P99;
  • 模拟下游是否与服务争用同一 CPU;
  • allocator、CPU governor、NUMA 与容器 quota 是否一致。

先用微基准定位函数级成本,再用固定到达率服务压测验证整体效果。

十一、诊断采样的生产策略

日志、span、console 和 profiler 都有成本。建议:错误全采样、慢请求优先采样、正常请求低比例;console 仅预发布/单诊断副本;perf 短窗口;高基数信息进入日志而非 metrics。

十二、线程数与 NUMA/容器边界

物理机多 NUMA 节点时,task 与内存跨节点访问可能放大延迟;容器 CPU quota 又可能与可见核数不同。先确保 runtime 并行度符合实际配额,再考虑 pinning/NUMA,后者需要真实 profile 和部署约束。

扩 worker 后吞吐不升的常见原因:单锁/atomic 热点、下游饱和、memory bandwidth、allocator 争用、CPU throttle。必须逐项以证据排除。

十三、优化记录模板

假设:哪个资源限制吞吐
证据:profile/span/queue 指标
改动:唯一变量与预期影响
实验:负载、机器、版本、持续时间
结果:RPS、P99、CPU、RSS、错误、恢复
副作用:复杂度、公平性、最坏情况

13.1 从一个慢请求拆出真正瓶颈

request total 320 ms
├─ admission wait 2 ms
├─ DB permit wait 180 ms
├─ DB query 8 ms
├─ JSON encode 3 ms
└─ scheduled/poll gap 127 ms

这不是“数据库查询慢”:DB pool 过小或泄漏让许可等待 180 ms;runtime 又可能被 blocking work 占住,产生 127 ms poll gap。扩大 query timeout 或优化 SQL 都不会解决主要问题。

修复实验要分两步:先让 DB semaphore 与连接池对齐,验证 permit wait;再隔离 blocking work,验证 scheduled/poll gap。一次同时改两个变量无法量化贡献。

13.2 观察 spawn_blocking 的队列

给提交时间、真正开始时间和完成时间分别打点:queue_wait = started - submittedwork = finished - started。大量 work 很短但 queue wait 很长,说明 blocking pool/CPU gate 饱和;work 自身长则分析同步库或算法。

13.3 内存与任务生命周期关联

在 span 中记录任务类型和输入大小,metrics 只保留低基数 bucket。RSS 上升时同时看 active task、channel depth 和 live bytes:task 数增代表生命周期泄漏;task 稳定而 live bytes 增可能是 cache/Arc 环;heap 稳定而 RSS 高还要看 allocator retained、mmap 和线程栈。

十四、本期验收

  • 在 handler 注入同步 sleep,使用 console/线程栈定位;
  • 分别限制 async I/O、blocking I/O 与 CPU work;
  • 建立 queue wait 与 work duration 两套直方图;
  • 比较优化前后的吞吐、P99、task 数、RSS 和 CPU throttle。

上一篇:Rust-4:状态与取消安全 · 下一篇:Rust-6:故障注入与线上排障

参考资料

Logo
RainLib

探索技术、设计与分布式系统的边界。构建面向未来的开发者工具。

留言与建议

© 2026 RainLib. 为未来构建。(Built for the Future)
版权所有。
系统正常