从 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 路径

一个请求的完整路径如下:

  1. 任务就绪,runtime 准备 sendmsg 请求
  2. 直接写入 SQE:sqe->opcode = IORING_OP_SENDMSG
  3. 更新 SQ tail(一次 atomic store,非 syscall)
  4. 内核 sqthread 异步拾取 SQE,执行 socket send
  5. 完成后 CQE 入 CQ
  6. 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

关键观察:

  1. P999 延迟从 145μs 降到 18μs——在分布式追踪场景下,这意味着微服务间 tail latency 大幅改善
  2. CPU 利用率降低 27% 以上——省下的 CPU 核可用于业务逻辑处理
  3. 在 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 等库逐步验证,也将成为下一代高性能网络服务的标准范式。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部