Zig 与 Linux io_uring:异步 IO 与高性能服务

io_uring 用共享内存环形队列替代 epoll 的「就绪通知」模型,把系统调用开销压到接近零。本文用 Zig 从 mmap 环形缓冲区开始,逐步实现 SQE 提交、CQE 收割、注册缓冲区与 SQPOLL,并给出高性能服务的多 ring 架构与选型建议。

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, &params);
    var rings = try mapRings(fd, &params);

    // 先提交一批 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, &params);

代价是内核线程会持续占用一个 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, &params);
    defer std.posix.close(fd);
    if (params.features & linux.IORING_FEAT.SINGLE_MMAP == 0) {
        return error.KernelTooOld;
    }
}

8. 性能对比与陷阱

在 4 KiB 随机读场景下,典型量级是:

模型每请求系统调用每秒 IOPS(单核,参考值)
同步 read1约 30 万
epoll + read2~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 服务是天然搭档。落地要点:

  1. 结构体一律 extern struct,head/tail 一律 volatile + 原子序。
  2. user_data 存索引而非指针,维护请求槽位表。
  3. 尽早注册文件表与缓冲区,网络服务优先考虑 SQPOLL。
  4. 每线程一个 ring,用 SO_REUSEPORT 做分发。

深入系统调用与文件描述符的通用处理,可以继续看 /zig-system-programming/。若要把 io_uring 服务与传统的 epoll 事件循环做对比,可参考 Linux 内核与系统调用全景 中的事件通知章节。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「系统编程」更多文章

  1. Zig 终端 TUI 开发:终端控制、布局与交互
  2. Zig 打包与分发:容器镜像、系统包与 Homebrew
  3. Zig GPU 计算:Vulkan Compute 与着色器绑定