1. 从 epoll 到 io_uring:模型差异
Linux 的传统异步 IO 走的是**就绪通知(readiness notification)**路线:epoll_wait 只告诉你「这个 fd 现在可读了」,真正的 read/write 仍要你自己发起。这意味着每个事件至少两次系统调用,且数据拷贝无法避免地穿越用户态与内核态边界。
io_uring 换成**完成通知(completion notification)**模型。用户态与内核态共享两块环形队列:
- 提交队列(Submission Queue, SQ):用户态写入「我想做什么」的请求描述符(SQE)。
- 完成队列(Completion Queue, CQ):内核写回「做完了,结果是多少」的完成事件(CQE)。
只有在内核需要被唤醒时才触发一次系统调用(io_uring_enter)。批量提交 128 个请求与提交 1 个请求,系统调用次数相同。对 Zig 这类无运行时、无 GC 的语言来说,这个模型尤其合适——环形缓冲区就是一段 mmap 出来的普通内存,Zig 可以直接用指针和 volatile 访问它,不需要任何绑定层。
如果你还没读过事件驱动的基础模型,可以先看 /zig-async-network/ 里对 epoll/kqueue 抽象的讨论;本文聚焦 io_uring 本身。操作系统层面的背景可以对照 Linux 异步 IO 与 io_uring 机制 。
2. 环形队列的内存布局
io_uring 的所有结构都在一段由 io_uring_setup 返回的 fd 上通过 mmap 映射出来。第一个映射是提交队列环(SQ ring),第二个是完成队列环(CQ ring),第三个(可选)是 SQE 数组。
const std = @import("std");
const linux = std.os.linux;
// 与内核 ABI 对齐的结构体,字段顺序不可更改
const IoUringSqe = extern struct {
opcode: u8,
flags: u8,
ioprio: u16,
fd: i32,
off: u64,
addr: u64,
len: u32,
op_flags: u32,
user_data: u64,
buf_index: u16,
personality: u16,
splice_fd_in: i32,
addr3: u64,
__pad2: [1]u64,
};
const IoUringCqe = extern struct {
user_data: u64,
res: i32,
flags: u32,
};
const SqRingOffsets = extern struct {
head: u32,
tail: u32,
ring_mask: u32,
ring_entries: u32,
flags: u32,
dropped: u32,
array: u32,
resv1: u32,
user_addr: u64,
};
const CqRingOffsets = extern struct {
head: u32,
tail: u32,
ring_mask: u32,
ring_entries: u32,
overflow: u32,
cqes: u32,
flags: u32,
resv1: u32,
user_addr: u64,
};
注意 extern struct:Zig 保证它按 C ABI 布局,字段顺序和填充与内核头文件一致。普通 struct 会被 Zig 自由重排字段,直接映射内核结构会读到垃圾数据。
2.1 ring 参数的确定
io_uring_setup 接受一个 entries 参数,内核会向上取整到 2 的幂。我们通过 params 结构拿回实际的偏移量:
const IoUringParams = extern struct {
sq_entries: u32,
cq_entries: u32,
flags: u32,
sq_thread_cpu: u32,
sq_thread_idle: u32,
features: u32,
wq_fd: u32,
resv: [3]u32,
sq_off: SqRingOffsets,
cq_off: CqRingOffsets,
};
pub fn setup(entries: u32, params: *IoUringParams) !i32 {
const rc = linux.syscall2(.io_uring_setup, entries, @intFromPtr(params));
const signed: isize = @bitCast(rc);
if (signed < 0) return error.IoUringSetupFailed;
return @intCast(signed);
}
2.2 映射三个区域
映射大小必须按页对齐。SQ ring 与 CQ ring 的大小由内核返回的偏移量推导,SQE 数组则需要按 sq_entries * sizeof(SQE) 计算:
const page = std.heap.page_size_min;
fn ringSize(off: u32, entries: u32, elem_size: usize) usize {
return off + entries * elem_size;
}
pub fn mapRings(fd: i32, p: *const IoUringParams) !Rings {
const sq_sz = ringSize(p.sq_off.array, p.sq_entries, @sizeOf(u32));
const cq_sz = ringSize(p.cq_off.cqes, p.cq_entries, @sizeOf(IoUringCqe));
const sq_ptr = try mmap(fd, sq_sz, linux.IORING_OFF_SQ_RING);
const cq_ptr = try mmap(fd, cq_sz, linux.IORING_OFF_CQ_RING);
const sqes_ptr = try mmap(fd, p.sq_entries * @sizeOf(IoUringSqe), linux.IORING_OFF_SQES);
return .{
.sq_head = @ptrCast(@alignCast(sq_ptr + p.sq_off.head)),
.sq_tail = @ptrCast(@alignCast(sq_ptr + p.sq_off.tail)),
.sq_mask = @ptrCast(@alignCast(sq_ptr + p.sq_off.ring_mask)),
.sq_array = @ptrCast(@alignCast(sq_ptr + p.sq_off.array)),
.cq_head = @ptrCast(@alignCast(cq_ptr + p.cq_off.head)),
.cq_tail = @ptrCast(@alignCast(cq_ptr + p.cq_off.tail)),
.cq_mask = @ptrCast(@alignCast(cq_ptr + p.cq_off.ring_mask)),
.cqes = @ptrCast(@alignCast(cq_ptr + p.cq_off.cqes)),
.sqes = @ptrCast(@alignCast(sqes_ptr)),
};
}
head/tail 必须用 *volatile u32 访问,因为内核会在任意时刻修改它们,编译器不得缓存或重排:
const Rings = struct {
sq_head: *volatile u32,
sq_tail: *volatile u32,
sq_mask: *u32,
sq_array: [*]u32,
cq_head: *volatile u32,
cq_tail: *volatile u32,
cq_mask: *u32,
cqes: [*]IoUringCqe,
sqes: [*]IoUringSqe,
};
3. 提交一个 SQE
SQE 的准备是纯内存写入,不需要系统调用。关键在于 array 的间接层:SQE 数组是固定大小的槽位池,array 记录本轮要提交哪些槽位的下标。这让「提交顺序」与「槽位复用」解耦。
pub fn getSqe(r: *Rings) ?*IoUringSqe {
const tail = r.sq_tail.*;
const head = r.sq_head.*;
if (tail -% head >= r.sq_mask.* + 1) return null; // 队列满
const idx = tail & r.sq_mask.*;
r.sq_array[idx] = idx;
return &r.sqes[idx];
}
pub fn submit(r: *Rings, fd: i32, count: u32) !u32 {
const tail = r.sq_tail.* +% count;
@atomicStore(u32, r.sq_tail, tail, .release);
const rc = linux.syscall6(
.io_uring_enter,
@as(usize, @intCast(fd)),
count,
0, // 不等待完成
0,
0,
0,
);
const signed: isize = @bitCast(rc);
if (signed < 0) return error.SubmitFailed;
return @intCast(signed);
}
@atomicStore 用 .release 序是硬性要求:必须保证 SQE 的字段写入对内核可见之后,tail 的更新才可见。写错序会导致内核读到半成品请求,症状是随机的 -EINVAL。
3.1 一次完整的读请求
pub fn prepRead(sqe: *IoUringSqe, fd: i32, buf: []u8, offset: u64, user_data: u64) void {
sqe.* = std.mem.zeroes(IoUringSqe);
sqe.opcode = linux.IORING_OP.READ;
sqe.fd = fd;
sqe.addr = @intFromPtr(buf.ptr);
sqe.len = @intCast(buf.len);
sqe.off = offset;
sqe.user_data = user_data;
}
pub fn prepWrite(sqe: *IoUringSqe, fd: i32, buf: []const u8, offset: u64, user_data: u64) void {
sqe.* = std.mem.zeroes(IoUringSqe);
sqe.opcode = linux.IORING_OP.WRITE;
sqe.fd = fd;
sqe.addr = @intFromPtr(buf.ptr);
sqe.len = @intCast(buf.len);
sqe.off = offset;
sqe.user_data = user_data;
}
user_data 是回传给用户态的唯一关联字段。实践中不要直接存指针——请求完成时缓冲区可能已被回收。存一个索引,用它去查自己的请求表:
const Slot = struct {
buf: []u8,
conn_id: u64,
op: enum { read, write, accept },
};
var slots: [1024]Slot = undefined;
// 提交时:user_data = @intCast(slot_index)
// 完成时:const slot = slots[@intCast(cqe.user_data)];
4. 收割 CQE
完成事件由内核写入 CQ ring,用户态读取 cq_tail 与 cq_head 的差值即待处理数量:
pub fn peekCqe(r: *Rings) ?IoUringCqe {
const head = r.cq_head.*;
const tail = @atomicLoad(u32, r.cq_tail, .acquire);
if (tail == head) return null;
return r.cqes[head & r.cq_mask.*];
}
pub fn cqeSeen(r: *Rings, cqe: IoUringCqe) void {
_ = cqe;
const head = r.cq_head.* +% 1;
@atomicStore(u32, r.cq_head, head, .release);
}
res 字段语义与同步系统调用一致:非负是成功的结果(如读到的字节数),负值是 -errno。转换成 Zig error 需要一层映射:
fn resultToError(res: i32) !usize {
if (res >= 0) return @intCast(res);
return switch (-res) {
linux.E.AGAIN => error.WouldBlock,
linux.E.CONNREFUSED => error.ConnectionRefused,
linux.E.PIPE => error.BrokenPipe,
else => error.IoError,
};
}
4.1 批量收割
每次收割后若 CQ 有剩余容量,应当一次性处理完,减少 io_uring_enter 的调用次数:
pub fn drain(r: *Rings, handler: anytype) !usize {
var n: usize = 0;
while (peekCqe(r)) |cqe| {
cqeSeen(r, cqe);
try handler(cqe);
n += 1;
}
return n;
}
5. 注册资源:消灭每次请求的重复开销
io_uring 有一组 IORING_REGISTER_* 操作,把「每次请求都要做」的工作提前做一次。
| 注册类型 | 作用 | 收益 |
|---|---|---|
REGISTER_FILES | 把 fd 表固定在内核 | 免去每次请求查 fd 表 |
REGISTER_BUFFERS | 固定用户态缓冲区 | 支持零拷贝,免去页表校验 |
REGISTER_EVENTFD | 用 eventfd 通知完成 | 可接入 epoll 主循环 |
REGISTER_FILES_UPDATE | 增量替换 fd | 避免整表重注册 |
固定缓冲区的用法是:注册时给出地址与长度的数组,之后 SQE 的 buf_index 填数组下标而非 addr:
pub fn registerBuffers(fd: i32, iovecs: []const linux.iovec) !void {
const rc = linux.syscall4(
.io_uring_register,
@as(usize, @intCast(fd)),
linux.IORING_REGISTER.BUFFERS,
@intFromPtr(iovecs.ptr),
iovecs.len,
);
const signed: isize = @bitCast(rc);
if (signed < 0) return error.RegisterFailed;
}
// 使用固定缓冲区读取
pub fn prepReadFixed(sqe: *IoUringSqe, fd: i32, len: u32, offset: u64, buf_idx: u16) void {
sqe.* = std.mem.zeroes(IoUringSqe);
sqe.opcode = linux.IORING_OP.READ_FIXED;
sqe.fd = fd;
sqe.len = len;
sqe.off = offset;
sqe.buf_index = buf_idx;
}
固定缓冲区的坑在于:注册后这些页会被内核 pin 住,直到 UNREGISTER。若你的服务需要动态扩容缓冲区池,必须先在用户态维护引用计数,确认没有 in-flight 请求后再重新注册。
6. 服务架构:多 ring 与 SQPOLL
6.1 每线程一个 ring
io_uring 实例本身不是线程安全的,也不该跨线程共享。标准做法是每 worker 线程一个 ring,配合 SO_REUSEPORT 让内核把新连接分发到各线程的监听套接字:
pub fn runWorker(listen_fd: i32) !void {
var params: IoUringParams = undefined;
const fd = try setup(1024, ¶ms);
var rings = try mapRings(fd, ¶ms);
// 先提交一批 accept
for (0..64) |i| {
const sqe = rings.getSqe() orelse break;
prepAccept(sqe, listen_fd, @intCast(i));
}
_ = try rings.submit(fd, 64);
while (true) {
// 阻塞等待至少一个完成
try enterWait(fd, 0, 1);
_ = try rings.drain(handleCqe);
}
}
6.2 SQPOLL 模式
默认模型下,每次提交都要一次 io_uring_enter。开启 IORING_SETUP_SQPOLL 后,内核会启动一个轮询线程持续检查 SQ tail,用户态完全不需要系统调用:
var params = std.mem.zeroes(IoUringParams);
params.flags = linux.IORING_SETUP.SQPOLL | linux.IORING_SETUP.SQ_AFF;
params.sq_thread_idle = 2000; // 空闲 2 秒后内核线程睡眠
const fd = try setup(4096, ¶ms);
代价是内核线程会持续占用一个 CPU 核心。适合延迟敏感且吞吐稳定的服务(如高频交易网关、数据库代理);对低负载服务反而浪费核心。若设了 sq_thread_idle 而内核线程睡下去,需要设置 IORING_SETUP_SQPOLL 对应的 IORING_SQ_NEED_WAKEUP 标志并主动调用 io_uring_enter 唤醒它。
6.3 用 eventfd 接入既有 epoll 循环
已有基于 epoll 的代码不必推倒重来。注册 REGISTER_EVENTFD 后,完成事件会写入 eventfd,把它加入 epoll 即可:
pub fn registerEventfd(fd: i32, efd: i32) !void {
const rc = linux.syscall4(
.io_uring_register,
@as(usize, @intCast(fd)),
linux.IORING_REGISTER.EVENTFD,
@intFromPtr(&efd),
1,
);
const signed: isize = @bitCast(rc);
if (signed < 0) return error.RegisterFailed;
}
7. 与 Zig async/await 的关系
Zig 的 async/await 在 0.14 之后被移出编译器(改为用户态实现),当前更推荐显式的事件循环。io_uring 与 Zig 组合的实践路径有三条:
- 纯 io_uring:自己维护请求表与状态机,控制力最强,代码量也最大。
- io_uring + 线程池:慢速操作(如磁盘 fsync)交给线程池,网络 IO 走 io_uring,避免阻塞 ring。
- 复用现有抽象:把 io_uring 作为后端塞进自己的
EventLoop接口,同时提供 epoll 后端以便在旧内核上运行。
io_uring 需要内核 5.1 以上;READ_FIXED、SQPOLL 等特性分别在 5.6、5.11 逐步完善。生产环境务必在启动时探测:
pub fn probe() !void {
var params: IoUringParams = undefined;
const fd = try setup(8, ¶ms);
defer std.posix.close(fd);
if (params.features & linux.IORING_FEAT.SINGLE_MMAP == 0) {
return error.KernelTooOld;
}
}
8. 性能对比与陷阱
在 4 KiB 随机读场景下,典型量级是:
| 模型 | 每请求系统调用 | 每秒 IOPS(单核,参考值) |
|---|---|---|
| 同步 read | 1 | 约 30 万 |
| epoll + read | 2~3 | 约 50 万 |
| io_uring 批量提交 | 约 0.01 | 约 200 万 |
| io_uring + SQPOLL + 固定缓冲区 | 0 | 约 350 万 |
数字随硬件差异很大,但数量级关系是稳定的。常见陷阱:
- 忘记内存屏障:
tail写入用普通赋值而非.release,在 ARM 上会偶发丢请求。 - CQ 溢出:默认 CQ 与 SQ 等大。若收割不及时且提交量巨大,
overflow计数增长,请求会被丢弃。 - 固定缓冲区与
fork:注册的缓冲区在fork后不继承,子进程必须重新注册。 - 阻塞操作污染 ring:在 io_uring 上提交可能长时间阻塞的操作会拖慢整个 ring,应交给线程池。
小结
io_uring 把异步 IO 的成本从「每请求两次系统调用」降到「每批一次」,对追求极致吞吐的 Zig 服务是天然搭档。落地要点:
- 结构体一律
extern struct,head/tail一律volatile+ 原子序。 user_data存索引而非指针,维护请求槽位表。- 尽早注册文件表与缓冲区,网络服务优先考虑
SQPOLL。 - 每线程一个 ring,用
SO_REUSEPORT做分发。
深入系统调用与文件描述符的通用处理,可以继续看 /zig-system-programming/。若要把 io_uring 服务与传统的 epoll 事件循环做对比,可参考 Linux 内核与系统调用全景 中的事件通知章节。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。