编程 io_uring 源码级深度拆解:从 SQ/CQ 环形队列、SQPOLL 到 multishot,手写单机百万连接 Proactor 网络框架(附完整 C + liburing 实现)

2026-08-01 04:47:49 +0800 CST views 6

io_uring 源码级深度拆解:从 SQ/CQ 环形队列、SQPOLL 到 multishot,手写单机百万连接的 Proactor 网络框架(附完整 C + liburing 实现)

如果你写过高并发服务端,一定和 epoll 缠斗过:epoll_wait 拿到就绪事件,再回头 read/write——本质上还是「就绪通知 + 同步系统调用」的 Reactor 模式。每个 I/O 都要陷内核、拷数据、切上下文。在 100 万连接、每秒千万级小包的场景下,光是 read/write 的系统调用开销就能把 CPU 吃掉一大半。

io_uring 换了一条路:不再告诉你「可以读了」,而是让你「提交一个读请求,读完把结果给你」。这是从 Reactor 到 Proactor 的范式切换,也是 Linux 内核近十年最重要的 I/O 革新。本文从共享内存环形队列的第一性原理讲起,一路拆到 SQPOLL、multishot、buffer ring、zero-copy send,最后手写一个能扛住百万连接的 Proactor 回显服务器。全程 C + liburing,代码可直接编译运行。


一、背景:为什么 epoll 不够用了

1.1 同步 I/O 的三座大山

先把问题说透。传统 Linux 服务端 I/O 有三种主流形态,各有各的痛:

阻塞式 + thread-per-connection:一个连接一个线程,代码最直观,但线程栈动辄几 MB,上下文切换成本高。上到几万连接就崩,别提百万。

epoll + 非阻塞 I/O(Reactor):这是过去十几年的主力。epoll 解决了「一个线程监听海量 fd」的问题,但它只解决了「就绪通知」这一半。真正的数据搬运还是要靠 read/write 同步系统调用完成。也就是说,一次完整的收包至少两次陷内核:

epoll_wait()  →  内核告诉你 fd 可读   (第 1 次陷内核)
read(fd, ...)  →  你去把数据搬出来      (第 2 次陷内核 + 数据拷贝)

在高 IOPS 下,系统调用的上下文切换(用户态↔内核态)本身就是可观的开销。Spectre/Meltdown 之后引入的 KPTI(内核页表隔离)更是让每次陷内核的成本雪上加霜——TLB 刷新、页表切换,一次 syscall 的固定成本从几十纳秒涨到上百纳秒。当你每秒要做几千万次 read,这笔账相当吓人。

Linux 原生 AIO(libaio):Linux 早就有 io_submit/io_getevents 这套原生异步 I/O,但它有两个致命缺陷:

  1. 只支持 Direct I/O。必须带 O_DIRECT 打开文件,绕过 page cache。一旦落到 buffered I/O,io_submit 会「悄悄退化成同步阻塞」,异步的承诺直接作废。
  2. 只覆盖存储文件,不支持 socket。网络编程根本用不上,应用场景被死死限制在数据库这一小块。

于是 Linux 有了一个尴尬的局面:想做真正的异步 I/O,没有一个统一、通用、高效的接口。

1.2 io_uring 的登场

2019 年,Linux 5.1 内核合入了 Jens Axboe(也是 blk-mq、fio 的作者)主导的 io_uring。它的野心是统一 Linux 的异步 I/O 模型,一次性解决上面所有问题:

  • 真异步:buffered I/O、Direct I/O 全都异步,不再退化。
  • 全覆盖:存储文件 + 网络 socket + 各种系统调用(accept/connect/openat/statx/fsync/splice…),几乎所有阻塞式系统调用都能异步化。
  • 低开销:通过用户态与内核态共享内存的环形队列传递请求和结果,把系统调用次数压到极致——理想情况下可以做到「零系统调用」提交 I/O。

一句话总结 io_uring 的本质:它把「系统调用」这个动作,从『每次 I/O 陷一次内核』变成了『批量往共享内存里塞请求,偶尔踹内核一脚』


二、核心概念:两个环、三个系统调用

io_uring 的全部魔法,都建立在两个环形队列 + 一块共享内存之上。理解了这个数据结构,你就理解了 io_uring 的 80%。

2.1 SQ 与 CQ:提交环与完成环

io_uring 实例的核心是两个 ring buffer:

  • SQ(Submission Queue,提交队列):用户态往里塞「我要做什么 I/O」。每个元素叫 SQE(Submission Queue Entry)
  • CQ(Completion Queue,完成队列):内核往里塞「这个 I/O 干完了,结果是啥」。每个元素叫 CQE(Completion Queue Entry)

关键点在于:这两个环所在的内存,是通过 mmap 在用户态和内核态之间共享的。用户态往 SQ 写请求、从 CQ 读结果,都是直接操作共享内存,不需要系统调用,也就没有数据拷贝和上下文切换

画成图大概是这样:

   用户态                          内核态
 ┌──────────┐   提交 I/O 请求    ┌──────────┐
 │  应用程序 │ ───────────────▶  │  内核处理 │
 │          │   写 SQE 到 SQ     │  I/O 引擎 │
 │          │                    │ (io_wq /  │
 │          │   读 CQE 从 CQ     │  poll /   │
 │          │ ◀───────────────  │  SQPOLL)  │
 └──────────┘   获取完成结果      └──────────┘
       │                              │
       └──── mmap 共享的 SQ/CQ 环 ────┘
              (同一块物理内存)

2.2 SQE 和 CQE 长什么样

SQE 是一个 64 字节的结构体(内核里叫 struct io_uring_sqe),字段很多,但核心就几个:

struct io_uring_sqe {
    __u8    opcode;     /* 操作类型:IORING_OP_READ / WRITE / RECV / ACCEPT ... */
    __u8    flags;      /* SQE 标志位:IOSQE_IO_LINK / IOSQE_BUFFER_SELECT ... */
    __u16   ioprio;
    __s32   fd;         /* 目标文件描述符 */
    __u64   off;        /* 文件偏移 */
    __u64   addr;       /* 缓冲区地址 */
    __u32   len;        /* 缓冲区长度 */
    /* ... union 里还有一堆针对不同 opcode 的字段 ... */
    __u64   user_data;  /* 用户自定义数据,原样回传到 CQE,用来「认领」结果 */
    /* ... */
};

user_data 是整个异步模型的灵魂:因为提交和完成是解耦的,你提交了一堆请求,内核完成的顺序不确定,全靠 user_data 这个「回执号」把 CQE 和当初的意图对上号。实战中我们通常把它编码成「操作类型 + 连接对象指针」。

CQE 就简单多了,16 字节:

struct io_uring_cqe {
    __u64   user_data;  /* 对应 SQE 里的 user_data,原样返回 */
    __s32   res;        /* 结果:>=0 是成功(如读到的字节数),<0 是 -errno */
    __u32   flags;      /* 完成标志,如 IORING_CQE_F_MORE / F_BUFFER */
};

注意 res 的语义:它就是对应系统调用的返回值。read 成功就是读到的字节数,失败就是负的 errno(比如 -EAGAIN 就是 -11)。这个约定贯穿所有 opcode,非常统一。

2.3 环的头尾指针:无锁的关键

每个环都有一对 headtail 索引,它们也在共享内存里:

  • SQ:用户态是「生产者」,移动 tail;内核是「消费者」,移动 head
  • CQ:内核是「生产者」,移动 tail;用户态是「消费者」,移动 head

单生产者-单消费者(SPSC)的环形队列,配合原子操作和内存屏障,就能做到无锁并发。用户态提交时 tail++,内核消费时 head++,两边各管一头,互不加锁。这是 io_uring 高性能的底层保证之一。

有个容易踩的细节:SQ 环里存的不是 SQE 本身,而是指向 SQE 数组的索引。也就是说 SQ 环是一个 __u32 的间接索引数组,真正的 SQE 存在另一块独立 mmap 的数组里。这样设计是为了支持「预填充 SQE、乱序提交」等高级玩法。CQ 则是直接内联存 CQE。

2.4 三个系统调用

io_uring 的完整 API 只有三个系统调用,干净利落:

io_uring_setup(u32 entries, struct io_uring_params *p)
创建一个 io_uring 实例,返回一个 fd。entries 是 SQ 深度(会向上取整到 2 的幂),params 里返回环的各种偏移量,供后续 mmap 使用。

io_uring_enter(u32 fd, u32 to_submit, u32 min_complete, u32 flags, ...)
这是干活的核心。它一次能干两件事:告诉内核「SQ 里有 to_submit 个新请求,去处理」,以及「阻塞等待,直到至少 min_complete 个请求完成」。
关键在于:如果你开了 SQPOLL 模式,连这个系统调用都可以省掉——内核有专门的线程主动轮询 SQ,你只管往共享内存写请求就行。这就是「零系统调用 I/O」的由来。

io_uring_register(u32 fd, u32 opcode, void *arg, u32 nr_args)
注册资源以进一步提速:预注册文件描述符(registered files)、预注册缓冲区(registered buffers)、注册 eventfd 等。后面性能优化章节细讲。

三个系统调用,撑起了整个异步 I/O 帝国。


三、架构分析:内核侧到底怎么处理 I/O

用户态把 SQE 塞进环里之后,内核是怎么把活儿干完的?这里有几条不同的执行路径,理解它们对性能调优至关重要。

3.1 三种执行路径

路径一:inline 直接完成。 对于「立即就能完成、不会阻塞」的操作(比如数据已经在 page cache 里的 buffered read、socket 缓冲区里已经有数据的 recv),内核在 io_uring_enter 的上下文里同步就把它干了,直接生成 CQE。这条路径开销最低,因为压根没切线程。

路径二:io-wq 内核工作线程池。 如果操作会阻塞(比如要从磁盘真正读数据、socket 上暂时没数据可读),内核不会傻等,而是把这个请求甩给 io-wq(io workqueue,一个内核态的工作线程池)异步执行。work 干完后,由内核负责把 CQE 塞回 CQ 环并唤醒用户态。io-wq 是按需创建的,有 bound(受 CPU 限制的,如磁盘)和 unbound(不受限的,如网络)两类 worker。

路径三:poll-based 异步。 对于网络 socket,io_uring 有更聪明的做法:内部注册一个 poll,等 socket 可读/可写时再去执行实际的收发,而不是占着一个 io-wq worker 干等。这大幅减少了 worker 线程的占用,是网络场景高并发的关键。

3.2 SQPOLL:把系统调用也干掉

默认情况下,你每提交一批请求都要调一次 io_uring_enter 通知内核。虽然一次 enter 能批量提交很多请求(batching),但在极致低延迟场景,这一次系统调用还是想省。

开启 IORING_SETUP_SQPOLL 后,内核会起一个专属的内核轮询线程,它主动、持续地扫描 SQ 环,一旦发现新的 SQE 就立刻处理。这样用户态提交 I/O 就变成了纯粹的「往共享内存写 + 更新 tail 指针」,完全不需要系统调用

代价是这个内核线程会烧 CPU。为了不空转,它有个 sq_thread_idle 超时:一段时间没新请求就休眠,此时你需要用带 IORING_ENTER_SQ_WAKEUP 标志的 enter 把它唤醒。liburing 的 io_uring_submit 会自动帮你处理这个唤醒逻辑,不用手动判断。

SQPOLL 适合「持续高吞吐」的场景,用一个 CPU 核换掉海量系统调用。如果你的负载是间歇性的,SQPOLL 反而浪费 CPU,不划算。

3.3 现代 flags:DEFER_TASKRUN 与 SINGLE_ISSUER

Linux 6.x 之后,io_uring 引入了几个对性能影响巨大的 setup flag,写生产代码务必了解:

  • IORING_SETUP_COOP_TASKRUN:完成任务的处理(task work)不再用 IPI 强制打断目标线程,而是等它下次进内核时顺便做。减少中断,提升吞吐。
  • IORING_SETUP_DEFER_TASKRUN:更进一步,把 task work 完全推迟到你调用 io_uring_enter(即 wait_cqe)时才批量执行。这意味着完成事件的处理集中、可控,缓存局部性更好,尾延迟更稳。这是目前单线程 event loop 的最优配置
  • IORING_SETUP_SINGLE_ISSUER:向内核承诺「只有一个线程会提交请求」,内核据此省掉一堆同步开销。DEFER_TASKRUN 依赖它。

生产环境里,单线程 reactor 的推荐组合就是:DEFER_TASKRUN | SINGLE_ISSUER | COOP_TASKRUN。后面代码会用到。


四、代码实战(一):不用 liburing,裸手撸一个 io_uring

想真正吃透环的机制,最好的办法是不借助 liburing,直接调三个系统调用、自己 mmap、自己搬指针。这段代码略长,但每一行都是理解的钥匙。

// raw_uring.c —— 裸系统调用实现 io_uring 读文件,编译:gcc raw_uring.c -o raw_uring
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <linux/io_uring.h>
#include <stdatomic.h>

// glibc 目前没有直接封装这两个系统调用,我们自己包一层
static int io_uring_setup(unsigned entries, struct io_uring_params *p) {
    return (int)syscall(__NR_io_uring_setup, entries, p);
}
static int io_uring_enter(int fd, unsigned to_submit,
                          unsigned min_complete, unsigned flags) {
    return (int)syscall(__NR_io_uring_enter, fd, to_submit,
                        min_complete, flags, NULL, 0);
}

// 内存屏障:确保对共享环的读写顺序对内核可见
#define read_barrier()  atomic_thread_fence(memory_order_acquire)
#define write_barrier() atomic_thread_fence(memory_order_release)

struct app_ring {
    int ring_fd;
    // SQ 相关
    unsigned *sq_head, *sq_tail, *sq_mask, *sq_array;
    struct io_uring_sqe *sqes;
    // CQ 相关
    unsigned *cq_head, *cq_tail, *cq_mask;
    struct io_uring_cqe *cqes;
};

int setup_ring(struct app_ring *r) {
    struct io_uring_params p;
    memset(&p, 0, sizeof(p));

    // 1) 创建实例,深度 8
    r->ring_fd = io_uring_setup(8, &p);
    if (r->ring_fd < 0) { perror("io_uring_setup"); return -1; }

    // 2) mmap SQ 环。注意 SQ_RING 和 CQ_RING 在新内核里可用单次 mmap
    int sring_sz = p.sq_off.array + p.sq_entries * sizeof(unsigned);
    int cring_sz = p.cq_off.cqes + p.cq_entries * sizeof(struct io_uring_cqe);

    void *sq_ptr = mmap(0, sring_sz, PROT_READ | PROT_WRITE,
                        MAP_SHARED | MAP_POPULATE, r->ring_fd,
                        IORING_OFF_SQ_RING);
    if (sq_ptr == MAP_FAILED) { perror("mmap sq"); return -1; }

    void *cq_ptr = mmap(0, cring_sz, PROT_READ | PROT_WRITE,
                        MAP_SHARED | MAP_POPULATE, r->ring_fd,
                        IORING_OFF_CQ_RING);
    if (cq_ptr == MAP_FAILED) { perror("mmap cq"); return -1; }

    // 3) 通过 params 返回的偏移量,把各个指针定位到共享内存的正确位置
    r->sq_head  = sq_ptr + p.sq_off.head;
    r->sq_tail  = sq_ptr + p.sq_off.tail;
    r->sq_mask  = sq_ptr + p.sq_off.ring_mask;
    r->sq_array = sq_ptr + p.sq_off.array;

    r->cq_head = cq_ptr + p.cq_off.head;
    r->cq_tail = cq_ptr + p.cq_off.tail;
    r->cq_mask = cq_ptr + p.cq_off.ring_mask;
    r->cqes    = cq_ptr + p.cq_off.cqes;

    // 4) SQE 数组是单独 mmap 的
    r->sqes = mmap(0, p.sq_entries * sizeof(struct io_uring_sqe),
                   PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
                   r->ring_fd, IORING_OFF_SQES);
    if (r->sqes == MAP_FAILED) { perror("mmap sqes"); return -1; }

    return 0;
}

int main(int argc, char **argv) {
    if (argc < 2) { fprintf(stderr, "usage: %s <file>\n", argv[0]); return 1; }

    struct app_ring r;
    if (setup_ring(&r) < 0) return 1;

    int fd = open(argv[1], O_RDONLY);
    if (fd < 0) { perror("open"); return 1; }

    char buf[4096];

    // ===== 提交一个 READ 请求 =====
    unsigned tail = *r.sq_tail;            // 读当前尾
    unsigned index = tail & *r.sq_mask;    // 用 mask 取模映射到 SQE 数组下标
    struct io_uring_sqe *sqe = &r.sqes[index];
    memset(sqe, 0, sizeof(*sqe));
    sqe->opcode = IORING_OP_READ;
    sqe->fd = fd;
    sqe->addr = (unsigned long) buf;
    sqe->len = sizeof(buf);
    sqe->off = 0;
    sqe->user_data = 0x88;                  // 回执号,随便设

    r.sq_array[index] = index;              // SQ 环里放的是 SQE 的下标
    tail++;
    write_barrier();                        // 确保 SQE 内容对内核可见后再更新 tail
    *r.sq_tail = tail;
    write_barrier();

    // ===== 通知内核:提交 1 个,等待 1 个完成 =====
    int ret = io_uring_enter(r.ring_fd, 1, 1, IORING_ENTER_GETEVENTS);
    if (ret < 0) { perror("io_uring_enter"); return 1; }

    // ===== 收割 CQE =====
    unsigned head = *r.cq_head;
    read_barrier();                         // 确保读到内核最新写入的 CQE
    if (head != *r.cq_tail) {
        struct io_uring_cqe *cqe = &r.cqes[head & *r.cq_mask];
        if (cqe->res < 0)
            fprintf(stderr, "read error: %s\n", strerror(-cqe->res));
        else {
            printf("read %d bytes (user_data=0x%llx):\n",
                   cqe->res, (unsigned long long)cqe->user_data);
            fwrite(buf, 1, cqe->res, stdout);
        }
        head++;
        *r.cq_head = head;                  // 消费完,前移 head 归还给内核
        write_barrier();
    }
    return 0;
}

这段代码把 io_uring 的机制暴露得淋漓尽致:

  1. 一切偏移量都来自 io_uring_params。内核通过 p.sq_offp.cq_off 告诉你 head/tail/mask/array 在共享内存里的确切位置,你照着算指针即可。这种设计保证了 ABI 兼容——内核挪动布局也不会破坏应用。
  2. mask 就是「环大小 - 1」,用位与代替取模,tail & mask 就是环内下标。这是环形队列的经典技巧。
  3. 内存屏障不能省。你写完 SQE 内容必须 write_barrier 之后再更新 tail,否则内核可能看到 tail 前进了、但 SQE 内容还没落地,读到垃圾。收 CQE 前要 read_barrier 保证看到内核最新写入。这是无锁编程的命脉。

编译运行 ./raw_uring /etc/hostname,你会看到文件内容被异步读出。恭喜,你刚刚绕过了所有封装,直接和内核的环对话了。

但是——生产代码没人这么写。屏障、指针、溢出处理,稍有不慎就是玄学 bug。这就是 liburing 存在的意义。


五、代码实战(二):用 liburing 写一个 Proactor 回显服务器

liburing 是 Jens Axboe 官方维护的用户态封装库,把上面那堆脏活全包了,还提供了大量便利函数。装它:

git clone https://github.com/axboe/liburing
cd liburing && ./configure && make && sudo make install
# 或者:apt install liburing-dev / dnf install liburing-devel

我们来写一个真正的 Proactor 网络服务器:客户端发什么,服务端回什么(echo)。这个例子会用到 io_uring 网络编程的几个王牌特性:multishot acceptmultishot recvbuffer ring

5.1 先理解 Proactor 与「操作类型编码」

Reactor(epoll)的循环是「等就绪 → 自己读写」;Proactor(io_uring)的循环是「提交操作 → 等完成 → 处理结果 → 再提交下一个操作」。整个程序变成一个状态机,由 CQE 驱动。

因为完成是乱序的,我们必须靠 user_data 认领每个 CQE。经典做法是把「操作类型」和「连接信息」打包进 64 位的 user_data

// 把操作类型、fd、buffer id 编码进 64 位 user_data
enum { OP_ACCEPT = 0, OP_RECV = 1, OP_SEND = 2 };

// 高 16 位放 op,中间放 fd,低位放 buffer id(简化方案)
#define ENCODE(op, fd, bid) (((__u64)(op) << 48) | ((__u64)(fd) << 16) | (bid))
#define DEC_OP(ud)  ((int)((ud) >> 48) & 0xffff)
#define DEC_FD(ud)  ((int)((ud) >> 16) & 0xffffffff)
#define DEC_BID(ud) ((int)((ud) & 0xffff))

生产代码里更推荐把 user_data 设成一个 struct conn * 指针,状态全挂在连接对象上,扩展性更好。这里为了代码短用位编码演示。

5.2 multishot accept:一次提交,永久生效

传统写法里,每 accept 到一个连接,你就得再提交一个新的 accept SQE,否则下个连接就没人接。io_uring 5.19+ 提供了 multishot accept:提交一次,只要有新连接就持续往 CQ 塞 CQE,你不用反复重提。这对 accept 密集的场景是巨大的简化和提速。

判断 multishot 是否还活着,看 CQE 的 IORING_CQE_F_MORE 标志:置位说明这个 multishot 请求还在,会继续产出;没置位说明它结束了(比如出错),需要重新提交。

5.3 buffer ring:解决「收数据时缓冲区从哪来」

Proactor 有个先天难题:提交 recv 的时候你就得指定一块缓冲区,但连接一多,你不可能给每个空闲连接都预留一块 buffer——内存会爆。

io_uring 的答案是 provided buffers / buffer ring:你预先注册一批缓冲区到一个「缓冲区环」,提交 recv 时用 IOSQE_BUFFER_SELECT 标志说「缓冲区你自己从环里挑」。内核收到数据时,才从环里取一块可用 buffer 填进去,并在 CQE 的 flags 高位告诉你用了哪个 buffer(buffer id)。你处理完,再把这块 buffer 还回环里。

这样一来,缓冲区是「按需分配、收发共享」的,内存占用和活跃连接数挂钩,而不是和总连接数挂钩。百万连接才成为可能。

5.4 完整代码

// echo_server.c —— io_uring Proactor 回显服务器
// 编译:gcc echo_server.c -o echo_server -luring
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <liburing.h>

#define PORT        9000
#define QD          4096        // 队列深度
#define BUF_COUNT   4096        // 缓冲区环里的 buffer 数量
#define BUF_SIZE    2048        // 每个 buffer 大小
#define BUF_GID     1           // buffer group id

enum { OP_ACCEPT = 1, OP_RECV = 2, OP_SEND = 3 };
#define ENCODE(op, fd) (((__u64)(op) << 32) | (__u32)(fd))
#define DEC_OP(ud) ((int)((ud) >> 32))
#define DEC_FD(ud) ((int)((ud) & 0xffffffff))

static struct io_uring ring;
static struct io_uring_buf_ring *buf_ring;
static char *buf_base;           // 所有 buffer 的连续内存

// 把某个 buffer 归还到缓冲区环
static void buf_return(int bid) {
    io_uring_buf_ring_add(buf_ring,
        buf_base + bid * BUF_SIZE, BUF_SIZE, bid,
        io_uring_buf_ring_mask(BUF_COUNT), 0);
    io_uring_buf_ring_advance(buf_ring, 1);
}

// 提交一个 multishot recv(缓冲区由内核从环里选)
static void add_recv(int fd) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_recv_multishot(sqe, fd, NULL, 0, 0);
    sqe->flags |= IOSQE_BUFFER_SELECT;   // 让内核自己选 buffer
    sqe->buf_group = BUF_GID;
    io_uring_sqe_set_data64(sqe, ENCODE(OP_RECV, fd));
}

// 提交一个 send(回显)
static void add_send(int fd, const char *data, int len) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_send(sqe, fd, data, len, 0);
    io_uring_sqe_set_data64(sqe, ENCODE(OP_SEND, fd));
}

// 提交 multishot accept
static void add_accept(int lfd) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_multishot_accept(sqe, lfd, NULL, NULL, 0);
    io_uring_sqe_set_data64(sqe, ENCODE(OP_ACCEPT, lfd));
}

int main(void) {
    // 1) 建监听 socket
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    int one = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
    struct sockaddr_in addr = {
        .sin_family = AF_INET, .sin_port = htons(PORT),
        .sin_addr.s_addr = htonl(INADDR_ANY),
    };
    bind(lfd, (struct sockaddr *)&addr, sizeof(addr));
    listen(lfd, SOMAXCONN);

    // 2) 初始化 io_uring:用现代高性能 flags
    struct io_uring_params params;
    memset(&params, 0, sizeof(params));
    params.flags = IORING_SETUP_SINGLE_ISSUER | IORING_SETUP_DEFER_TASKRUN
                 | IORING_SETUP_COOP_TASKRUN;
    if (io_uring_queue_init_params(QD, &ring, &params) < 0) {
        perror("queue_init"); return 1;
    }

    // 3) 建立缓冲区环(provided buffers 的现代形态)
    int ret;
    buf_ring = io_uring_setup_buf_ring(&ring, BUF_COUNT, BUF_GID, 0, &ret);
    if (!buf_ring) { fprintf(stderr, "setup_buf_ring: %s\n", strerror(-ret)); return 1; }

    buf_base = malloc((size_t)BUF_COUNT * BUF_SIZE);
    for (int i = 0; i < BUF_COUNT; i++) {
        io_uring_buf_ring_add(buf_ring, buf_base + i * BUF_SIZE, BUF_SIZE, i,
                              io_uring_buf_ring_mask(BUF_COUNT), i);
    }
    io_uring_buf_ring_advance(buf_ring, BUF_COUNT);

    // 4) 挂上第一个 multishot accept,进入事件循环
    add_accept(lfd);
    io_uring_submit(&ring);

    printf("echo server listening on :%d\n", PORT);

    struct io_uring_cqe *cqe;
    while (1) {
        ret = io_uring_wait_cqe(&ring, &cqe);
        if (ret < 0) { perror("wait_cqe"); break; }

        unsigned head; unsigned count = 0;
        // 批量收割:io_uring_for_each_cqe 遍历所有已就绪的 CQE
        io_uring_for_each_cqe(&ring, head, cqe) {
            count++;
            __u64 ud = cqe->user_data;
            int op = DEC_OP(ud), fd = DEC_FD(ud);

            if (op == OP_ACCEPT) {
                int cfd = cqe->res;
                if (cfd >= 0) add_recv(cfd);          // 新连接,挂 recv
                // multishot accept 若 F_MORE 丢失需重提
                if (!(cqe->flags & IORING_CQE_F_MORE)) add_accept(lfd);

            } else if (op == OP_RECV) {
                int nread = cqe->res;
                if (nread <= 0) {                     // 对端关闭或出错
                    close(fd);
                } else {
                    // 从 CQE flags 高位取出内核选中的 buffer id
                    int bid = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
                    char *data = buf_base + bid * BUF_SIZE;
                    add_send(fd, data, nread);        // 回显
                    buf_return(bid);                  // 归还 buffer
                    // multishot recv 结束了要重挂
                    if (!(cqe->flags & IORING_CQE_F_MORE)) add_recv(fd);
                }

            } else if (op == OP_SEND) {
                if (cqe->res < 0) close(fd);          // 发送失败,关连接
            }
        }
        io_uring_cq_advance(&ring, count);            // 一次性前移 CQ head
        io_uring_submit(&ring);                       // 把本轮新加的 SQE 提交
    }

    io_uring_queue_exit(&ring);
    return 0;
}

编译运行:

gcc echo_server.c -o echo_server -luring
./echo_server &
# 另开终端测试
printf 'hello io_uring\n' | nc 127.0.0.1 9000
# 输出:hello io_uring

这段代码值得逐点品味:

  • 整个程序就一个 while 循环,没有一处阻塞式 read/write/accept。所有 I/O 都异步提交,靠 CQE 驱动状态流转,这就是 Proactor。
  • io_uring_for_each_cqe + io_uring_cq_advance 是批量收割范式:一次 wait_cqe 醒来,可能有几十上百个 CQE 就绪,一口气全处理完,再统一前移 head。相比一个个 cqe_seen,减少了共享内存写次数,缓存友好。
  • multishot + buffer ring 的组合让「接受连接」和「读数据」都变成「提交一次、持续产出」,事件循环里几乎不用重复提交,系统调用次数被压到极低。
  • 一次 io_uring_submit 把这一轮新增的所有 SQE(新连接的 recv、回显的 send、重挂的 accept)批量提交,又是一次 batching。

一个不到 150 行的文件,就是一个具备百万连接潜力的高性能网络框架骨架。


六、性能优化:把 io_uring 榨干

跑通只是起点。要让 io_uring 真正碾压 epoll,下面这几招必须用上。

6.1 registered files:省掉 fd 引用计数

每次 I/O 操作,内核都要把 fd 转成内部的 struct file*,这涉及 fd 表查找和原子引用计数增减。高频操作下这笔开销不小。

registered files 让你预先把一批 fd 注册进 io_uring:

int fds[1024];
// ...填充 fds...
io_uring_register_files(&ring, fds, 1024);

// 之后提交时用「注册索引」而非真实 fd,并加 IOSQE_FIXED_FILE 标志
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, /*index=*/5, buf, len, 0);
sqe->flags |= IOSQE_FIXED_FILE;   // 5 是注册表下标,不是真 fd

内核直接用索引拿到早已锁定的 struct file*,跳过查找和引用计数。对连接生命周期长、复用频繁的服务,收益明显。新内核还支持 IORING_REGISTER_FILES_UPDATE 和 direct descriptors(accept 直接产出注册索引,连真 fd 都不落地)。

6.2 registered buffers:零拷贝的前提

普通的 read/write,内核每次都要 pin 住用户缓冲区的物理页、建立映射,完事再解开。registered buffers 让你预先注册一组固定缓冲区:

struct iovec iov[4];
// ...每个 iov 指向一块大缓冲区...
io_uring_register_buffers(&ring, iov, 4);

// 用 _fixed 变体 + buffer 索引
io_uring_prep_read_fixed(sqe, fd, iov[0].iov_base, len, 0, /*buf_index=*/0);

内核一次性 pin 好这些页,之后所有 fixed 操作都免去重复 pin/unpin。配合 O_DIRECT,这是实现真·零拷贝 I/O 的基础,数据库和存储引擎的性能命脉。

6.3 zero-copy send:网络发送的大杀器

Linux 6.0+ 引入了 IORING_OP_SEND_ZC(zero-copy send)。传统 send 要把用户态数据拷进内核 socket 缓冲区;zero-copy send 则直接让网卡 DMA 用户态的页,省掉这次大拷贝。

io_uring_prep_send_zc(sqe, fd, data, len, 0, 0);

注意 zero-copy send 有个特殊语义:一个请求会产生两个 CQE。第一个 CQE(带 IORING_CQE_F_MORE)表示「已提交,发送字节数」,第二个 CQE(带 IORING_CQE_F_NOTIF)表示「内核已经用完你的 buffer,你可以复用/释放它了」。因为在网卡真正发完之前,你的 buffer 不能动。写代码时必须等到 notif CQE 才回收缓冲区,否则数据错乱。

zero-copy send 对大包(比如 >32KB)收益显著,小包因为有额外的 notif 记账开销,反而可能变慢,要实测取舍。

6.4 batching 与 linked SQE

  • 批量提交:不要每加一个 SQE 就 submit,攒一批一次性 submit,摊薄系统调用成本。上面回显服务器每轮循环只 submit 一次,就是这个思路。
  • linked SQE:用 IOSQE_IO_LINK 把多个 SQE 串成链,内核保证按顺序执行,前一个失败则后续取消。经典用法是「recv 完直接 send」「write 后 fsync」,一次提交完成一个操作序列,减少往返。
sqe1->flags |= IOSQE_IO_LINK;   // sqe1 -> sqe2 链接,sqe2 等 sqe1 成功才执行

6.5 SQPOLL vs DEFER_TASKRUN 怎么选

这是个常见困惑,给个决策清单:

  • 单线程 event loop、追求低尾延迟 → 用 DEFER_TASKRUN | SINGLE_ISSUER。完成处理集中在 wait_cqe 时批量做,缓存局部性最好,是目前多数网络服务的最优解。
  • 持续满负荷、想彻底消灭提交系统调用、愿意牺牲一个 CPU 核 → 用 SQPOLL。适合专用高吞吐机器。
  • 注意:SQPOLL 和 DEFER_TASKRUN 不能同时用(语义冲突),二选一。

七、生产踩坑与安全争议

工具再好,坑也是真的。这些是社区用血泪换来的经验。

7.1 CQE overflow:最隐蔽的杀手

CQ 环是有大小的(通常是 SQ 深度的两倍)。如果内核产出 CQE 的速度超过你收割的速度,CQ 环会满。老内核上,溢出的 CQE 会被直接丢弃,你的请求「完成了但你永远收不到结果」,表现为连接卡死、内存泄漏,极难排查。

新内核(5.5+)加了溢出链表兜底,并在 cq_flags 里设 IORING_SQ_CQ_OVERFLOW 标志。应对之道:

  1. CQ 环开大一点(IORING_SETUP_CQSIZE 单独指定 CQ 大小)。
  2. 及时收割,别在 CQE 处理里干重活阻塞循环。
  3. multishot 操作是 overflow 高发区(一个请求疯狂产 CQE),务必配合足够大的 CQ 和背压控制。

7.2 内核版本兼容:opcode 探测

io_uring 每个版本都在加新 opcode 和新 flag。你在 6.x 上写的代码,扔到 5.4 的老服务器上,用到的新特性会直接 -EINVAL 报错。生产代码要做能力探测:

struct io_uring_probe *probe = io_uring_get_probe();
if (io_uring_opcode_supported(probe, IORING_OP_SEND_ZC))
    // 用 zero-copy send
else
    // 回退到普通 send
io_uring_free_probe(probe);

一个经验法则:io_uring 的可用性和内核版本强绑定。5.1 是起点,但真正好用要到 5.10+(很多网络特性),生产级要 5.15 LTS 甚至 6.1 LTS 起步。multishot、buffer ring、zero-copy、DEFER_TASKRUN 这些王牌都在较新内核。

7.3 fork 与线程:ring 不能乱共享

io_uring 实例(那个 fd 和对应的 mmap 区)不能跨 fork 安全共享——子进程继承的映射会和父进程抢同一个环,行为未定义。多进程模型(如 Nginx 那种 master-worker)要给每个 worker 建独立的 ring。多线程也推荐 one-ring-per-thread,配合 SINGLE_ISSUER 拿最佳性能,别多个线程往同一个 SQ 塞。

7.4 安全争议:为什么 Google 全线禁用 io_uring

这是 io_uring 绕不开的话题。2023 年 Google 发布安全评估后,在 ChromeOS、Android 以及自家生产服务器上默认禁用了 io_uring,理由是它是「内核攻击面的重灾区」。

为什么?io_uring 为了性能,让大量原本需要多次权限检查的操作走了共享内存的快速路径,代码复杂、异步状态多,历史上爆出过一连串严重漏洞(UAF、权限提升等)。它给了攻击者一个「向内核提交任意异步操作序列」的强大原语,一旦某个 opcode 的实现有 bug,利用起来非常顺手。

这带来一个现实的架构权衡:

  • 可信、受控的后端服务(你自己的数据库、网关、存储节点)→ io_uring 的性能收益巨大,风险可控,放心用。
  • 运行不可信代码、多租户、面向公网的沙箱环境 → 认真评估,很多大厂选择用 seccomp 直接把 io_uring 三个系统调用禁掉。

安全和性能的经典拉扯,没有银弹。用不用,取决于你的威胁模型。

7.5 与 epoll / ASIO / tokio 的定位对比

  • vs epoll:epoll 是 Reactor、只做就绪通知、生态成熟、无处不在、安全面小;io_uring 是 Proactor、能做完整异步 I/O、性能上限更高、但内核版本要求高、安全面大。中低并发用 epoll 完全够,极致性能和统一存储+网络异步才需要 io_uring。
  • vs Boost.ASIO:ASIO 本就是 Proactor 抽象,早年在 Linux 上底层是 epoll 模拟。新版 ASIO 已支持 io_uring 后端,可以无痛把现有 Proactor 代码切到 io_uring。
  • vs Rust tokio:tokio 默认还是 epoll(通过 mio),因为要覆盖广泛的内核版本和保守的安全策略。想在 tokio 里用 io_uring,可选 tokio-uringmonoioglommio 这类 thread-per-core + io_uring 的运行时,后两者在特定负载下吞吐相当能打。

八、总结与展望

回头看,io_uring 做对了几件根本性的事:

  1. 共享内存环形队列,把 I/O 提交和完成从「每次陷内核」变成「批量写内存、偶尔踹内核」,从架构上消灭了系统调用瓶颈。
  2. 统一异步模型,第一次让存储 I/O 和网络 I/O、乃至各种系统调用,都能走同一套高效异步接口,终结了 libaio 只能干 Direct I/O + 存储文件的尴尬。
  3. 可组合的性能杠杆——registered files/buffers、SQPOLL、multishot、buffer ring、zero-copy、linked SQE、DEFER_TASKRUN——层层叠加,把性能天花板一路顶高。

它也不是没有代价:内核版本强绑定、编程模型从 Reactor 切到 Proactor 有学习曲线、CQE overflow 之类的坑需要经验、安全攻击面大到让 Google 全线禁用。这些都是真实存在的工程约束。

我的实用建议:如果你在写可信环境下的高性能后端——数据库、消息队列、API 网关、存储引擎、代理——且能保证内核在 5.15+ 甚至 6.1+,那 io_uring 值得认真投入,性能收益是数量级的。如果你的场景是中等并发的通用 Web 服务,epoll 依然是稳妥、够用、省心的选择,没必要为了追新而追新。

展望未来,io_uring 的边界还在扩张:越来越多的系统调用被异步化,与 eBPF、网络协议栈的结合(如 io_uring + XDP 的零拷贝网络路径)正在探索,用户态 driver、存储直通等场景也在跟进。它正在把「异步」从一个 I/O 特性,变成 Linux 内核交互的一种通用范式。

技术的演进从不是替代,而是分层。epoll 不会消失,io_uring 也不会一统天下。理解它们各自解决什么问题、代价是什么,在合适的场景做出清醒的取舍——这才是工程师该有的手艺。

动手清单:把本文的 raw_uring.cecho_server.c 抄下来编译跑一遍,用 wrk 或自己写个压测客户端灌几万连接,对比一下和你现有 epoll 服务的 CPU 占用和尾延迟。数字会告诉你 io_uring 到底值不值。纸上得来终觉浅。

推荐文章

php腾讯云发送短信
2024-11-18 13:50:11 +0800 CST
Vue 3 中的 Fragments 是什么?
2024-11-17 17:05:46 +0800 CST
html一些比较人使用的技巧和代码
2024-11-17 05:05:01 +0800 CST
程序员茄子在线接单