
最近折腾了一下 Rust 搭配 io_uring 的低延迟 Webhook 入口网关,踩了几个坑,这篇把问题说清楚:传统 epoll 模式的耗时到底在哪里,io_uring 能省掉什么,零拷贝接收又需要哪些硬件和内核条件。
Webhook 承载着现代集成系统里的各种事件:支付通知、CI/CD 触发器、聊天平台回调和设备遥测。入口网关通常挡在这些服务前面,需要承受小规模 HTTP(超文本传输协议)请求的突发流量,完成身份认证,再把请求转交给下游队列,同时不能自己变成瓶颈。
在请求速率很高时,跨越操作系统边界的成本会在单请求预算中占据很大比例。本文会拆解 Linux io_uring 和 Rust 如何降低这部分开销,零拷贝接收真正需要哪些条件,以及如何围绕它们搭一个网关骨架。同时也会说明,这项技术在哪些场景下值得用,哪些场景下并不划算。
传统的就绪通知型服务器,比如基于 epoll 构建的服务,处理一个进来的请求大致会经历下面几步:
epoll_wait() 中的线程被唤醒,因为此时 socket 已经可读。read() 或 recv(),内核把有效载荷从 socket 缓冲区拷贝到用户空间缓冲区。这些系统调用都会跨越用户态和内核态边界,而有效载荷进入应用时还会经历一次拷贝。Spectre 和 Meltdown 漏洞之后引入的 CPU(中央处理器)安全缓解措施,在部分硬件上会进一步提高这些状态切换的成本。具体开销受 CPU 型号、内核版本和缓解配置影响很大,别直接照搬某个固定数字,应该在自己的机器上实测。
对于处理海量小请求的网关来说,单请求的系统调用开销和拷贝次数会不断累积。业务逻辑往往反而不是主要成本。
io_uring 在 Linux 5.1 中引入。应用和内核不再为每个操作单独发起一次系统调用,而是在内存中共享两个环形缓冲区:
accept、recv 或 send 等操作。应用可以一次排队多个操作,再通过一次 io_uring_enter 调用统一提交。这个调用也可以等待完成事件,因此相比“等待就绪再读取”的循环,系统调用次数会明显下降。
Application (user space) writes SQEs ──► [ Submission Queue ] ──┐ │ shared memory rings reads CQEs ◄── [ Completion Queue ] ◄─┘ │
Kernel: io_uring subsystem executes the operations
IORING_SETUP_SINGLE_ISSUER:声明只有一个线程会向环形缓冲区提交请求,内核因此可以跳过部分同步操作。IORING_SETUP_DEFER_TASKRUN:把完成处理推迟到应用显式进入内核等待事件时再执行。liburing 文档建议将它作为“一线程一环形缓冲区”这类新应用的默认配置,同时它依赖 SINGLE_ISSUER。IORING_OP_RECV_ZC):在 Linux 6.15 中合入,后面会详细说明。io_uring 的零拷贝接收(ZC Rx)去掉了接收路径上的内核到用户态拷贝,数据包有效载荷直接进入用户空间内存。数据包头部仍由内核 TCP 栈按常规流程处理,因此它不像 DPDK(数据平面开发套件)这类内核旁路框架,依然使用内核现成的 TCP 实现。
这不是打开一个开关就能生效的功能。按照内核文档,它依赖下面所有条件:
IORING_SETUP_SINGLE_ISSUER、IORING_SETUP_DEFER_TASKRUN,并启用大尺寸完成项,也就是 IORING_SETUP_CQE32,或者在较新内核中使用 IORING_SETUP_CQE_MIXED。io_uring 管理,其规则和普通网络缓冲区不同。按照内核文档给出的示例,网卡配置大致如下:
# Make sure there are at least two hardware queues
sudo ethtool -L eth0 combined 2 # Enable header/data split
sudo ethtool -G eth0 tcp-data-split on # Reserve half of the queues for zero-copy using RSS
sudo ethtool -X eth0 equal 1 # Steer the flows you want into the zero-copy queue (queues are 0-indexed)
sudo ethtool -N eth0 flow-type tcp4 dst-port 8443 action 1
网卡名称、队列数量和流规则都要按实际硬件调整。
零拷贝接收节省的是每个有效载荷的一次内存拷贝。Webhook 载荷通常很小,往往只有几百字节到几 KB,因此拷贝本身一般只占成本的一小部分。对于小请求来说,io_uring 的大部分收益来自更少的系统调用、批量提交和多次接收,而不是零拷贝。只有当载荷大小和带宽继续增长时,零拷贝接收的价值才会明显上升。建议先使用可移植的特性,只有性能分析证明拷贝确实占比明显,而且硬件也满足要求时,再引入 ZC Rx。
tokio-rs 组织维护的 io-uring crate,是 Rust 对 io_uring 接口的低层绑定。tokio-uring 等上层 crate 则在其基础上实现了兼容 Tokio(异步运行时)的运行时。tokio-uring 运行的是当前线程运行时,其文档建议通过启动多个线程、每个线程各自持有一个运行时来实现并发。下面这套架构正是基于这个思路。
core_affinity 之类的 crate 绑定 CPU 核心。这样每个线程的数据都能留在自己的缓存中,而且因为只有一个线程访问对应环形缓冲区,SINGLE_ISSUER 也能自然成立。SO_REUSEPORT 绑定,内核便可将新连接分发到不同工作线程,不需要共享 accept 队列。下面的代码片段展示核心引擎的大致结构,只能用于说明思路,还达不到生产可用标准。务必固定 crate 版本,检查 docs.rs 上最新的 io-uring API(应用程序接口),并在启动时运行操作码探测,确认当前内核支持所使用的功能。
[dependencies]
io-uring = "0.7"
libc = "0.2"
core_affinity = "0.8"
ring = "0.17"
use io_uring::IoUring;
use std::io; pub fn create_ingress_ring(entries: u32) -> io::Result<IoUring> { // One ring per worker thread. SINGLE_ISSUER declares that only this // thread submits; DEFER_TASKRUN requires it and defers completion work // until this thread enters the kernel to wait for events. IoUring::builder() .setup_single_issuer() .setup_defer_taskrun() .build(entries)
}
多次接收会从已注册的缓冲区组里选择缓冲区。必须先向内核提供这个组,可以使用 ProvideBuffers,也可以注册一个通过缓冲区环形队列 API 映射到环形缓冲区的缓冲区组。
use io_uring::{opcode, types, IoUring};
use std::io;
use std::os::unix::io::RawFd; pub fn submit_multishot_recv( ring: & mut IoUring, fd: RawFd, buf_group: u16, user_data: u64,
) -> io::Result<()> { // One SQE; the kernel keeps posting CQEs as data arrives, picking a // buffer from `buf_group` each time. Multishot recv needs Linux 6.0+. let sqe = opcode::RecvMulti::new(types::Fd(fd), buf_group) .build() .user_data(user_data); // Safety: the fd and buffer group must stay valid while the operation is in flight. unsafe { ring.submission() .push(&sqe) .map_err(|_| io::Error::new(io::ErrorKind::Other, "submission queue full"))?; } Ok(())
}
多次接收请求结束时,最后一个 CQE 不会设置 IORING_CQE_F_MORE 标志。需要检查这个标志,一旦消失,就重新挂载接收请求。缓冲区组耗尽时就会发生这种情况。处理完数据后,要把缓冲区归还给缓冲区组。
如果还要进一步使用零拷贝接收,就需要注册接口队列、内存区域,以及第 3 节介绍的补充环形缓冲区。内核文档使用 C 语言版 liburing API 展示这个过程。真正落地前,先确认 Rust 绑定的当前状态;如果 crate 尚未覆盖相关能力,可以考虑调用 liburing,或者直接使用原始注册接口。
Webhook 网关必须验证载荷身份,同时在高压流量下保护自身。
很多服务提供方会使用 HMAC-SHA256(带密钥的哈希消息认证码)对原始请求体进行签名。例如,GitHub 会在 X-Hub-Signature-256 请求头中发送签名,格式为 sha256= 加上一段十六进制编码的摘要。务必基于收到的原始字节计算 HMAC(带密钥的哈希消息认证码),必须先完成验证,再进行 JSON(JavaScript 对象表示法)解析或重新序列化,比较时使用恒定时间算法。
ring crate 的 hmac::verify 会使用恒定时间完成比较:
use ring::hmac; /// `payload` is the raw request body; `sig_hex` is the header value
/// with the `sha256=` prefix already stripped.
pub fn verify_webhook_signature(key: &hmac::Key, payload: &[u8], sig_hex: &[u8]) -> bool { // Decode hex into a fixed-size stack buffer (SHA-256 = 32 bytes). let mut tag = [0u8; 32]; if sig_hex.len() != 64 || hex_decode(sig_hex, &mut tag) .is_err() { return false; } hmac::verify(key, payload, &tag) .is_ok()
} fn hex_decode( src: &[u8], dst: & mut [u8; 32],
) -> Result<(), ()> { fn nibble(b: u8) -> Result<u8, ()> { match b { b'0'..=b'9' => Ok(b - '0'), b'a'..=b'f' => Ok(b - 'a' + 10), b'A'..=