Tokio io_uring 支持异步 I/O 在 Linux 上的新路径一、epoll 挺好但还不够Tokio 是目前 Rust 异步生态的绝对核心。它的TcpListener、File、定时器这些组件让异步 I/O 在 Rust 里变得和同步 I/O 一样顺手。但如果你把它的底层引擎拆开来看会发现 Tokio在 Linux 上依赖的是 epoll——一种问世于 2002 年的 I/O 多路复用系统调用。epoll 的设计思路是用一个事件通知机制告诉应用层这个 fd 可读了。每次读写实际还是需要真正的系统调用——read()、write()、accept()——来把数据从内核空间拷贝到用户空间。在网络场景下epoll 表现已经足够好但在磁盘 I/O 密集的场景下频繁的用户态与内核态切换、缓冲区拷贝就开始成为瓶颈。一个参考数字在大量小文件的随机 4KB 读取场景中epoll 常规preadv的 IOPS 大约在 15 万到 20 万左右而 io_uring 可以做到 80 万以上。差距不是百分之几十而是几倍。这就是 io_uring 被引入 Linux 内核5.1 版本的动机——用一种全新的方式组织异步 I/O。二、epoll 和 io_uring 走的根本不是同一条路理解 io_uring 和 epoll 的区别最直观的方法是看它们的数据流动路径。epoll 的流程是事件通知 独立系统调用先知道可以读了再发一次系统调用去读。而 io_uring 把请求和完成合并进了一对共享内存环形缓冲区Submission Queue 和 Completion Queue应用和内核直接通过内存读写来交互省掉了大量重复的系统调用。sequenceDiagram participant App as 用户程序 participant Kernel as 内核 rect rgb(240, 248, 255) Note over App,Kernel: epoll 路径 — 三次系统调用 一次数据拷贝 App-Kernel: 1. epoll_wait() — 等待事件通知 Kernel--App: 返回可读/可写的 fd 列表 App-Kernel: 2. read(fd, buf) — 发起读请求 Kernel-Kernel: 从磁盘/网卡获取数据 Kernel--App: 拷贝数据到用户空间缓冲区 end rect rgb(255, 250, 240) Note over App,Kernel: io_uring 路径 — 零额外系统调用 共享内存 App-App: 1. 将 I/O 请求写入 SQ\n(Submission Queue — 共享内存) App-App: 2. 可选: io_uring_enter()\n提交一批请求(一次调用) Kernel-Kernel: 3. 内核直接读取 SQ\n处理所有 I/O 请求 Kernel-App: 4. 结果写入 CQ\n(Completion Queue — 共享内存) App-App: 5. 用户态直接读取 CQ\n获取完成结果 end关键的区别有两点。第一io_uring 允许批量提交和批量完成epoll 在每次epoll_wait返回后需要逐个read处理。第二io_uring 可以通过IORING_SETUP_SQPOLL开启内核轮询模式——内核的一个独立线程会自动检查 SQ 队列应用层连io_uring_enter系统调用都可以省掉。在低延迟场景下这个模式能把 tail latency 压到稳定在几十微秒级别。三、用 tokio-uring 替换传统 Tokio I/Ouse std::fs::File; use std::io; // 添加依赖: tokio-uring 0.5 use tokio_uring::fs::File as UringFile; /// 传统的 Tokio epoll 方式 — 每个读请求一次系统调用 async fn read_with_epoll(path: str) - io::ResultVecu8 { use tokio::io::AsyncReadExt; let mut file tokio::fs::File::open(path).await?; let mut buf Vec::new(); file.read_to_end(mut buf).await?; Ok(buf) } /// io_uring 方式 — 零拷贝读取 async fn read_with_uring(path: str) - io::ResultVecu8 { // 打开文件 (io_uring 兼容 O_DIRECT 和普通模式) let file UringFile::open(path).await?; // 获取文件大小, 用于预分配缓冲区 let metadata std::fs::metadata(path)?; let len metadata.len() as usize; // 分配固定缓冲区 — 避免内核和用户态之间的内存拷贝 let buf vec![0u8; len]; let (res, _file, buf) file.read_at(buf, 0).await; // 返回所有权链 let n res?; let mut result buf; result.truncate(n); Ok(result) } /// 批量读取多个文件 — io_uring 的优势场景 async fn batch_read_with_uring(paths: [str]) - io::ResultVecVecu8 { // 并行打开所有文件 let open_futures: Vec_ paths .iter() .map(|p| UringFile::open(p)) .collect(); let files futures::future::join_all(open_futures) .await .into_iter() .collect::ResultVec_, _()?; // 并行读取所有文件 let read_futures: Vec_ files .iter() .map(|file| { let buf vec![0u8; 4096]; // 简化: 假设每个文件 ≤ 4KB file.clone().read_at(buf, 0) }) .collect(); let results futures::future::join_all(read_futures).await; results .into_iter() .map(|(res, _, buf)| res.map(|n| { let mut b buf; b.truncate(n); b })) .collect() } fn main() - io::Result() { // tokio-uring 需要在自己的 runtime 中启动 tokio_uring::start(async { let data read_with_uring(/etc/hostname).await?; println!(读取: {} 字节, data.len()); Ok(()) }) }需要注意的是tokio-uring和标准的tokio不共享同一个 runtime。tokio-uring必须通过tokio_uring::start()启动它自己的运行时因为它的底层完全绕开了 epoll直接操作 io_uring 的 SQ 和 CQ。如果你的程序既需要网络 I/OTokio 擅长又需要磁盘 I/Oio_uring 擅长可以在 Tokio 的 runtime 中 spawn 一个单独的线程来运行 tokio-uring两边通过 channel 交换数据。四、io_uring 的边界——不是什么场景都该用第一个也是最硬的限制只支持 Linux而且需要内核 5.1 及以上。对于需要跨平台的 Rust 项目io_uring 不能作为唯一的 I/O 后端。如果在 macOS 或 Windows 上编译需要回退到标准的 Tokio 异步 I/O。第二个限制是内核版本的兼容性。Linux 5.1 引入了基础 io_uring但很多高级特性是后续版本才加入的——比如IORING_SETUP_SQPOLL需要 5.11、固定缓冲区支持在 5.6 之后才稳定。如果你在 Docker 容器中部署还要检查宿主机的内核版本因为容器共享宿主内核。第三个是复杂性。io_uring 的 API 数量远超 epoll队列管理、固定缓冲区注册、eventfd 集成、多线程共享队列——这些概念的学习成本不低。对大多数应用来说epoll 能搞定的场景就不要为了追求数字好看而上 io_uring。只有当你的 load 图上磁盘 I/O 成了最粗的那条线再来考虑迁不迁移。还有一个最近社区在讨论的问题io_uring 的安全性。因为内核态和用户态共享了环形缓冲区如果应用有漏洞导致 SQ/CQ 区域被污染攻击者可能构造假的 I/O 请求让内核执行恶意操作。Linus 本人也多次在邮件列表中强调 io_uring 需要更严格的安全审查。如果你在构建一个需要高隔离性的系统这些都值得认真评估。五、总结io_uring 不是在替代 epoll而是在解决 epoll 解决不了的那类问题——高并发磁盘 I/O、极低延迟、批量操作。对于绝大多数 Rust 后端服务来说Tokio 默认的 epoll 后端加上spawn_blocking已经能处理大部分磁盘操作。io_uring 更像是留给那些已经把所有优化手段都试过IOPS 图还是不够看的项目的。但作为 Rust 开发者理解 io_uring 是迟早的事。Linux 内核团队在 6.x 系列中持续扩大 io_uring 的操作覆盖面未来它可能会成为 Linux 上默认的异步 I/O 原语。现在把概念搞清楚等那个时候到来时就不会措手不及了。
Tokio io_uring 支持:异步 I/O 在 Linux 上的新路径
发布时间:2026/7/9 2:05:08
Tokio io_uring 支持异步 I/O 在 Linux 上的新路径一、epoll 挺好但还不够Tokio 是目前 Rust 异步生态的绝对核心。它的TcpListener、File、定时器这些组件让异步 I/O 在 Rust 里变得和同步 I/O 一样顺手。但如果你把它的底层引擎拆开来看会发现 Tokio在 Linux 上依赖的是 epoll——一种问世于 2002 年的 I/O 多路复用系统调用。epoll 的设计思路是用一个事件通知机制告诉应用层这个 fd 可读了。每次读写实际还是需要真正的系统调用——read()、write()、accept()——来把数据从内核空间拷贝到用户空间。在网络场景下epoll 表现已经足够好但在磁盘 I/O 密集的场景下频繁的用户态与内核态切换、缓冲区拷贝就开始成为瓶颈。一个参考数字在大量小文件的随机 4KB 读取场景中epoll 常规preadv的 IOPS 大约在 15 万到 20 万左右而 io_uring 可以做到 80 万以上。差距不是百分之几十而是几倍。这就是 io_uring 被引入 Linux 内核5.1 版本的动机——用一种全新的方式组织异步 I/O。二、epoll 和 io_uring 走的根本不是同一条路理解 io_uring 和 epoll 的区别最直观的方法是看它们的数据流动路径。epoll 的流程是事件通知 独立系统调用先知道可以读了再发一次系统调用去读。而 io_uring 把请求和完成合并进了一对共享内存环形缓冲区Submission Queue 和 Completion Queue应用和内核直接通过内存读写来交互省掉了大量重复的系统调用。sequenceDiagram participant App as 用户程序 participant Kernel as 内核 rect rgb(240, 248, 255) Note over App,Kernel: epoll 路径 — 三次系统调用 一次数据拷贝 App-Kernel: 1. epoll_wait() — 等待事件通知 Kernel--App: 返回可读/可写的 fd 列表 App-Kernel: 2. read(fd, buf) — 发起读请求 Kernel-Kernel: 从磁盘/网卡获取数据 Kernel--App: 拷贝数据到用户空间缓冲区 end rect rgb(255, 250, 240) Note over App,Kernel: io_uring 路径 — 零额外系统调用 共享内存 App-App: 1. 将 I/O 请求写入 SQ\n(Submission Queue — 共享内存) App-App: 2. 可选: io_uring_enter()\n提交一批请求(一次调用) Kernel-Kernel: 3. 内核直接读取 SQ\n处理所有 I/O 请求 Kernel-App: 4. 结果写入 CQ\n(Completion Queue — 共享内存) App-App: 5. 用户态直接读取 CQ\n获取完成结果 end关键的区别有两点。第一io_uring 允许批量提交和批量完成epoll 在每次epoll_wait返回后需要逐个read处理。第二io_uring 可以通过IORING_SETUP_SQPOLL开启内核轮询模式——内核的一个独立线程会自动检查 SQ 队列应用层连io_uring_enter系统调用都可以省掉。在低延迟场景下这个模式能把 tail latency 压到稳定在几十微秒级别。三、用 tokio-uring 替换传统 Tokio I/Ouse std::fs::File; use std::io; // 添加依赖: tokio-uring 0.5 use tokio_uring::fs::File as UringFile; /// 传统的 Tokio epoll 方式 — 每个读请求一次系统调用 async fn read_with_epoll(path: str) - io::ResultVecu8 { use tokio::io::AsyncReadExt; let mut file tokio::fs::File::open(path).await?; let mut buf Vec::new(); file.read_to_end(mut buf).await?; Ok(buf) } /// io_uring 方式 — 零拷贝读取 async fn read_with_uring(path: str) - io::ResultVecu8 { // 打开文件 (io_uring 兼容 O_DIRECT 和普通模式) let file UringFile::open(path).await?; // 获取文件大小, 用于预分配缓冲区 let metadata std::fs::metadata(path)?; let len metadata.len() as usize; // 分配固定缓冲区 — 避免内核和用户态之间的内存拷贝 let buf vec![0u8; len]; let (res, _file, buf) file.read_at(buf, 0).await; // 返回所有权链 let n res?; let mut result buf; result.truncate(n); Ok(result) } /// 批量读取多个文件 — io_uring 的优势场景 async fn batch_read_with_uring(paths: [str]) - io::ResultVecVecu8 { // 并行打开所有文件 let open_futures: Vec_ paths .iter() .map(|p| UringFile::open(p)) .collect(); let files futures::future::join_all(open_futures) .await .into_iter() .collect::ResultVec_, _()?; // 并行读取所有文件 let read_futures: Vec_ files .iter() .map(|file| { let buf vec![0u8; 4096]; // 简化: 假设每个文件 ≤ 4KB file.clone().read_at(buf, 0) }) .collect(); let results futures::future::join_all(read_futures).await; results .into_iter() .map(|(res, _, buf)| res.map(|n| { let mut b buf; b.truncate(n); b })) .collect() } fn main() - io::Result() { // tokio-uring 需要在自己的 runtime 中启动 tokio_uring::start(async { let data read_with_uring(/etc/hostname).await?; println!(读取: {} 字节, data.len()); Ok(()) }) }需要注意的是tokio-uring和标准的tokio不共享同一个 runtime。tokio-uring必须通过tokio_uring::start()启动它自己的运行时因为它的底层完全绕开了 epoll直接操作 io_uring 的 SQ 和 CQ。如果你的程序既需要网络 I/OTokio 擅长又需要磁盘 I/Oio_uring 擅长可以在 Tokio 的 runtime 中 spawn 一个单独的线程来运行 tokio-uring两边通过 channel 交换数据。四、io_uring 的边界——不是什么场景都该用第一个也是最硬的限制只支持 Linux而且需要内核 5.1 及以上。对于需要跨平台的 Rust 项目io_uring 不能作为唯一的 I/O 后端。如果在 macOS 或 Windows 上编译需要回退到标准的 Tokio 异步 I/O。第二个限制是内核版本的兼容性。Linux 5.1 引入了基础 io_uring但很多高级特性是后续版本才加入的——比如IORING_SETUP_SQPOLL需要 5.11、固定缓冲区支持在 5.6 之后才稳定。如果你在 Docker 容器中部署还要检查宿主机的内核版本因为容器共享宿主内核。第三个是复杂性。io_uring 的 API 数量远超 epoll队列管理、固定缓冲区注册、eventfd 集成、多线程共享队列——这些概念的学习成本不低。对大多数应用来说epoll 能搞定的场景就不要为了追求数字好看而上 io_uring。只有当你的 load 图上磁盘 I/O 成了最粗的那条线再来考虑迁不迁移。还有一个最近社区在讨论的问题io_uring 的安全性。因为内核态和用户态共享了环形缓冲区如果应用有漏洞导致 SQ/CQ 区域被污染攻击者可能构造假的 I/O 请求让内核执行恶意操作。Linus 本人也多次在邮件列表中强调 io_uring 需要更严格的安全审查。如果你在构建一个需要高隔离性的系统这些都值得认真评估。五、总结io_uring 不是在替代 epoll而是在解决 epoll 解决不了的那类问题——高并发磁盘 I/O、极低延迟、批量操作。对于绝大多数 Rust 后端服务来说Tokio 默认的 epoll 后端加上spawn_blocking已经能处理大部分磁盘操作。io_uring 更像是留给那些已经把所有优化手段都试过IOPS 图还是不够看的项目的。但作为 Rust 开发者理解 io_uring 是迟早的事。Linux 内核团队在 6.x 系列中持续扩大 io_uring 的操作覆盖面未来它可能会成为 Linux 上默认的异步 I/O 原语。现在把概念搞清楚等那个时候到来时就不会措手不及了。