Skip to content

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 是什么

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::writespawn_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(就绪通知):

  1. 注册感兴趣的 fd
  2. epoll 告诉你"这个 fd 现在可以操作了"
  3. 你发起系统调用完成操作

io_uring 是 completion-based(完成通知):

  1. 你把操作描述(做什么、用哪个 buffer)提交到 submission queue
  2. 内核异步执行这个操作
  3. 完成后把结果放入 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 保证每条消息持久化:

场景指标Tokiocompio/io_uring改善幅度
8 生产者 × 8 流P999 延迟2.36 ms1.81 ms23%
8 生产者 × 8 流P9999 延迟34.00 ms6.51 ms81%
16 生产者 × 16 流P95 延迟2.52 ms1.82 ms28%
16 生产者 × 16 流P99 延迟3.01 ms2.05 ms32%
16 生产者 × 16 流P9999 延迟86.30 ms7.17 ms92%
32 生产者 × 32 流P95 延迟3.77 ms1.62 ms57%
32 生产者 × 32 流P99 延迟4.52 ms1.82 ms60%
32 生产者 × 32 流P9999 延迟27.52 ms11.83 ms57%
16 分区 + fsync吞吐843 MB/s992 MB/s18%
16 分区 + fsyncP95 延迟18.00 ms9.98 ms45%

关键规律:吞吐提升有限(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 耗时极度抖动

log
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 端的慢请求日志:

log
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.03ms

session_process 稳定在 30~51ms,其他步骤合计不超过 1ms。session_process 的实现是向 meta-service 发送 gRPC 请求,等待 raft 共识完成并返回。

根因分析

把两个日志放在一起看,链路就清晰了:

text
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 花了两年时间。我们目前处于观察和理解问题的阶段,需要更多的压测数据和更深入的分析,再决定是否以及如何走这条路。

🎉 既然都登录了 GitHub,不如顺手给我们点个 Star 吧!⭐ 你的支持是我们最大的动力 🚀