(unstable)io-uring
主线位置:不在两条主线上。它是挂在 IO 驱动旁边的另一种模型,只服务
tokio::fs。本页只讲它要解决什么、1.54.1 做到了哪一步。
flowchart TB
call["fs::read 等"] --> init{"ring 可用且支持该操作?"}
init -->|否| blk[spawn_blocking 老路]
init -->|是| op["Op future:首次 poll"]
op --> reg["register_op:放进 SQ,立即 submit"]
reg --> pend[返回 Pending]
pend --> kern[内核完成,写 CQE]
kern --> fd["ring fd 可读,mio 报告 Token(0)"]
fd --> turn["turn():dispatch_completions"]
turn --> wake[按下标找到 waker,wake]
wake --> op2["Op 再次 poll:取走 CQE"]
前几章的反应器像食堂叫号。屏幕上跳出你的号(就绪事件),你还得自己走到窗口去端(自己调 read),去晚了可能扑空(WouldBlock)。
io_uring 像点外卖。你在单子上写清楚要什么、放进哪个盘子(SQE 里的 fd 和缓冲区地址),订单号就是前台登记簿上的行号(user_data 是 ops 这个 slab 的下标)。外卖员把东西放进你的盘子,在取餐架上留一张小票(CQE)。门铃只有一个,响了叫醒的是前台(ring 的 fd 用 Token(0) 注册进 mio)。前台(turn() 里的 dispatch_completions)按小票上的订单号翻登记簿,把那一行从「等待中」(Waiting(waker))改成「已送达」(Completed(cqe)),再去敲你的门(wake)。
你中途不想要了(Op 被 drop),外卖已经在路上,撤不回来(不提交 IORING_OP_ASYNC_CANCEL)。盘子也不能收走,外卖员还要往里放东西,只好把盘子交给前台保管(Cancelled(CancelData)),等小票到了由前台收拾。
比方到这里为止:真外卖员送到就直接敲你家门。这里门铃只叫醒前台,而且要有线程进了 turn() 才会有人去翻小票;worker 忙的时候,每 61 个任务才看一眼取餐架。
要解决的问题
前面几章的反应器是就绪模型:内核告诉你「现在可以读了」,读这个动作还是你自己调系统调用去做。这对 socket 很合适,对普通文件不行:epoll 不接受普通文件(epoll_ctl 对它返回 EPERM),磁盘读写也没有「就绪」可言,一次 read 该等多久就等多久。
所以 tokio::fs 默认全部走阻塞线程池。比如 fs::read 最后落到 read_spawn_blocking,也就是 asyncify(move || std::fs::read(path)),一次文件操作就是一次 spawn_blocking:任务交给另一个线程,做完再通过 JoinHandle 唤醒回来(见阻塞线程池)。
io_uring 是完成模型:把「读这个 fd 的这段到这块内存」写成一个提交项(SQE)放进和内核共享的提交队列,内核做完后往完成队列放一个完成项(CQE)。没有额外的线程,也没有线程间交接。
怎么打开
所有相关代码都包在 cfg_io_uring! 里:
#[cfg(all(
tokio_unstable,
feature = "io-uring",
feature = "rt",
feature = "fs",
target_os = "linux",
))]
五个条件缺一不可:RUSTFLAGS="--cfg tokio_unstable"、io-uring feature、rt、fs,并且目标是 Linux。io-uring 这个依赖 crate(0.7.11)在 Cargo.toml 里只出现在 cfg(all(tokio_unstable, target_os = "linux")) 的 target 依赖下,别的平台上开了 feature 也拉不进来。
io-uring 一开始不是 Cargo feature,而是一个 cfgio_uring 的基础代码随 #7320 进了 1.46.0。那时打开它靠 RUSTFLAGS="--cfg tokio_uring",io-uring、slab 等依赖写在 [target.'cfg(all(tokio_uring, target_os = "linux"))'.dependencies] 下,而且不是 optional。issue #7546 报告(当时是 1.47.1,在 macOS 上):从这次提交起,不管开不开 feature,tokio 在 lockfile 里的依赖都多了 io-uring,这会干扰 vendoring、漏洞跟踪和 SBOM。#7621(1.48.0)改成现在的样子:一个要配合 tokio_unstable 才能用的 io-uring feature,target 依赖全部标成 optional。
Builder 上有一个 enable_io_uring,但它只做了一件事:
pub fn enable_io_uring(&mut self) -> &mut Self {
// Currently, the uring flag is equivalent to `enable_io`.
self.enable_io = true;
self
}
只要编译条件满足、运行时开了 IO 驱动,fs 就会尝试用 io_uring。ring 本身是懒创建的:第一次有文件操作调到 check_and_init 时,才 io_uring_setup 一个 DEFAULT_RING_SIZE = 256 项的 ring,并用 probe 查一遍内核支持哪些操作码。结果存进一个 OnceCell,之后每次只查表。
match self.try_init(&mut probe) {
Ok(()) => Ok(Some(probe)),
// If the system doesn't support io_uring, we set the probe to `None`.
Err(e) if e.raw_os_error() == Some(libc::ENOSYS) => Ok(None),
// If we get EPERM, io-uring syscalls may be blocked (for example, by seccomp).
// In this case, we try to fall back to spawn_blocking for this and future operations.
// See also: https://github.com/tokio-rs/tokio/issues/7691
Err(e) if e.raw_os_error() == Some(libc::EPERM) => Ok(None),
// For other system errors, we just return it.
Err(e) => Err(e),
}
内核太老(ENOSYS)或者被 seccomp 拦了(EPERM,容器里常见),都记成「不支持」,回到 spawn_blocking。某个操作码内核不支持,也回到老路。
1.54.1 用到了哪些操作
| 入口 | 操作码 | 说明 |
|---|---|---|
OpenOptions::open | OpenAt | 编译条件满足时 OpenOptions::new 直接构造 Kind::Uring |
fs::read | Read(加 OpenAt、Statx) | 额外要求 target_env = "gnu" 或 Android,因为要用 statx 拿文件大小 |
fs::write | Write(加 OpenAt) | write_uring 循环 Op::write_at 直到写完 |
fs::rename | RenameAt | |
fs::try_exists | Statx | 同样要求 gnu 或 Android |
File 的读(poll_read_inner) | Read | ring 还没探测过时,先 spawn 一个任务去初始化 |
File 的写、fs::metadata、目录操作等其余 API 仍然全走 spawn_blocking。网络 socket 完全不经过 io_uring,还是前几章的 mio。
和驱动怎么接
ring 放在 IO 驱动的 Handle 里,外面一把 Mutex:
pub(crate) struct UringContext {
pub(crate) uring: Option<io_uring::IoUring>,
pub(crate) ops: slab::Slab<Lifecycle>,
}
- 提交。
register_op在ops里占一个槽,槽下标写进 SQE 的user_data,推进提交队列,然后马上submit(一次io_uring_enter)。注释写明:「目前每个 entry 立即提交,没有做批量」。 - 完成通知。 ring 初始化时,它的 fd 用
Token(0)注册进 mio(add_uring_source)。这是驱动的唤醒 token:完成队列里有新 CQE 时 ring fd 变成可读,睡在epoll_wait上的线程返回,turn()的分发循环对Token(0)照例什么也不做。 - 分发。
turn()在处理完 mio 事件后,无条件拿锁调dispatch_completions:遍历完成队列,按user_data找到槽,把Waiting(waker)换成Completed(cqe)并唤醒。如果完成队列溢出过,就反复submit加分发,直到内核缓冲的溢出项都取完。
所以 io_uring 的完成和 socket 事件走同一条送达路径:都要等某个线程进 turn()。忙的 worker 同样是每 61 个任务查一次(见反应器)。
Op:一次操作的状态机
每个操作是一个 Op<T> future,T 持有这次操作要用的 fd 和缓冲区。驱动那边每个槽是一个 Lifecycle:
stateDiagram-v2 [*] --> Waiting: register_op Waiting --> Waiting: 再次 poll,换 waker Waiting --> Completed: dispatch_completions Completed --> [*]: Op poll 取走 CQE,删槽 Waiting --> Cancelled: Op 被 drop Completed --> [*]: Op 被 drop,直接删槽 Cancelled --> [*]: CQE 到达,删槽
难点在取消。io_uring 的 SQE 里放的是裸指针:缓冲区地址、路径字符串地址。SQE 交给内核后,内核随时可能往这块内存里写。如果 Op 被 drop(比如 select! 里另一个分支先完成),缓冲区跟着释放,内核就会写进已释放的内存。
cancel_op 的做法是把 T 的资源交给驱动:Op 的 Drop 调它,把 CancelData(缓冲区、fd、路径)放进槽里,状态改成 Cancelled。之后 dispatch_completions 收到这个槽的 CQE 时才删槽、释放资源;如果是被取消的 open,还要把内核返回的新 fd 关掉,否则泄漏。它不提交 IORING_OP_ASYNC_CANCEL,内核里的操作照常做完,只是结果没人要。
第一次在 open 操作合入之前。#7436(1.47.0)描述的情形是:操作已经完成,驱动取走了 CQE,把槽改成 Completed;Op 还没来得及再被 poll,就因为运行时关闭之类的原因被 drop,槽被改成 Cancelled。接着 UringContext 的 Drop 要等每个 Cancelled 槽的 CQE,可这个 CQE 早被驱动取走了,submit_and_wait(1) 就一直等下去。修法是 cancel_op 碰到 Completed 直接删槽,就是上面状态图里「Completed → Op 被 drop,直接删槽」那条边。
第二次是 issue #7979:被取消的 open 泄漏 fd。只有 Open::complete 会把 CQE 里的新 fd 收进一个 File;Op 被 drop 之后没人再调它,驱动收到 CQE 时把结果直接丢掉,fd 就一直开着。报告给的影响是:能触发并取消 io_uring 文件 open 的攻击者可以耗尽进程的 fd,评级为低。#7983(1.51.1)让驱动收到被取消的 open 的 CQE 时,把成功返回的 fd 关掉,也就是上一段说的那一步。
运行时关闭时,UringContext 的 Drop 先提交队列里剩下的 SQE,再 submit_and_wait(1) 循环,直到每个未完成的操作都等到 CQE。这一步会阻塞关闭流程。
现在的局限
都来自 1.54.1 的源码:
- 只在 Linux、只在
tokio_unstable下。 API 和行为都可能在小版本之间变化。 - 只覆盖上表几个文件操作。
File的写、元数据、目录操作还是spawn_blocking;网络 IO 不用它。 - 每个操作单独一次
io_uring_enter。 没有批量提交,io_uring 最大的卖点之一(一次系统调用提交很多操作)还没用上。 - 一个运行时一个 ring,一把锁。 提交、分发、
Op的每次 poll 都要拿uring_context这把Mutex,多个 worker 并发做文件 IO 时会在这里排队。 - ring 大小固定 256。 提交队列满了就先
submit腾空;完成队列满了就先分发。没有配置项。 - 取消不打断内核操作。 被取消的操作照常完成,资源一直占到 CQE 到达;关闭时要等所有在途操作。
小结
- io_uring 解决的是文件 IO 没有「就绪」可等、只能扔进阻塞线程池的问题:提交操作,内核做完再通知。
- 需要
tokio_unstable+io-uringfeature +rt+fs+ Linux。enable_io_uring等同于enable_io;ring 在第一次文件操作时懒创建,不支持就退回spawn_blocking。 - 1.54.1 覆盖 open、read、write、rename、statx 和
File的读。 - ring 的 fd 借驱动的唤醒 token 注册进 mio,完成事件在
turn()里分发,和 socket 事件走同一条路径。 - 被取消的操作把缓冲区和 fd 交给驱动保管,直到内核交回 CQE。