编程 io_uring 深度实战:Linux 异步 I/O 如何用零拷贝和共享内存把网络吞吐量推到极限

2026-05-04 20:07:01 +0800 CST views 5

io_uring 深度实战:Linux 异步 I/O 如何用零拷贝和共享内存把网络吞吐量推到极限

引言:Linux 异步 I/O 的世纪之痛

如果你在 Linux 上写过高性能网络服务,你一定经历过这样的困境:epoll 的边缘触发模式让你小心翼翼地处理 EAGAIN,磁盘 I/O 仍然阻塞在同步调用上,而 Linux AIO(libaio)的限制让人抓狂——只支持 O_DIRECT、回调模型反人类、错误码语义混乱。这不是你的问题,这是 Linux 内核几十年来在异步 I/O 领域的历史欠债。

2019 年,Linux 5.1 合入了一个全新的子系统:io_uring。它不是对现有 AIO 的修修补补,而是从零开始设计的一套完整异步 I/O 框架。通过共享内存环形队列实现内核与用户态的零拷贝通信,通过批量化提交把系统调用开销摊薄到几乎为零,通过原生支持文件和网络的统一异步接口终结了 epoll + 线程池的缝合怪架构。

三年后的今天,io_uring 已经成为 Linux 内核最活跃的子系统之一,每个内核版本都在持续迭代。从 Rust 的 tokio-uring 到 Go 的 golang.org/x/sys/unix,从 Nginx 的 io_uring 模块到 RocksDB 的 io_uring 后端,主流生态正在全面拥抱这一技术。

本文将从底层架构出发,用大量代码实战带你理解 io_uring 的设计哲学,最后给出生产级的性能优化策略。读完本文,你不仅能写出基于 io_uring 的高性能服务,还能理解为什么 Jens Axboe(io_uring 作者、Linux 块设备维护者)说"这是 Linux I/O 的未来"。


一、历史包袱:为什么我们需要 io_uring

1.1 同步 I/O:最简单的模型,最致命的瓶颈

传统的同步 I/O 模型(read/write)在低并发场景下工作良好,但当你的服务需要同时处理上万连接时,问题就暴露了:

// 传统的同步读取——一个连接一个线程
void* handle_client(void* arg) {
    int fd = *(int*)arg;
    char buf[4096];
    
    while (1) {
        // 如果对端不发送数据,线程就卡在这里
        ssize_t n = read(fd, buf, sizeof(buf));
        if (n <= 0) break;
        
        // 处理数据...
        process(buf, n);
    }
    
    close(fd);
    return NULL;
}

每个连接一个线程的模型在 C10K 问题面前彻底崩溃。线程切换开销、栈内存消耗、锁竞争——每一个都是性能杀手。

1.2 epoll:事件驱动的折中方案

epoll 是 Linux 对 C10K 问题的回答,但它的本质是 I/O 多路复用,不是真正的异步 I/O:

// epoll 的经典用法——事件通知,但读写仍是同步的
int epfd = epoll_create1(0);

struct epoll_event ev = {
    .events = EPOLLIN | EPOLLET,  // 边缘触发
    .data.fd = client_fd
};
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);

while (1) {
    struct epoll_event events[1024];
    int nfds = epoll_wait(epfd, events, 1024, -1);
    
    for (int i = 0; i < nfds; i++) {
        int fd = events[i].data.fd;
        
        // ⚠️ 这里仍然是同步读取!
        // 边缘触发下必须读完所有数据,否则会丢失事件
        while (1) {
            char buf[4096];
            ssize_t n = read(fd, buf, sizeof(buf));
            if (n == -1) {
                if (errno == EAGAIN) break;  // 数据读完了
                perror("read");
                break;
            }
            process(buf, n);
        }
    }
}

epoll 的核心问题:

  1. 磁盘 I/O 不适用:磁盘文件总是"可读"的,epoll 对文件 I/O 无能为力
  2. 通知 ≠ 完成:epoll 告诉你"可以读了",但你还是要自己调 read,这仍然是同步阻塞
  3. 系统调用开销:每次 epoll_wait 都是一次系统调用,频繁调用时开销不可忽视

1.3 Linux AIO:一个失败的尝试

Linux 内核在 2.6 版本引入了 AIO(libaio),但它的设计存在根本性缺陷:

// Linux AIO 的使用——限制重重
#include <libaio.h>

io_context_t ctx;
io_queue_init(128, &ctx);

struct iocb cb;
io_prep_pread(&cb, fd, buf, size, offset);

// ⚠️ 必须使用 O_DIRECT 打开文件!
// ⚠️ 缓冲区必须对齐!
// ⚠️ 不支持 socket!

struct iocb* cbs[1] = { &cb };
io_submit(ctx, 1, cbs);

struct io_event events[1];
io_getevents(ctx, 1, 1, events, NULL);

Linux AIO 的致命缺陷:

  • 只支持 O_DIRECT:必须绕过页缓存,对齐要求苛刻
  • 不支持网络 I/O:只能做文件操作,网络还得用 epoll
  • 系统调用密集:io_submit 和 io_getevents 都是系统调用
  • API 反人类:iocb 结构体字段繁多,回调模型难以使用

这就是 2019 年之前 Linux 异步 I/O 的窘境:epoll 不支持文件,AIO 不支持网络,生产环境只能 epoll + 线程池搞缝合。


二、架构解析:io_uring 如何重新定义异步 I/O

2.1 核心设计哲学:共享内存 + 环形队列

io_uring 的设计可以用一句话概括:用共享内存上的两个环形队列,实现用户态和内核态之间的零拷贝、零系统调用通信

┌─────────────────────────────────────────────────┐
│                   用户空间                         │
│                                                   │
│   ┌───────────────┐     ┌───────────────┐        │
│   │ Submission    │     │ Completion    │        │
│   │ Queue (SQ)    │     │ Queue (CQ)    │        │
│   │               │     │               │        │
│   │  ┌─┬─┬─┬─┬─┐ │     │  ┌─┬─┬─┬─┬─┐ │        │
│   │  │ │ │ │ │ │ │     │  │ │ │ │ │ │ │        │
│   │  └─┴─┴─┴─┴─┘ │     │  └─┴─┴─┴─┴─┘ │        │
│   │   SQE 数组    │     │   CQE 数组    │        │
│   └───────┬───────┘     └───────┬───────┘        │
│           │ 写入                  │ 读取           │
├───────────┼──────────────────────┼────────────────┤
│           │                      │                │
│           ▼                      │                │
│     内核消费 SQE            内核写入 CQE           │
│                                                   │
│                   内核空间                         │
└─────────────────────────────────────────────────┘

关键设计点:

  1. SQ(Submission Queue):用户态写入,内核态读取。用户把 I/O 请求封装成 SQE(Submission Queue Entry)放进 SQ。
  2. CQ(Completion Queue):内核态写入,用户态读取。I/O 完成后,内核把结果封装成 CQE(Completion Queue Entry)放进 CQ。
  3. 共享内存:SQ 和 CQ 通过 mmap 映射到用户空间,内核和用户态直接读写同一块内存,零拷贝
  4. Memory Barrier:用内存屏障代替锁,实现无锁的环形队列操作。

2.2 三个系统调用的极简接口

io_uring 只有三个系统调用,这是它的全部接口:

// 1. 初始化 io_uring 实例
int io_uring_setup(u32 entries, struct io_uring_params *params);

// 2. 提交和等待完成
int io_uring_enter(unsigned int fd, unsigned int to_submit,
                   unsigned int min_complete, unsigned int flags,
                   sigset_t *sig);

// 3. 注册共享资源(文件描述符、缓冲区等)
int io_uring_register(unsigned int fd, unsigned int opcode,
                      void *arg, unsigned int nr_args);

就这三个。对比一下:

  • epoll:epoll_create + epoll_ctl + epoll_wait + read/write = 至少 4 种系统调用
  • AIO:io_setup + io_submit + io_getevents + io_destroy = 4 种系统调用
  • io_uring:3 种系统调用,而且通过 SQPOLL 模式可以完全消除系统调用

2.3 SQE 和 CQE:请求与响应的数据结构

// 提交队列条目(SQE)—— 用户填写,内核消费
struct io_uring_sqe {
    __u8  opcode;        /* I/O 操作类型:read、write、send、recv... */
    __u8  flags;         /* 操作标志 */
    __u16 ioprio;        /* I/O 优先级 */
    __s32 fd;            /* 文件描述符 */
    union {
        __u64 off;       /* 文件偏移量 */
        __u64 addr2;
    };
    union {
        __u64 addr;      /* 缓冲区地址 */
        __u64 splice_off_in;
    };
    __u32 len;           /* 缓冲区长度 */
    union {
        __kernel_rwf_t rw_flags;
        __u32 fsync_flags;
        __u16 poll_events;
        __u32 poll32_events;
        __u32 sync_range_flags;
        __u32 msg_flags;
        __u32 timeout_flags;
        __u32 accept_flags;
        __u32 cancel_flags;
        __u32 open_flags;
        __u32 statx_flags;
        __u32 fadvise_advice;
    };
    __u64 user_data;     /* 用户自定义标识,完成时原样返回 */
    union {
        __u16 buf_index; /* 固定缓冲区索引 */
        __u16 buf_group;
    };
    __u16 personality;
    union {
        __s32 splice_fd_in;
        __u32 file_index;
    };
    __u64 addr3;
    __u64 __pad2[1];
};

// 完成队列条目(CQE)—— 内核填写,用户消费
struct io_uring_cqe {
    __u64 user_data;     /* 对应 SQE 中的 user_data */
    __s32 res;           /* 操作结果:返回字节数或错误码 */
    __u32 flags;         /* 完成标志 */
};

注意 user_data 字段——这是 io_uring 的"回执机制"。你在提交请求时设置一个唯一标识,内核完成 I/O 后原样返回。这意味着你不需要维护任何"请求-响应"的映射关系,内核帮你做到了。

2.4 数据流:一次 I/O 操作的完整生命周期

以一次 read 操作为例:

1. 用户态:准备 SQE
   ┌─────────────────────────────┐
   │ sqe->opcode = IORING_OP_READ│
   │ sqe->fd     = client_fd    │
   │ sqe->addr   = buffer       │
   │ sqe->len    = buffer_size  │
   │ sqe->user_data = req_id    │
   └──────────┬──────────────────┘
              │ 写入 SQ
              ▼
2. 用户态:通知内核(可选,SQPOLL 模式下不需要)
   io_uring_enter(fd, 1, 0, 0, NULL)
              │
              ▼
3. 内核态:从 SQ 取出 SQE,执行 I/O 操作
   ┌─────────────────────────────┐
   │ vfs_read() → 文件系统 → 磁盘 │
   │ 或                          │
   │ sock_recvmsg() → 网络栈      │
   └──────────┬──────────────────┘
              │ I/O 完成
              ▼
4. 内核态:写入 CQE
   ┌─────────────────────────────┐
   │ cqe->user_data = req_id    │
   │ cqe->res      = bytes_read │
   │ cqe->flags    = 0          │
   └──────────┬──────────────────┘
              │ 写入 CQ
              ▼
5. 用户态:从 CQ 取出 CQE,处理结果
   if (cqe->res < 0) {
       // 错误处理
   } else {
       // 处理读取到的数据
   }

整个过程的关键:用户态和内核态之间没有数据拷贝,只有索引的传递


三、代码实战:从 liburing 到裸系统调用

3.1 用 liburing 快速上手

liburing 是 io_uring 的官方封装库,隐藏了 mmap、环形队列管理等复杂性:

#include <liburing.h>
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

#define QUEUE_DEPTH 4

int main(int argc, char *argv[]) {
    struct io_uring ring;
    int fd, ret;
    
    // 1. 初始化 io_uring
    ret = io_uring_queue_init(QUEUE_DEPTH, &ring, 0);
    if (ret < 0) {
        fprintf(stderr, "io_uring_queue_init: %s\n", strerror(-ret));
        return 1;
    }
    
    // 2. 打开文件
    fd = open(argv[1], O_RDONLY);
    if (fd < 0) {
        perror("open");
        return 1;
    }
    
    // 3. 准备并提交读请求
    char buffers[QUEUE_DEPTH][4096];
    struct iovec iov[QUEUE_DEPTH];
    
    for (int i = 0; i < QUEUE_DEPTH; i++) {
        struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
        if (!sqe) break;
        
        iov[i].iov_base = buffers[i];
        iov[i].iov_len = sizeof(buffers[i]);
        
        io_uring_prep_readv(sqe, fd, &iov[i], 1, i * 4096);
        io_uring_sqe_set_data(sqe, (void*)(long)i);  // 设置请求标识
    }
    
    // 4. 提交所有请求
    ret = io_uring_submit(&ring);
    if (ret < 0) {
        fprintf(stderr, "io_uring_submit: %s\n", strerror(-ret));
        return 1;
    }
    
    // 5. 等待完成
    for (int i = 0; i < QUEUE_DEPTH; i++) {
        struct io_uring_cqe *cqe;
        ret = io_uring_wait_cqe(&ring, &cqe);
        if (ret < 0) {
            fprintf(stderr, "io_uring_wait_cqe: %s\n", strerror(-ret));
            continue;
        }
        
        int req_id = (int)(long)io_uring_cqe_get_data(cqe);
        printf("Request %d: read %d bytes\n", req_id, cqe->res);
        
        io_uring_cqe_seen(&ring, cqe);  // 标记为已处理
    }
    
    // 6. 清理
    close(fd);
    io_uring_queue_exit(&ring);
    return 0;
}

编译运行:

gcc -o io_uring_read io_uring_read.c -luring
./io_uring_read /tmp/testfile

3.2 网络服务:用 io_uring 替代 epoll

这是 io_uring 最令人兴奋的应用场景——用统一的异步接口处理网络 I/O:

#include <liburing.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string.h>
#include <stdlib.h>

#define QUEUE_DEPTH  256
#define BUFFER_SIZE  4096
#define MAX_CONNECTIONS 1024

// 连接状态
struct conn_state {
    int fd;
    char read_buf[BUFFER_SIZE];
    char write_buf[BUFFER_SIZE];
    int write_len;
    enum {
        CONN_ACCEPTING,
        CONN_READING,
        CONN_WRITING,
        CONN_CLOSING
    } state;
};

static struct io_uring ring;
static struct conn_state conns[MAX_CONNECTIONS];

// 提交 accept 请求
static void submit_accept(int listen_fd) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    struct sockaddr_in client_addr;
    socklen_t client_len = sizeof(client_addr);
    
    io_uring_prep_accept(sqe, listen_fd, 
                         (struct sockaddr*)&client_addr,
                         &client_len, 0);
    io_uring_sqe_set_data64(sqe, CONN_ACCEPTING);
}

// 提交 read 请求
static void submit_read(int conn_idx) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    struct conn_state *cs = &conns[conn_idx];
    
    io_uring_prep_recv(sqe, cs->fd, cs->read_buf, BUFFER_SIZE, 0);
    io_uring_sqe_set_data64(sqe, conn_idx);
    cs->state = CONN_READING;
}

// 提交 write 请求
static void submit_write(int conn_idx, int len) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    struct conn_state *cs = &conns[conn_idx];
    
    cs->write_len = len;
    io_uring_prep_send(sqe, cs->fd, cs->write_buf, len, 0);
    io_uring_sqe_set_data64(sqe, conn_idx | 0x10000);  // 高位标记写操作
    cs->state = CONN_WRITING;
}

// Echo 服务器主循环
int main() {
    int listen_fd;
    struct sockaddr_in addr;
    
    // 创建监听 socket
    listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons(8080);
    
    bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(listen_fd, 128);
    
    // 初始化 io_uring
    io_uring_queue_init(QUEUE_DEPTH, &ring, 0);
    
    // 提交初始 accept
    submit_accept(listen_fd);
    
    printf("Echo server listening on :8080 (io_uring mode)\n");
    
    // 事件循环
    while (1) {
        struct io_uring_cqe *cqe;
        int ret = io_uring_wait_cqe(&ring, &cqe);
        if (ret < 0) continue;
        
        int user_data = io_uring_cqe_get_data64(cqe);
        int res = cqe->res;
        io_uring_cqe_seen(&ring, cqe);
        
        if (res < 0) {
            // 错误处理
            if (user_data == CONN_ACCEPTING) {
                submit_accept(listen_fd);  // 重新提交 accept
            }
            continue;
        }
        
        if (user_data == CONN_ACCEPTING) {
            // 新连接到达
            int conn_fd = res;
            int idx = -1;
            for (int i = 0; i < MAX_CONNECTIONS; i++) {
                if (conns[i].fd == 0) { idx = i; break; }
            }
            
            if (idx >= 0) {
                conns[idx].fd = conn_fd;
                submit_read(idx);
            } else {
                close(conn_fd);  // 连接数已满
            }
            
            submit_accept(listen_fd);  // 继续接受新连接
            
        } else if (user_data & 0x10000) {
            // 写操作完成
            int conn_idx = user_data & 0xFFFF;
            struct conn_state *cs = &conns[conn_idx];
            
            if (res == cs->write_len) {
                submit_read(conn_idx);  // 继续读
            } else {
                // 写不完整,继续写
                memmove(cs->write_buf, cs->write_buf + res, 
                        cs->write_len - res);
                cs->write_len -= res;
                submit_write(conn_idx, cs->write_len);
            }
            
        } else {
            // 读操作完成
            int conn_idx = user_data;
            struct conn_state *cs = &conns[conn_idx];
            
            if (res == 0) {
                // 连接关闭
                close(cs->fd);
                memset(cs, 0, sizeof(*cs));
            } else {
                // Echo:把读到的数据写回去
                memcpy(cs->write_buf, cs->read_buf, res);
                submit_write(conn_idx, res);
            }
        }
    }
    
    io_uring_queue_exit(&ring);
    return 0;
}

注意这个代码的核心优势:

  1. 没有 epoll:不再需要 epoll_create + epoll_ctl + epoll_wait
  2. 没有线程池:所有 I/O 操作都是真正的异步
  3. 统一接口:accept、recv、send 全都用 io_uring 的 SQE/CQE 机制
  4. 批量提交:可以在一次 io_uring_submit 中提交多个操作

3.3 高级特性:Multishot Accept

Linux 6.0 引入了一个杀手级特性——Multishot Accept。传统模式下,每次 accept 完成后需要重新提交一个新的 accept 请求;而 Multishot Accept 只需提交一次,内核就会持续为你接收新连接:

// 传统模式:每次 accept 后需要重新提交
static void submit_accept(int listen_fd) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_accept(sqe, listen_fd, ...);
    io_uring_sqe_set_data64(sqe, CONN_ACCEPTING);
}
// 每次 CQE 返回后都要调 submit_accept()

// Multishot 模式:一次提交,持续生效
static void submit_multishot_accept(int listen_fd) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_multishot_accept(sqe, listen_fd, NULL, NULL, 0);
    io_uring_sqe_set_data64(sqe, CONN_ACCEPTING);
}
// 一次调用,后续每个新连接都会产生一个 CQE

Multishot 不止 accept,还支持:

  • IORING_OP_READ_MULTISHOT:持续读取,每次有数据就产生 CQE
  • IORING_OP_ACCEPT_MULTISHOT:持续接受连接
  • IORING_POLL_MULTISHOT:持续监听事件

这些特性让"提交一次、持续回调"成为可能,极大减少了 SQE 的提交量。


四、性能深度剖析:io_uring 为什么这么快

4.1 系统调用消除:SQPOLL 模式

传统模式下,每次提交 I/O 请求都需要调用 io_uring_enter() 进入内核态。对于高频 I/O 场景(如 NVMe SSD 的百万 IOPS),系统调用本身就是瓶颈。

io_uring 的 SQPOLL(Submission Queue Polling)模式彻底消除了这个问题:

// 初始化时启用 SQPOLL
struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_cpu = 3;      // 绑定到 CPU 3
params.sq_thread_idle = 2000;  // 空闲 2 秒后休眠

io_uring_setup(256, &params);

SQPOLL 模式下,内核会启动一个内核线程(sq thread),持续轮询 SQ。当用户态写入 SQE 后,sq thread 会自动发现并处理——不需要任何系统调用

传统模式:
  用户写入 SQE → io_uring_enter() → 内核处理 → CQE
  
SQPOLL 模式:
  用户写入 SQE → sq thread 自动发现 → 内核处理 → CQE
                ^^^^^^^^^^^^^^^^^^^^
                无系统调用!内核线程在轮询!

sq thread 在空闲一段时间后会进入休眠(由 sq_thread_idle 控制),此时用户态需要通过 io_uring_enter() 唤醒它。但只要 I/O 请求持续到达,sq thread 就一直在运行,完全零系统调用。

4.2 批量提交:摊薄开销

io_uring 的另一个性能利器是批量提交。你可以在 SQ 中攒一批请求,然后一次 io_uring_submit() 全部提交:

// 批量提交:一次提交多个 I/O 请求
#define BATCH_SIZE 64

void batch_submit_reads(int fd, off_t start, int count) {
    // 1. 准备一批 SQE
    for (int i = 0; i < count && i < BATCH_SIZE; i++) {
        struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
        
        io_uring_prep_read(sqe, fd, buffers[i], BUFFER_SIZE, 
                           start + i * BUFFER_SIZE);
        io_uring_sqe_set_data64(sqe, i);
    }
    
    // 2. 一次性提交——只产生一次系统调用
    int submitted = io_uring_submit(&ring);
    printf("Submitted %d requests in one syscall\n", submitted);
}

对比一下:

方式提交 1000 个 read 请求系统调用次数
同步 read1000 次 read1000
epoll + read1 次 epoll_wait + N 次 read1 + N
io_uring(逐个)1000 次 io_uring_submit1000
io_uring(批量)1 次 io_uring_submit1
io_uring(SQPOLL)0 次0

4.3 固定资源注册:减少内核查找开销

每次 I/O 请求都需要内核通过文件描述符查找对应的 file 结构体。对于高频操作,这个查找开销不小。io_uring 允许你提前注册资源:

// 注册固定文件描述符
int fds[MAX_FDS] = {fd1, fd2, fd3, ...};
io_uring_register(ring_fd, IORING_REGISTER_FILES, fds, MAX_FDS);

// 使用固定文件描述符——不再通过 fd 查找
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, 0 /* 直接用索引而非 fd */, buf, len, off);
sqe->flags |= IOSQE_FIXED_FILE;  // 标记使用固定文件

// 注册固定缓冲区
struct iovec iov[BUFFER_COUNT] = {
    { .iov_base = buf0, .iov_len = BUF_SIZE },
    { .iov_base = buf1, .iov_len = BUF_SIZE },
    // ...
};
io_uring_register(ring_fd, IORING_REGISTER_BUFFERS, iov, BUFFER_COUNT);

// 使用固定缓冲区
io_uring_prep_read_fixed(sqe, fixed_fd_idx, buf0, BUF_SIZE, off, 0);

注册资源的好处:

  • 固定文件:内核直接通过索引访问,跳过 fd → file 的查找
  • 固定缓冲区:内核提前 pin 住内存页,避免每次 I/O 都要做 get_user_pages

Linux 6.1 引入了 IORING_OP_SENDMSG_ZC(Zero-Copy Sendmsg),让数据直接从用户缓冲区发送到网卡,不经过内核缓冲区:

// 零拷贝发送
struct msghdr msg = {
    .msg_iov = &iov,
    .msg_iovlen = 1,
};

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_sendmsg_zc(sqe, fd, &msg, 0);
io_uring_sqe_set_data64(sqe, req_id);

// 零拷贝会产生两个 CQE:
// 1. IORING_CQE_F_NOTIF 表示数据已发送完成
// 2. IORING_CQE_F_ZC_NOTIFY 表示网卡确认数据已刷出

这对高性能网络服务(如 HTTP 服务器、消息队列)意义重大——大文件发送时避免了内核态的内存拷贝。

4.5 性能基准测试:io_uring vs epoll vs AIO

以下是在 NVMe SSD 上的 4K 随机读 IOPS 对比(libaio 的官方基准数据):

方式IOPSCPU 利用率系统调用次数/s
同步 read~800K100%800K
libaio (O_DIRECT)~1.5M85%300K
io_uring (默认)~2.0M60%100K
io_uring (SQPOLL)~2.5M45%0
io_uring (SQPOLL + fixed)~2.8M40%0

网络吞吐量对比(Echo Server,单核):

方式请求/秒P99 延迟
epoll (ET)~350K120μs
io_uring (默认)~520K85μs
io_uring (SQPOLL)~680K55μs
io_uring (SQPOLL + multishot)~750K42μs

五、生产级优化策略

5.1 CQ 批量收割:减少 io_uring_wait_cqe 的调用

// ❌ 低效:逐个等待 CQE
while (1) {
    struct io_uring_cqe *cqe;
    io_uring_wait_cqe(&ring, &cqe);
    process_cqe(cqe);
    io_uring_cqe_seen(&ring, cqe);
}

// ✅ 高效:批量收割 CQE
while (1) {
    struct io_uring_cqe *cqes[BATCH_SIZE];
    
    // 1. 先尝试非阻塞地收割已完成的 CQE
    int count = io_uring_peek_batch_cqe(&ring, cqes, BATCH_SIZE);
    
    if (count == 0) {
        // 2. 没有已完成的,等待至少一个
        struct io_uring_cqe *cqe;
        io_uring_wait_cqe(&ring, &cqe);
        cqes[0] = cqe;
        count = 1;
        
        // 3. 可能还有更多已完成的,继续收割
        count += io_uring_peek_batch_cqe(&ring, cqes + 1, 
                                          BATCH_SIZE - 1);
    }
    
    // 4. 批量处理
    for (int i = 0; i < count; i++) {
        process_cqe(cqes[i]);
    }
    
    // 5. 批量标记已处理
    io_uring_cq_advance(&ring, count);
}

5.2 合理选择 Polling 策略

io_uring 有三种 Polling 模式,适用于不同场景:

┌──────────────────────────────────────────────────┐
│ 场景                    │ 推荐 Polling 模式       │
├──────────────────────────────────────────────────┤
│ 低延迟 NVMe 存储        │ IOPOLL + SQPOLL        │
│ 高吞吐网络服务          │ SQPOLL                 │
│ 通用混合 I/O            │ 默认(中断模式)         │
│ 低功耗场景              │ 默认 + 适当 cq_entries  │
└──────────────────────────────────────────────────┘
// IOPOLL:内核主动轮询 I/O 完成状态(适用于 NVMe 等高速设备)
struct io_uring_params params = {0};
params.flags = IORING_SETUP_IOPOLL;
io_uring_setup(256, &params);

// SQPOLL:内核线程轮询提交队列
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_idle = 2000;  // ms

// 两者结合:极致性能
params.flags = IORING_SETUP_SQPOLL | IORING_SETUP_IOPOLL;

5.3 缓冲区自动选择:Provided Buffers

在网络服务中,每个连接需要独立的读缓冲区。传统方式是预先分配或动态分配,io_uring 提供了更优雅的方案——Provided Buffers:

// 1. 注册缓冲区池
struct io_uring_probe *probe = io_uring_get_probe_ring(&ring);
// 检查是否支持 IORING_OP_PROVIDE_BUFFERS

#define BUF_GROUP 1
#define BUF_SIZE  4096
#define BUF_COUNT 256

// 注册缓冲区
for (int i = 0; i < BUF_COUNT; i++) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_provide_buffers(sqe, buffers[i], BUF_SIZE, 
                                   1, BUF_GROUP, i);
}
io_uring_submit(&ring);

// 2. 提交 recv 请求时,指定缓冲区组
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, fd, NULL, 0, 0);  // 不指定缓冲区!
sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->buf_group = BUF_GROUP;

// 3. 从 CQE 中获取内核选择的缓冲区 ID
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);

int buf_id = cqe->flags >> 16;  // IOSQE_BUFFER_SELECT 的结果
char *data = buffers[buf_id];
int len = cqe->res;

// 4. 处理完毕后归还缓冲区
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_provide_buffers(sqe, buffers[buf_id], BUF_SIZE,
                               1, BUF_GROUP, buf_id);
io_uring_submit(&ring);

这个机制的美妙之处在于:内核在数据到达时自动从缓冲区池中选择一个,你不需要预分配 连接数 × 缓冲区大小 的内存。

5.4 超时与取消:优雅的错误处理

// 提交一个带超时的读请求
struct __kernel_timespec ts = {
    .tv_sec = 5,
    .tv_nsec = 0,
};

// 方法一:使用 LINK_TIMEOUT(推荐)
struct io_uring_sqe *read_sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(read_sqe, fd, buf, buf_size, 0);
read_sqe->flags |= IOSQE_IO_LINK;  // 链接下一个 SQE
io_uring_sqe_set_data64(read_sqe, REQ_READ);

struct io_uring_sqe *timeout_sqe = io_uring_get_sqe(&ring);
io_uring_prep_link_timeout(timeout_sqe, &ts, 0);
io_uring_sqe_set_data64(timeout_sqe, REQ_TIMEOUT);

io_uring_submit(&ring);

// 如果 5 秒内读完成,timeout 自动取消
// 如果 5 秒超时,read 自动取消,CQE 中 res = -ECANCELED

// 方法二:手动取消
struct io_uring_sqe *cancel_sqe = io_uring_get_sqe(&ring);
io_uring_prep_cancel64(cancel_sqe, req_id_to_cancel, 0);
io_uring_submit(&ring);

io_uring 默认不保证请求的执行顺序——这正是异步的本质。但有些场景需要顺序保证(比如先 write 再 fsync):

// write → fsync 顺序执行
struct io_uring_sqe *write_sqe = io_uring_get_sqe(&ring);
io_uring_prep_write(write_sqe, fd, data, len, offset);
write_sqe->flags |= IOSQE_IO_LINK;  // ← 链接下一个操作
io_uring_sqe_set_data64(write_sqe, REQ_WRITE);

struct io_uring_sqe *fsync_sqe = io_uring_get_sqe(&ring);
io_uring_prep_fsync(fsync_sqe, fd, IORING_FSYNC_DATASYNC);
io_uring_sqe_set_data64(fsync_sqe, REQ_FSYNC);

io_uring_submit(&ring);
// fsync 一定在 write 完成后执行
// 如果 write 失败,fsync 不会执行,直接返回 -ECANCELED

5.6 CQE 溢出处理

当 CQ 队列满了但内核仍有完成事件时,会发生 CQE 溢出。这是生产环境最容易踩的坑:

// 检测溢出
if (io_uring_cq_ready(&ring) == 0 && 
    (*ring.cq.koverflow > 0 || 
     io_uring_cq_ready(&ring) > 0)) {
    // 处理溢出
    printf("CQ overflow detected: %u events dropped\n", 
           *ring.cq.koverflow);
}

// 预防措施:
// 1. 给 CQ 分配足够大的空间(通常 SQ 深度的 2-4 倍)
struct io_uring_params params = {0};
params.cq_entries = QUEUE_DEPTH * 4;  // CQ 是 SQ 的 4 倍
params.flags |= IORING_SETUP_CQSIZE;

// 2. 及时收割 CQE,不要积压
// 3. 使用 IORING_SETUP_COOP_TASKRUN 减少内核中断风暴

六、生态现状与框架集成

6.1 Rust:tokio-uring

Rust 生态对 io_uring 的支持最为活跃。tokio-uring 基于 io_uring 提供了完全异步的运行时:

use tokio_uring::{start, net::TcpListener};
use tokio_uring::buf::IoBuf;

async fn echo_server() -> std::io::Result<()> {
    let listener = TcpListener::bind("0.0.0.0:8080")?;
    
    loop {
        let (stream, addr) = listener.accept().await?;
        println!("New connection from {}", addr);
        
        tokio_uring::spawn(async move {
            let mut stream = stream;
            
            loop {
                // 使用 io_uring 的 provided buffers
                let buf = vec![0u8; 4096];
                let (res, buf) = stream.read(buf).await;
                
                match res {
                    Ok(n) if n > 0 => {
                        let (res, _) = stream.write_all(buf.slice(..n)).await;
                        if res.is_err() { break; }
                    }
                    _ => break,
                }
            }
        });
    }
}

fn main() {
    start(echo_server()).unwrap();
}

6.2 Go:golang.org/x/sys/unix

Go 标准库的 net 包使用 epoll,但你可以通过 golang.org/x/sys/unix 直接使用 io_uring 系统调用:

package main

import (
    "fmt"
    "golang.org/x/sys/unix"
)

func initIOUring(entries uint32) (int, *unix.IoUringParams, error) {
    params := &unix.IoUringParams{}
    fd, err := unix.IoUringSetup(entries, params)
    if err != nil {
        return -1, nil, err
    }
    return fd, params, nil
}

func main() {
    fd, params, err := initIOUring(256)
    if err != nil {
        panic(err)
    }
    defer unix.Close(fd)
    
    fmt.Printf("io_uring fd=%d, features=0x%x\n", fd, params.Features)
}

6.3 Nginx:ngx_http_iouring_module

Nginx 社区已经开发了 io_uring 模块,可以在不改动 Nginx 架构的情况下用 io_uring 替代 epoll 处理文件 I/O:

# nginx.conf
events {
    io_uring on;                # 启用 io_uring
    io_uring_sq_entries 512;    # SQ 深度
    io_uring_cq_entries 2048;   # CQ 深度
    io_uring_thread_poll on;    # SQPOLL 模式
}

6.4 数据库:RocksDB 与 QEMU

  • RocksDB 从 7.x 版本开始支持 io_uring 作为 PosixEnv 的替代后端,在 NVMe SSD 上可获得 15-30% 的吞吐提升。
  • QEMU 使用 io_uring 替代 Linux AIO 作为 virtio-blk 的 I/O 引擎,减少 guest I/O 延迟。

七、踩坑实录:生产环境的血泪教训

7.1 内核版本选择

io_uring 在不同内核版本上的功能差异巨大:

内核版本关键特性生产推荐
5.1基础 io_uring(文件读写)❌ 不推荐
5.5网络操作支持(send/recv)⚠️ 勉强可用
5.6提供缓冲区、超时、取消✅ 最低推荐版本
5.11SQPOLL + IOPOLL 改进✅ 稳定
5.19CQE 批量收割优化✅ 推荐
6.0Multishot Accept✅ 推荐
6.1零拷贝发送✅ 强烈推荐
6.6+稳定 + 大量性能优化✅✅ 最佳选择

教训一:不要在 5.x 内核上使用 io_uring 做生产级网络服务。至少 5.10 LTS,推荐 6.1+。

7.2 CQ 队列溢出导致数据丢失

这是最常见的问题。当你的 CQ 队列太小而 I/O 完成速度太快时,CQE 会被丢弃:

[  123.456] io_uring: CQ ring overflow, 42 CQEs dropped

解决方案:

  1. CQ 队列至少是 SQ 的 2 倍
  2. 及时收割 CQE(不要在业务逻辑中做重计算)
  3. 使用 IORING_SETUP_COOP_TASKRUN 减少中断风暴

7.3 SQPOLL 的 CPU 亲和性问题

SQPOLL 的内核线程绑定到指定 CPU,如果你的进程也在同一个 CPU 上运行,会导致 CPU 争抢:

// ❌ 错误:sq_thread_cpu 和进程运行在同一个 CPU
params.sq_thread_cpu = 0;  // 和主线程争 CPU 0

// ✅ 正确:把 sq_thread 放到专用 CPU
params.sq_thread_cpu = 2;  // 主线程在 CPU 0-1,sq_thread 在 CPU 2

7.4 与信号处理的冲突

io_uring 的信号通知机制与传统 signal 有冲突。如果你的程序依赖 SIGIO/SIGUSR1 等信号:

// 使用 IORING_SETUP_SQPOLL 时,sq_thread 不响应信号
// 需要改用 io_uring 的信号通知机制

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_poll_add(sqe, signal_fd, POLLIN);
// 用 signalfd 把信号转换为 fd 事件

八、未来展望:io_uring 的演进方向

8.1 io_uring 在内核中的持续进化

io_uring 仍然是 Linux 内核最活跃的子系统之一,每个版本都有新特性合入:

  • 6.6+:io_uring 支持 IORING_OP_WAITID(等待子进程)、IORING_OP_FUTEX_WAIT(futex 异步化),让进程管理也能走 io_uring
  • 6.8+IORING_OP_READ_BUNDLE——批量读优化,把多个小 read 合并为一次大 read
  • 6.12+IORING_OP_SENDMSG_ZC 稳定化,支持更多零拷贝场景
  • 未来:io_uring + eBPF 融合,允许在内核中用 eBPF 程序处理 I/O 完成事件

8.2 标准化趋势

io_uring 正在成为 Linux 异步 I/O 的事实标准:

  • glibc 已经在 2.36+ 中添加了 io_uring 的底层支持
  • FreeBSD 社区正在开发兼容层
  • 语言运行时(Rust、Go、Python)都在积极集成

8.3 对开发者的启示

io_uring 不仅仅是一个新的 I/O 接口,它代表了一种新的编程模型:

  1. 从"系统调用驱动"到"共享内存驱动":I/O 请求不再通过系统调用传递,而是通过共享内存队列
  2. 从"通知+同步"到"提交+完成":不再需要"通知可读→同步读取"的两步走,而是一步到位
  3. 从"文件和网络分离"到"统一异步接口":epoll 处理网络、AIO 处理文件的历史结束了

九、总结

io_uring 是 Linux 内核近十年来最革命性的 I/O 子系统。它用共享内存 + 环形队列 + 批量提交的三板斧,从根本上解决了 Linux 异步 I/O 的历史问题:

维度epoll + 线程池io_uring
系统调用高频(epoll_wait + read/write)零(SQPOLL 模式)
内存拷贝有(内核↔用户态数据拷贝)最小化(共享内存 + 零拷贝发送)
文件 I/O不支持异步原生支持
网络 I/O通知+同步读取真正异步
接口统一否(epoll + AIO 缝合)是(统一 SQE/CQE)
延迟高(系统调用 + 两步走)低(零系统调用 + 一步到位)

如果你在 2026 年还在用 epoll + 线程池做高性能 I/O,是时候认真考虑 io_uring 了。内核 6.6+ 已经足够稳定,liburing 的 API 也足够成熟。从 Echo Server 开始,逐步替换你的 I/O 热点路径——你的 CPU 和延迟指标会感谢你。


参考资源

复制全文 生成海报 io_uring Linux 异步I/O 网络编程 高性能

推荐文章

微信内弹出提示外部浏览器打开
2024-11-18 19:26:44 +0800 CST
免费常用API接口分享
2024-11-19 09:25:07 +0800 CST
JavaScript中的常用浏览器API
2024-11-18 23:23:16 +0800 CST
维护网站维护费一年多少钱?
2024-11-19 08:05:52 +0800 CST
如何开发易支付插件功能
2024-11-19 08:36:25 +0800 CST
利用Python构建语音助手
2024-11-19 04:24:50 +0800 CST
使用Rust进行跨平台GUI开发
2024-11-18 20:51:20 +0800 CST
前端代码规范 - Commit 提交规范
2024-11-18 10:18:08 +0800 CST
一文详解回调地狱
2024-11-19 05:05:31 +0800 CST
如何使用go-redis库与Redis数据库
2024-11-17 04:52:02 +0800 CST
使用xshell上传和下载文件
2024-11-18 12:55:11 +0800 CST
windows下mysql使用source导入数据
2024-11-17 05:03:50 +0800 CST
程序员茄子在线接单