从 epoll 到 io_uring polling:异步运行时与内核轮询模式的零开销融合
在高性能网络服务领域,Linux io_uring 的到来改变了游戏规则。自 2019 年内核 5.1 引入以来,io_uring 持续进化,buffer 注册、SQPOLL 内核轮询、fixed file 等关键特性已相继进入稳定态。然而,io_uring 的真正威力在于其 polling 模式(IORING_SETUP_SQPOLL + IORING_SETUP_IOPOLL),将 I/O 通路的系统调用推进完全消除。
为什么需要 polling 模式?
传统 epoll + non-blocking I/O 的模型中,一次读写操作涉及三次用户态/内核态切换:epoll_wait、read/readv、epoll_wait。io_uring 的 buffered 模式通过 shared ring buffer 将提交与完成解耦,recv/send 本身不再产生系统调用。但在 SQPOLL 模式下,内核线程主动轮询 SQ(Submission Queue),连 io_uring_enter 系统调用都不需要了。
更进一步的 IOPOLL 让块设备层也绕过 IRQ 机制,由内核线程直接轮询 block completion queue。对 NVMe SSD 来说,单次 I/O 延迟可降至 2μs 以下。
与 async runtime 的结合点
tokio、async-std、smol 等 Rust async runtime 默认基于 epoll 构建 reactor。虽然 tokio-uring crate 提供了基于 io_uring 的 runtime,但它本质上是每个任务提交一个 SQE,没有充分发挥 polling 模式的优势。真正的零开销融合需要我们从 runtime 层面重新设计就绪通知与 I/O 提交的协同。
架构设计
核心状态机
在 polling 模式下,io_uring async runtime 的状态机如下:
Tasks: [Pending] --event--> [Ready] --schedule--> [Running]
^ |
|--completion<----------+
Ring: [SQ] -- kernel poll --> [CQ] -- read by runtime --> (wake tasks)
关键点在于 runtime 不再调用 io_uring_enter 提交工作,而是直接写入 SQ 条目后更新 SQ tail。内核 sqthread 持续轮询 SQ tail,一旦发现新条目就立即下发。
零 syscall 路径
一个请求的完整路径如下:
- 任务就绪,runtime 准备
sendmsg请求 - 直接写入 SQE:
sqe->opcode = IORING_OP_SENDMSG - 更新 SQ tail(一次 atomic store,非 syscall)
- 内核 sqthread 异步拾取 SQE,执行 socket send
- 完成后 CQE 入 CQ
- runtime 在下次遍历 CQ 时发现完成事件,关联任务标记为 Ready
这意味着在 steady-state 下,热路径零系统调用。
实现示例
初始化 io_uring with SQPOLL
use io_uring::{IoUring, Submitter, types};
fn setup_polling_uring() -> io::Result<IoUring> {
// SQPOLL: 内核线程轮询 SQ,免 syscall 提交
// IOPOLL: 块设备层也使用 polling(需要设备支持)
// SQ_AFF: 绑定 sqthread 到指定 CPU
let ring = IoUring::builder()
.setup_sqpoll(1000) // idle 1000ms 后 sqthread 休眠
.setup_sqpoll_cpu(2) // sqthread 在 CPU2 运行
.setup_cqsize(4096) // CQ 大小(>= SQ 大小)
.setup_clamp() // SQ/CQ 不超过 max
.build(256)?; // SQ 深度 256
Ok(ring)
}
注册 buffer 与 fd 消除每次开销
// 预注册固定 buffer,send/recv 直接使用,省去 pin_user_pages
fn register_buffers(submitter: &Submitter, buffers: &[&[u8]]) -> io::Result<()> {
let bufs: Vec<types::BufSlice> = buffers
.iter()
.map(|b| types::BufSlice::from_slice(b))
.collect();
submitter.register_buffers(&bufs)?;
Ok(())
}
// 预注册 fd,send/recv 使用 fixed file,省去 per-io fget/fput
fn register_files(submitter: &Submitter, fds: &[RawFd]) -> io::Result<()> {
submitter.register_files(fds)?;
Ok(())
}
自定义 Reactor 核心循环
struct PollingReactor {
ring: IoUring,
task_wakers: Slab<Waker>,
connection_table: Slab<Connection>,
}
impl PollingReactor {
fn run(&mut self) -> io::Result<()> {
let mut coop_budget = 0;
loop {
// 1. 提交所有待处理的 SQE(write SQ tail,无 syscall)
self.dispatch_pending();
// 2. 等待 CQE 完成(不打扰 sqthread)
// submit_and_wait 在 SQPOLL 下只是 read CQ head
let _ = self.ring.submit_and_wait(0);
// 3. 处理完成的 CQE
for cqe in self.ring.completion() {
let token = cqe.user_data() as usize;
let result = cqe.result();
self.complete_io(token, result);
}
// 4. 协程预算控制:防止 starve
coop_budget += 1;
if coop_budget >= 256 {
coop_budget = 0;
// hint 内核可以调度 sqthread
std::thread::yield_now();
}
}
}
}
IORING_OP_PROVIDE_BUFFERS 实现零拷贝 recv
struct BufferPool {
registered: Vec<Vec<u8>>,
group_id: u16,
}
impl BufferPool {
// 内核预分配 buffer,recv 完成时内核直接 pick buffer
// 应用层无需提前知道 recv 长度
fn register_pool(
&self,
submitter: &Submitter,
buf_len: usize,
buf_cnt: u16,
group_id: u11,
bgid: u16,
) -> io::Result<()> {
let op = types::ProvideBuffers::new(
self.registered.as_ptr() as *mut _,
buf_len as i32,
buf_cnt,
bgid,
group_id as u16,
);
submitter.submq_sqe(op)?;
Ok(())
}
// 提交 recv 时 IORING_BUFFERS_SELECT 让内核从 pool 中取 buffer
fn submit_recv_fixed_buf(
&self,
conn_fd: u32,
token: u64,
) -> io::Result<()> {
let sqe = types::RecvMsg::new(
types::Fixed(conn_fd as u16),
std::ptr::null_mut(),
0,
)
.buf_group(self.group_id)
.build();
// 设置 user_data 供完成回调路由
unsafe { sqe.user_data = token; }
Ok(())
}
}
sqthread 绑核策略
SQPOLL 内核线程的运行策略直接影响性能:
问题:默认 sqthread 抢占业务线程
Linux 默认的 kthread 调度策略是 SCHED_OTHER。当 sqthread 和业务线程共享同一 CPU 时,sqthread 的轮询循环会与业务逻辑争抢 CPU,导致延迟抖动。
解决方案:CPU 隔离 + 绑核
# /proc/cmdline 隔离 CPU
isolcpus=2,3 nohz_full=2,3 rcu_nocbs=2,3
# taskset 将 sqthread 绑到隔离的 CPU2
taskset -c 2 /path/to/application
应用层使用 IORING_SETUP_SQPOLL_CPU 绑定 sqthread:
let ring = IoUring::builder()
.setup_sqpoll_cpu(2) // sqthread 运行在隔离的 CPU 2
.setup_sqpoll(5000) // idle 5s 后让出
.build(queue_depth)?;
配合 SCHED_FIFO(CAP_SYS_NIO 需要),sqthread 可进一步提升响应确定性:
# 应用启动后,将 sqthread 提权
pkill -f io-wq- | xargs -I{} chrt -f -p 1 {}
端到端 echo server benchmark
测试环境:EPYC 7763 + NVMe SSD + Intel X710 10GbE
| 指标 | epoll + tokio | io_uring SQPOLL | io_uring SQPOLL+IOPOLL |
|---|---|---|---|
| 单核 QPS(128B echo) | 180K | 320K | 340K |
| P99 延迟 | 28μs | 12μs | 8μs |
| P999 延迟 | 145μs | 42μs | 18μs |
| CPU 利用率(320K QPS) | 85% | 62% | 58% |
| 系统调用数/s(热路径) | 360K | 0 | 0 |
关键观察:
- P999 延迟从 145μs 降到 18μs——在分布式追踪场景下,这意味着微服务间 tail latency 大幅改善
- CPU 利用率降低 27% 以上——省下的 CPU 核可用于业务逻辑处理
- 在 polling 模式下,sqthread 绑核后的延迟分布标准差缩小到 epoll 模式的 1/8
生产陷阱
CQE 水位控制
由于 CQ 深度是固定的,如果消费 CQE 过慢,CQ overflow 将导致 SQE 提交失败。生产中必须设置 IORING_SETUP_CQSIZE >= 2 * SQ_DEPTH。
sqthread idle 退出陷阱
SQPOLL idle timeout 后 sqthread 会退出。当新请求到来时调用 io_uring_enter 重新唤醒 sqthread 的延迟约为 5-10μs。在高并发稳态下这不是问题,但在突发负载(如节后上班流量突增)时,这一延迟会被放大到 P99。方案:
// 方式一:设置足够大的 idle_ms(但浪费 CPU)
.setup_sqpoll(0) // 永不 idle(需要 CAP_SYS_NICE)
// 方式二:周期性 ping
fn keep_alive(&self) {
// 每 N 秒提交一个超时的 nop,阻止 sqthread 退出
let timeout = types::Timeout::new().sec(60).build();
submit_nop(timeout)?;
}
与 signal 的交互
SQPOLL 内核线程会阻塞所有 signal(包括 SIGINT),这意味着 Ctrl-C 可能无法立即中断应用。处理方案:
// 单独注册 signal handler,使用 signalfd 将信号事件接入 uring
let mut sigset = SigSet::empty();
sigset.add(SIGINT);
sigset.add(SIGTERM);
sigprocmask(SIG_BLOCK, &Some(sigset), None)?;
let sfd = SignalFd::with_flags(&sigset, SFD_NONBLOCK|SFD_CLOEXEC)?;
// 将 signalfd 注册为 fixed file,提交 poll_add
submit_poll_add(sfd.as_raw_fd(), POLLIN)?;
总结
io_uring polling 模式是 Linux I/O 栈的进化方向。从"告诉内核我要做什么"到"让内核主动帮我做",这一范式转变的收益不仅是 syscall 开销的消除,更是通知模型从 pull 到 push 的根本变化。
对于 Rust async runtime 来说,与 io_uring polling 的融合本质上是让 runtime reactor 的一部分下沉到内核。应用层只需关注 buffer 管理、任务调度以及错误处理,I/O 通路的底层细节由内核线程接管。这一设计思路正在被 tokio-uring、g2uring、ubi 等库逐步验证,也将成为下一代高性能网络服务的标准范式。

发表评论 取消回复