当 work-stealing 遇到块设备:一个异步运行时在存储路径上的边界
RobustMQ 在压测 MQTT Broker 时注意到一个反常现象:meta-service 的 raft apply 路径中,
set_last_applied这一步的耗时极不稳定——有时不到 1 ms,有时突增到 5–11 ms。而这一步做的事情非常基础:往 RocksDB 写一条记录。RocksDB 自己的监控显示写入耗时始终在 1 ms 以内。两个数字之间的差距,指向的不是存储,而是 tokio 异步运行时的调度抖动。这个问题往下挖,挖出的不是一个可以“调参解决”的 bug,而是一个调度模型与 I/O 模型同时抵达各自边界的时刻。
work-stealing 为谁设计
tokio 的多线程执行器使用 work-stealing 调度:每个 worker 线程维护本地任务队列,本地队列空时去其他线程那里“偷”任务。这个设计对通用 Web 服务效果极好——请求通常是短小的、彼此独立的、CPU 密集度低的,偷任务带来的负载均衡收益远大于任务迁移的成本。
但“通用 Web 服务”这个前提,隐含了两个不写在文档里的假设:任务的执行时间足够短,以至于迁移代价可以忽略;以及 I/O 是“就绪”语义就能覆盖的。 当场景切换到高吞吐存储系统时,这两个假设同时失效。
两个内生代价,不是 bug
Iggy 团队在从 tokio 迁移的过程中,把 work-stealing 在高吞吐存储路径上的代价归纳为两条,它们都不是实现缺陷,而是模型的内生属性。
第一条是 CPU cache 失效。 当一个 task 在 worker A 上执行到 await 挂起,随后被 worker B 偷走继续执行时,这个 task 所访问的栈帧、局部变量和关联数据结构原本已经在 A 的 L1/L2 cache 里。换到 B 之后,这些数据对 B 来说是冷的,必须从 L3 或主存重新加载。对于一个高频执行存储操作的系统,这种 cache miss 的积累是显著的。
第二条是调度时机不可控。 task 在 await 之后何时被调度、被哪个线程调度,完全由运行时的内部状态决定,应用层不可见。当大量并发连接同时触发存储路径时,一个 task 从挂起到重新执行的等待可以是几毫秒,而这段等待会被计入任何在 await 之后出现的操作耗时里。RobustMQ 观察到的 5–11 ms 抖动,本质上就是这个等待被“嫁祸”给了 RocksDB 的写入。
这两条代价的共同点是:它们只在低并发、短任务的环境下不显眼。一旦系统进入高吞吐、长尾敏感的状态,它们就从“可接受的噪声”变成“架构天花板”。
epoll 与块设备:更底层的错配
如果问题只停留在调度层面,thread-per-core 就能解决。但 tokio 在存储路径上还有一层更深的边界,它来自 I/O 模型本身。
tokio 底层基于 epoll。epoll 的工作方式是监听文件描述符的“就绪”状态:fd 可读或可写时通知应用层。这对网络 socket 是完美适配——TCP 连接确实有就绪状态。但 Linux 对普通文件(块设备上的文件)的处理完全不同:普通文件的 fd 总是被认为“已就绪”。epoll 无法感知一次 write() 是否已经真正刷到盘上。
tokio 的解法是把所有文件 I/O 丢给一个内部辅助线程池,操作在池子里同步完成后再把结果传回 async 上下文。这个线程池最多可以扩展到 512 个线程。对于核心路径是连续顺序写磁盘的消息系统来说,这意味着每一笔磁盘 I/O 都要穿过一次线程池的排队和上下文切换。Iggy 在 tokio 上测到的顺序读吞吐峰值是 10–12 GB/s,团队判断这已经接近 tokio + epoll 架构在当前硬件上的极限。
这还不是“实现不够好”的问题。io_uring 采用完成语义(completion-based),应用提交一个操作描述后,缓冲区从提交到内核返回完成通知期间由内核控制,应用不能触碰。而 tokio 的 Future 是 poll 语义,缓冲区的生命周期由 Rust 所有权系统在用户态管理。两种语义在类型层面就存在根本冲突,不是靠工程优化能抹平的。
thread-per-core 是镜像,不是答案
Iggy 选择的迁移方向是 thread-per-core + io_uring:每个线程绑定一个 CPU 核,运行独立的完成语义事件循环,任务在线程之间永不迁移。这直接消除了 cache 失效和调度不确定性,也绕开了 epoll 对块设备的语义错配。迁移后的数据呈现出一种值得注意的模式:吞吐提升是温和的(10–25%),但尾延迟的改善是剧烈的,而且负载越高改善越大。P95/P99 改善 23%–60%,P9999 改善 57%–92%。
但 thread-per-core 不是免费午餐,它只是把边界换了一个位置。thread-per-core 要求任务不跨线程迁移,这意味着任务不再需要 Send,这是它的便利;代价是负载均衡必须由应用层自己承担。如果负载分布本身是倾斜的,固定线程绑定会导致某些核排队而另一些核空闲,CPU 利用率反而可能不如 work-stealing 模型[reference:12]。work-stealing 用迁移代价换来了弹性,thread-per-core 用数据局部性换走了弹性。两者都在自己的边界内成立,越界之后各自的优势就反转成劣势。
边界才是选型的依据
把这条线索串起来,会发现 tokio 在存储路径上的“问题”并不是它做错了什么。work-stealing 被设计来服务的是短任务、高并发、网络 I/O 为主的场景,在那个区间里它是极其出色的调度器。问题出现在场景滑动之后:当任务变长、I/O 变成块设备、尾延迟变成核心指标时,之前被收益掩盖的代价开始主导。
判断一个运行时是否适合自己,比读基准测试的吞吐数字更有用的,是先读它默认服务的是哪一类负载。 work-stealing 的弹性建立在“迁移是便宜的”这个前提上;epoll 的简洁建立在“fd 有就绪语义”这个前提上。前提成立时,它们是正确答案;前提不成立时,它们不是“慢一点”,而是架构层面的不匹配。Iggy 花了将近两年、经过 monoio 和 Glommio 两个中间候选才最终落到 compio,本质上不是在寻找一个“更快的 tokio”,而是在重新选择一套与自己负载的边界对齐的前提。
- 点赞
- 收藏
- 关注作者
评论(0)