Rust in Everything
基于 tokio 1.54.1

(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"]
💡TIP
打个比方:食堂叫号,和点外卖

前几章的反应器像食堂叫号。屏幕上跳出你的号(就绪事件),你还得自己走到窗口去端(自己调 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 也拉不进来。

📝NOTE
冷知识:io-uring 一开始不是 Cargo feature,而是一个 cfg

io_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::openOpenAt编译条件满足时 OpenOptions::new 直接构造 Kind::Uring
fs::readRead(加 OpenAt、Statx)额外要求 target_env = "gnu" 或 Android,因为要用 statx 拿文件大小
fs::writeWrite(加 OpenAt)write_uring 循环 Op::write_at 直到写完
fs::renameRenameAt
fs::try_existsStatx同样要求 gnu 或 Android
File 的读(poll_read_inner)Readring 还没探测过时,先 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>,
}

(UringContext)

  • 提交。 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,内核里的操作照常做完,只是结果没人要。

📝NOTE
冷知识:取消这条路修过两次

第一次在 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-uring feature + 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。

💬 关联讨论

发起讨论
加载中...