Tokio 调度抖动与 io_uring:从 Iggy 的探索到 RobustMQ 的思考
背景
在对 RobustMQ MQTT Broker 进行压测时,我们注意到一个奇怪的现象:meta-service 的 raft apply 路径中,set_last_applied 步骤的耗时极不稳定——有时不到 1ms,有时突增到 5~11ms。而这一步做的事情非常简单:往 RocksDB 写一条记录。
RocksDB 的监控指标显示,实际写入耗时始终在 1ms 以内。
这两个数字之间的差距,指向的不是存储本身,而是 tokio 异步运行时的调度抖动。
在研究这个问题的过程中,我们发现 Apache Iggy(一个用 Rust 编写的开源消息流系统)在 2024~2026 年间经历了一次从 tokio 到 io_uring 的系统级架构迁移,留下了非常详细的技术分析和性能数据。他们遇到的问题和我们观察到的现象高度重合。这篇文章整理了 Iggy 的探索过程、RobustMQ 目前的观察,以及我们的思考。
参考文章:
- Iggy.rs — one year of building the message streaming(2024.05)
- Iggy.rs - Technology Radar & current goals(2024.10)
- Building WebSocket Protocol in Apache Iggy using io_uring and Completion Based I/O Architecture(2025.11)
- Thread-per-core architecture powered by io_uring(2026.02)
Iggy 是什么
Apache Iggy 是一个用 Rust 编写的高性能消息流平台,目标是提供类似 Kafka 的持久化消息流能力,但追求更低的延迟和更高的吞吐。核心路径是顺序写本地文件:producer 写入 → 持久化到 segment 文件 → consumer 消费。这个路径对 I/O 延迟极度敏感,任何抖动都会直接反映到端到端延迟上。
Iggy 遇到的问题
问题一:tokio work-stealing 调度器的固有代价
tokio 的多线程执行器使用 work-stealing 调度策略。每个 worker 线程维护一个本地任务队列,当本地队列空时,线程会去其他线程的队列里"偷"任务。这个设计对通用 Web 服务效果极好——它能自动平衡负载,让 CPU 利用率维持在高位。
但对于高吞吐存储系统,work-stealing 有两个内生代价:
CPU cache 失效:当一个 task 在 worker A 上执行到 await 挂起,又被 worker B 偷走继续执行时,task 所访问的数据(栈帧、局部变量、关联的数据结构)已经在 worker A 的 L1/L2 cache 里。换到 worker B 后,这些数据对 worker B 来说是冷的,必须从更慢的 L3 cache 或主存重新加载。对于一个高频次执行存储操作的系统,这种 cache miss 的积累是显著的。
调度时机不可控:task 在 await 之后何时被调度、被哪个线程调度,由运行时的内部状态决定,应用层完全不可见。当系统中有大量 task 竞争 worker 线程时(比如 1000 个并发连接同时触发存储路径),一个 task 从 await 挂起到重新被调度执行的等待时间可以是几毫秒,而这段等待时间会被计入任何在 await 之后出现的操作耗时里。
Iggy 团队对此的描述是:"高性能系统很快会耗尽这种线程池的能力",以及"我们缺乏对调度行为的控制"。
问题二:tokio 无法对块设备做真正的异步 I/O
这是一个更根本的架构问题。
tokio 底层基于 epoll(Linux)。epoll 的工作原理是监听文件描述符的"就绪"状态,当 fd 可读或可写时通知应用层。这对网络 socket 是完美适配的——TCP 连接确实有就绪状态,数据到达时 fd 变为可读,发送缓冲区有空间时 fd 变为可写。
但 Linux 对普通文件(块设备上的文件)的处理完全不同:普通文件的 fd 总是被认为"已就绪"。这意味着 epoll 无法感知文件 I/O 的真实完成时机——向一个文件发起 write() 调用,epoll 无法告诉你"这次写入已经刷到盘了"。
tokio 的解法是:将所有文件 I/O 操作(tokio::fs::write、spawn_blocking 里的同步 I/O)丢给一个内部辅助线程池执行。这个线程池是阻塞的,操作在里面同步完成后,结果再传回 async context。这个线程池最多可以扩展到 512 个线程。
对于一个消息 broker,核心路径就是持续地顺序写磁盘文件。用一个 512 线程上限的辅助线程池来处理全部磁盘 I/O,不仅引入了线程切换和上下文切换的开销,更设置了一个明确的扩展天花板。当并发写入量上来后,线程池本身会成为瓶颈,产生排队延迟。
Iggy 在 tokio 上测量到的顺序读吞吐峰值是 10~12 GB/s,已经在逼近这个架构的极限。
问题三:poll-based 与 completion-based 模型的根本不兼容
io_uring 是 Linux 5.1 引入的高性能 I/O 接口,它的设计思路与 epoll 完全不同。
epoll 是 readiness-based(就绪通知):
- 注册感兴趣的 fd
- epoll 告诉你"这个 fd 现在可以操作了"
- 你发起系统调用完成操作
io_uring 是 completion-based(完成通知):
- 你把操作描述(做什么、用哪个 buffer)提交到 submission queue
- 内核异步执行这个操作
- 完成后把结果放入 completion queue 通知你
这个差异在内存缓冲区的所有权上体现得最为关键。io_uring 要求:从操作提交到内核,到内核返回完成通知,这段时间内缓冲区由内核控制,应用层不能碰。而 tokio 的 Future 模型是基于 poll 的,缓冲区的生命周期由 Rust 的所有权系统管理,与 io_uring 的内核持有语义天然冲突。
要在 tokio 上接入 io_uring,必须在 Rust 类型系统层面做大量妥协和适配(比如用 Arc<[u8]> 共享所有权、或者在 unsafe 代码里手动管理生命周期),最终能使用的 io_uring 特性也是受限的。这不是工程上能优化绕过去的,是两种设计哲学的根本冲突。
Iggy 的探索过程
Iggy 花了将近两年时间,经历了四个阶段,才完成这次迁移。
阶段一:在 tokio 上触及天花板(2024 上半年)
在正常的工程优化路径(减少锁竞争、批量写入、优化序列化等)穷尽之后,Iggy 的顺序读吞吐稳定在 10~12 GB/s。团队判断这已经接近 tokio + epoll 架构在当前硬件上的极限,继续优化的边际收益几乎为零,开始系统评估替代方案。
阶段二:monoio 概念验证——证明方向可行(2024 下半年)
第一个验证对象是字节跳动开源的 monoio。
monoio 的设计思路是:抛弃 work-stealing,改为 thread-per-core——每个线程绑定到一个 CPU core,独立运行一个基于 io_uring 的事件循环,线程之间不做任务迁移。这彻底消除了 cache 失效和调度不确定性问题。同时,它直接使用 io_uring 而不是 epoll,文件 I/O 是真正的异步完成通知,不需要辅助线程池。
Iggy 把服务器核心路径移植到 monoio 后,顺序读吞吐从 10~12 GB/s 提升到 15+ GB/s,幅度超过 25%。这个数据证明了方向的可行性。
但 monoio 本身有明显的局限:对 io_uring 特性集的支持不完整(很多高级操作无法使用),且在字节跳动之外缺乏社区维护。Iggy 团队不愿把核心架构押注在一个可能停止维护的依赖上。
阶段三:Glommio——技术上成熟,但维护已停滞(2024 下半年)
第二个评估对象是 DataDog 开源的 Glommio。
Glommio 的技术背景更为扎实:原作者 Glauber Costa 曾主导过 Linux 内核的 cgroup 工作,Glommio 的设计直接受 Seastar(C++ 高性能异步框架,ScyllaDB 的底层运行时)启发,同样是 thread-per-core + io_uring 架构,并且有任务优先级、共享内存池等生产级特性。
从纯技术角度看,Glommio 是这几个选项里最成熟的。
但 Glauber Costa 离开 DataDog 加入 Turso(一家数据库公司)之后,Glommio 的维护基本停滞,PR 堆积,issue 无人响应。在 2024 年底,这个项目处于事实上的无人维护状态。Iggy 拒绝了这个选项。
阶段四:最终选择 compio——活跃维护 + 架构解耦(2025~2026)
最终选择是 compio,一个相对较新(2023 年开始)的 Rust 异步运行时。
compio 的核心架构特点:
- completion-based I/O:基于 io_uring(Linux)和 IOCP(Windows),不是 epoll 的封装
- executor 与 I/O driver 解耦:执行器和底层 I/O 机制分离,可以独立替换,未来切换到更新的 io_uring 特性不需要改应用层代码
- 跨平台:Linux 用 io_uring,Windows 用 IOCP,macOS 用 kqueue(降级兼容),同一套接口
- 活跃维护:有持续的贡献者和版本迭代
最终的系统架构是:compio + io_uring + thread-per-core shared-nothing。
每个线程绑定到一个物理 CPU core(通过 CPU affinity),网络连接和存储 partition 按一致性 hash 分配到固定线程,线程之间不共享任何数据结构,不需要任何锁。这个设计和 ScyllaDB、Redpanda 的架构哲学一脉相承——用无共享(shared-nothing)消除并发原语的开销,用线程亲和(thread affinity)保持 cache 热度。
迁移后的性能数据
以下数据来自 2026 年 2 月的测试报告,测试环境为 AWS,目标吞吐 1,000 MB/s,启用 fsync 保证每条消息持久化:
| 场景 | 指标 | Tokio | compio/io_uring | 改善幅度 |
|---|---|---|---|---|
| 8 生产者 × 8 流 | P999 延迟 | 2.36 ms | 1.81 ms | 23% |
| 8 生产者 × 8 流 | P9999 延迟 | 34.00 ms | 6.51 ms | 81% |
| 16 生产者 × 16 流 | P95 延迟 | 2.52 ms | 1.82 ms | 28% |
| 16 生产者 × 16 流 | P99 延迟 | 3.01 ms | 2.05 ms | 32% |
| 16 生产者 × 16 流 | P9999 延迟 | 86.30 ms | 7.17 ms | 92% |
| 32 生产者 × 32 流 | P95 延迟 | 3.77 ms | 1.62 ms | 57% |
| 32 生产者 × 32 流 | P99 延迟 | 4.52 ms | 1.82 ms | 60% |
| 32 生产者 × 32 流 | P9999 延迟 | 27.52 ms | 11.83 ms | 57% |
| 16 分区 + fsync | 吞吐 | 843 MB/s | 992 MB/s | 18% |
| 16 分区 + fsync | P95 延迟 | 18.00 ms | 9.98 ms | 45% |
关键规律:吞吐提升有限(10~25%),但尾延迟大幅改善,且负载越高改善越显著。
P95、P99 的改善在 23%~60% 之间,P9999 的改善高达 57%~92%。这个规律非常符合预期:tokio work-stealing 在低并发下调度抖动不明显,在高并发(32 生产者 × 32 流)下尾延迟急剧恶化;而 thread-per-core 模型的延迟分布更窄,不受并发数增长的影响。
早期 monoio 概念验证(2024)的数据更直接:顺序读吞吐从 10~12 GB/s 跳到 15+ GB/s,说明 io_uring 在纯吞吐场景下的优势更明显。之所以 compio 最终数据里吞吐提升不大,是因为测试场景加入了 fsync,瓶颈转移到了磁盘持久化延迟,而不是 I/O 吞吐本身。
RobustMQ 观察到的现象
RobustMQ 的 meta-service 基于 openraft 实现分布式共识,底层用 RocksDB 持久化 raft 日志和状态机数据。在 MQTT Broker 的连接压测(mqttx bench conn -c 1000)中,我们在 raft apply 路径上加了精细的逐步计时,发现以下现象。
现象一:apply 路径中 set_last_applied 耗时极度抖动
WARN meta_service::raft::store::state: [data_0] apply batch slow: total=11.07ms route=0.07ms membership=0.00ms set_last_applied=11.00ms
WARN meta_service::raft::store::state: [data_0] apply batch slow: total=6.29ms route=0.05ms membership=0.00ms set_last_applied=6.24ms
WARN meta_service::raft::store::state: [data_0] apply batch slow: total=8.76ms route=0.05ms membership=0.00ms set_last_applied=8.71ms
WARN meta_service::raft::store::state: [data_0] apply batch slow: total=5.29ms route=0.86ms membership=0.00ms set_last_applied=4.43ms
WARN meta_service::raft::store::state: [data_0] apply batch slow: total=10.10ms route=0.07ms membership=0.00ms set_last_applied=10.02ms
WARN meta_service::raft::store::state: [data_0] apply batch slow: total=5.56ms route=0.03ms membership=0.00ms set_last_applied=5.54ms
WARN meta_service::raft::store::state: [data_0] apply batch slow: total=5.08ms route=0.05ms membership=0.00ms set_last_applied=5.03ms日志中三个字段的含义:
route:实际的业务处理(解码请求、更新缓存、写 RocksDB 存储数据)membership:raft 成员变更处理set_last_applied:将当前 log index 写入 RocksDB,记录状态机已应用到哪条日志
从数字上看,route 耗时 0.03~0.86ms,set_last_applied 耗时 4~11ms。前者是真正的业务逻辑,后者只是一条 put_cf 写入。
现象二:connect 路径中 session_process 稳定慢
同一时间段,MQTT Broker 端的慢请求日志:
WARN mqtt_broker::mqtt::connect: [connect] slow connect_id=5922 total=30.61ms get_cluster=0.00ms check_limit=0.04ms build_conn=0.01ms session_process=30.44ms add_cache=0.04ms st_report=0.02ms
WARN mqtt_broker::mqtt::connect: [connect] slow connect_id=18258 total=31.47ms get_cluster=0.00ms check_limit=0.04ms build_conn=0.01ms session_process=31.32ms add_cache=0.02ms st_report=0.02ms
WARN mqtt_broker::mqtt::connect: [connect] slow connect_id=19237 total=51.11ms get_cluster=0.00ms check_limit=0.05ms build_conn=0.02ms session_process=50.91ms add_cache=0.03ms st_report=0.03ms
WARN mqtt_broker::mqtt::connect: [connect] slow connect_id=19238 total=51.22ms get_cluster=0.00ms check_limit=0.04ms build_conn=0.01ms session_process=51.08ms add_cache=0.02ms st_report=0.03mssession_process 稳定在 30~51ms,其他步骤合计不超过 1ms。session_process 的实现是向 meta-service 发送 gRPC 请求,等待 raft 共识完成并返回。
根因分析
把两个日志放在一起看,链路就清晰了:
MQTT connect → session_process (gRPC) → meta-service → raft apply → set_last_applied (RocksDB)session_process 的 30~51ms = gRPC 网络往返 + raft 共识延迟 + apply() 执行时间。
而 apply() 内的 set_last_applied 的 5~11ms,是 apply() 被 tokio 调度到 worker 线程的等待时间——不是 RocksDB 慢,是 task 在队列里等待被执行。
为什么是 set_last_applied 而不是 route 慢?
apply() 是一个 async fn,里面有多个 await 点:处理每条日志条目时会 await route 函数。每次 await 后,task 会被暂停并重新进入调度队列。在 1000 个并发 CONNECT 打满 tokio worker 线程的情况下,task 从 await 挂起到重新被调度,可能需要等待数毫秒。
route 是每条日志条目处理时的第一个 await,此时 task 刚被调度到,往往能快速执行。set_last_applied 是所有条目处理完之后的最后一步,此时 task 已经经历了多次 await/重调度,调度等待时间的积累体现在这里最为明显。
这和 Iggy 描述的 work-stealing 调度不确定性完全吻合:测量到的不是操作本身的延迟,而是 tokio 调度队列的等待时间。
我们的想法
Iggy 的探索给了我们一个清晰的问题框架:在高并发存储密集型路径上,tokio work-stealing 的调度抖动是结构性问题,不是在 tokio 内部调参能解决的。
RobustMQ 目前的架构:MQTT Broker 是网络密集型,处理大量并发客户端连接;meta-service 是存储密集型,所有状态变更都要经过 raft 共识写 RocksDB。两者都在同一个 tokio 运行时里,资源竞争在高并发下会相互放大。
我们目前观察到的抖动(5~11ms 的调度等待)是在 1000 个并发连接这个相对低的负载下产生的。随着连接数增长,worker 线程竞争会加剧,尾延迟的分布会更宽,最坏情况会更差。Iggy 的数据已经证明了这个趋势——P9999 在高负载下恶化最为剧烈。
thread-per-core + io_uring 的方向是否适合 RobustMQ,我们还没有结论。这是一次系统级的架构迁移,涉及运行时替换、网络层重写、存储层适配,Iggy 花了两年时间。我们目前处于观察和理解问题的阶段,需要更多的压测数据和更深入的分析,再决定是否以及如何走这条路。
