io_uring 多 Shot Accept 与零拷贝实战:从零构建高性能网络代理

引言:当 epoll 遇见 io_uring

2026 年的 Linux 服务器编程,io_uring 已不再是"未来时"。从 5.1 的初次亮相,到 6.x 内核中 io_uring 已支持多 shot accept、多 shot recv、registered buffers、fixed file、send_zc 等高级特性,io_uring 生态已经成熟到可以支撑生产级网络代理的开发。

然而,大多数教程仍停留在"echo server"层面。本文直面真实工程场景:如何用 io_uring 的多 shot accept + registered buffers + send_zc 组合拳,构建一个能跑满 10GbE 的反向代理服务器? 我们将从原理剖析到完整实现,展示 io_uring 如何从根本上改变网络 I/O 的编程模型。

一、旧模型的核心痛点

1.1 accept 的 O(n) 问题

传统 epoll + 阻塞 accept 的模式下,高并发场景中 accept() 成为严重瓶颈:

while (1) {
    client_fd = accept(server_fd, ...);  // 每次只能 accept 一个连接
    // 64k 并发连接意味着要调用 accept 64000 次
}

Linux 4.17 引入了 SO_REUSEPORT,让多线程各自 bind 同一个端口来分散 accept 压力,但线程间仍存在连接分配不均(thundering herd)问题。accept() 本身不支持批量操作,在高 PPS 场景下 syscall overhead 显著。

1.2 read/write 的拷贝代价

传统代理的转发循环:

while (n = read(client_fd, buf, BUF_SIZE)) {
    write(server_fd, buf, n);  // 数据经历了:网卡→内核→用户态→内核→网卡
}

数据在用户态和内核态之间来回拷贝两次。虽然 splice()/sendfile() 可以实现 zero-copy,但它们对 socket 的支持有诸多限制(sendfile 要求源必须是 page cache 文件,不能是 socket)。

1.3 syscall 开销

epoll 模式每次 I/O 至少需要 2 次 syscall(epoll_wait + read/write)。10GbE 线速处理 1500B 包意味着约 812K PPS,syscall 开销成为瓶颈。

二、io_uring 核心机制回顾

2.1 共享环形队列架构

io_uring 通过两个环形队列(Submission Queue / Completion Queue)实现用户态与内核的零 syscall 通信:

用户态                          内核态
  │                              │
  ├─ 填入 SQE ──────────────────→│
  │  tail++                      │
  │                              ├── 处理 SQE(可批量)
  │                              │
  │←────────────── 填入 CQE ─────┤
  │  head++                      │
  │                              │

关键特性:
- SQPOLL 模式:内核线程主动轮询 SQ,用户态无需 io_uring_enter() syscall
- IORING_SETUP_SQPOLL:内核轮询线程消耗 CPU,但省去 syscall
- 批量提交:一次 io_uring_enter() 可提交多个 SQE

2.2 Registered Buffers(预注册缓冲区)

IORING_REGISTER_BUFFERS 允许预先注册一组持久缓冲区给 io_uring:

struct iovec iov[BUF_COUNT];
for (int i = 0; i < BUF_COUNT; i++) {
    iov[i].iov_base = malloc(BUF_SIZE);
    iov[i].iov_len = BUF_SIZE;
}
io_uring_register_buffers(&ring, iov, BUF_COUNT);

注册后,内核可预先 mmap 这些缓冲区,避免每次 I/O 的 get_user_pages/unpin 开销,在读操作中可节省约 15-20% 延迟。

2.3 Fixed File(固定文件描述符)

IORING_REGISTER_FILES 预先注册 fd 数组,提交 SQE 时只需指定数组索引而非 fd 数值,避免每次 fget/fput 的文件表查找。

三、多 Shot Accept:accept 的范式革命

3.1 单 Shot vs 多 Shot

io_uring 的 IORING_OP_ACCEPT 起初与传统 accept() 行为一致:每次 SQE 只完成一个 accept。这意味着高并发场景下仍需反复提交 accept SQE。

多 Shot Accept(IORING_ACCEPT_MULTISHOT,Linux 5.19+)则一举解决了该问题:一个 accept SQE 完成第一个连接后,内核自动继续监听,每次新连接到达时自动产生 CQE,无需用户态重新提交。

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_multishot_accept(sqe, server_fd, &addr, &addrlen, 0);
sqe->user_data = MULTISHOT_ACCEPT_USER_DATA;
io_uring_submit(&ring);

完成事件的区别:

单 Shot:  提交 accept → 1个CQE → 重新提交 accept → 1个CQE → ...(N个连接 = N次提交)
多 Shot:  提交 accept → 1个CQE → 2个CQE → 3个CQE → ...(N个连接 = 1次提交)

多 Shot Accept 配合 SQPOLL 时,除第一次填充 SQE 外,全程零 syscall。

3.2 多 Shot 的终止语义

当连接被关闭或出错时,多 shot accept 会自动返回一个错误的 CQE,标志多 shot 操作终止。此时需要重新提交 accept SQE 恢复监听。

if (cqe->res == -ECONNABORTED || cqe->res == -EBADF) {
    // 多 shot 被终止,需要重新提交
    submit_multishot_accept(&ring, server_fd);
}

四、核心数据结构

#include <liburing.h>
#include <netinet/in.h>

#define BUF_SIZE       16384
#define BUF_COUNT      4096
#define BACKLOG        4096
#define MAX_CONNECTIONS 65536

// 连接状态
enum conn_state {
    CONN_ACCEPTED,
    CONN_READING_FROM_CLIENT,
    CONN_CONNECTING_UPSTREAM,
    CONN_READING_FROM_UPSTREAM,
    CONN_CLOSING,
};

// 连接上下文
struct connection {
    int client_fd;
    int upstream_fd;
    enum conn_state state;
    unsigned int buf_idx;       // registered buffer 索引
    size_t bytes_pending;        // 待转发字节数
    struct connection *partner;  // 对端连接
};

// 代理服务器上下文
struct proxy_server {
    struct io_uring ring;
    struct io_uring_buf_ring *buf_ring;
    int server_fd;
    struct sockaddr_in upstream_addr;

    // registered buffers
    struct iovec iov[BUF_COUNT];

    // 连接池
    struct connection conn_pool[MAX_CONNECTIONS];
    int next_conn_idx;
};

五、从零构建:多 Shot Accept 代理

5.1 初始化 io_uring 与注册资源

int proxy_init(struct proxy_server *proxy, const char *listen_port) {
    struct io_uring_params params = {0};
    int ret;

    // 配置 SQPOLL + 多 shot 支持
    params.flags = IORING_SETUP_SQPOLL | IORING_SETUP_SQ_AFF;
    params.sq_thread_idle = 2000;  // SQPOLL 空闲 2s 退出
    params.sq_thread_cpu = 0;      // 绑定 CPU0 给 SQPOLL 线程

    ret = io_uring_queue_init_params(4096, &proxy->ring, &params);
    if (ret < 0) {
        fprintf(stderr, "io_uring_init: %s\n", strerror(-ret));
        return ret;
    }

    // 检查是否支持多 shot accept
    if (!(params.features & IORING_FEAT_RECQSEND_TIMEOUT)) {
        fprintf(stderr, "kernel doesn't support multishot accept\n");
        return -1;
    }

    // 注册缓冲区
    for (int i = 0; i < BUF_COUNT; i++) {
        proxy->iov[i].iov_base = aligned_alloc(4096, BUF_SIZE);
        proxy->iov[i].iov_len = BUF_SIZE;
    }
    ret = io_uring_register_buffers(&proxy->ring, proxy->iov, BUF_COUNT);
    if (ret < 0) {
        fprintf(stderr, "register_buffers: %s\n", strerror(-ret));
        return ret;
    }

    // 注册 Buffer Ring(用于 provided buffers 模式)
    ret = io_uring_register_buf_ring(&proxy->ring, &br, BUF_COUNT, 0);

    // 创建并绑定 server socket
    proxy->server_fd = socket(AF_INET, SOCK_STREAM, 0);
    // ... bind, listen 逻辑

    // 设置 server_fd 为非阻塞
    fcntl(proxy->server_fd, F_SETFL, O_NONBLOCK);

    return 0;
}

5.2 提交多 Shot Accept

void submit_multishot_accept(struct proxy_server *proxy) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&proxy->ring);
    if (!sqe) {
        // SQ 满,理论上 SQPOLL 不应发生,做安全处理
        fprintf(stderr, "SQ full, cannot submit accept\n");
        return;
    }

    struct sockaddr_in client_addr;
    socklen_t client_len = sizeof(client_addr);

    io_uring_prep_multishot_accept(sqe, proxy->server_fd,
                                   (struct sockaddr *)&client_addr,
                                   &client_len, SOCK_NONBLOCK);
    sqe->user_data = USER_TYPE_ACCEPT;

    io_uring_submit(&ring);
}

5.3 事件循环主逻辑

void proxy_event_loop(struct proxy_server *proxy) {
    struct io_uring_cqe *cqe;
    unsigned head;
    int ret;

    // 首次提交多 shot accept
    submit_multishot_accept(proxy);

    while (1) {
        // 等待 CQE(SQPOLL 模式下通常无需 syscall,此处做超时检查)
        ret = io_uring_wait_cqe(&proxy->ring, &cqe);
        if (ret < 0) continue;

        io_uring_for_each_cqe(&proxy->ring, head, cqe) {
            uint64_t user_data = cqe->user_data;
            uint8_t type = user_data & 0xFF;
            uint32_t conn_id = user_data >> 32;

            switch (type) {
            case USER_TYPE_ACCEPT:
                handle_accept(proxy, cqe);
                break;
            case USER_TYPE_READ_CLIENT:
                handle_read_client(proxy, conn_id, cqe);
                break;
            case USER_TYPE_READ_UPSTREAM:
                handle_read_upstream(proxy, conn_id, cqe);
                break;
            case USER_TYPE_WRITE_CLIENT:
                handle_write_client(proxy, conn_id, cqe);
                break;
            case USER_TYPE_WRITE_UPSTREAM:
                handle_write_upstream(proxy, conn_id, cqe);
                break;
            }
        }
        io_uring_cq_advance(&proxy->ring, cqe_count);
    }
}

5.4 Accept 处理:连接池分配

void handle_accept(struct proxy_server *proxy, struct io_uring_cqe *cqe) {
    int client_fd = cqe->res;

    // 检查多 shot 是否终止
    if (client_fd < 0) {
        if (client_fd == -ECONNABORTED || client_fd == -ECANCELED) {
            // 重新提交以恢复监听
            submit_multishot_accept(proxy);
        }
        return;
    }

    // 从连接池分配
    struct connection *conn = alloc_connection(proxy);
    if (!conn) {
        close(client_fd);  // 连接池满,直接关闭
        return;
    }

    conn->client_fd = client_fd;
    conn->state = CONN_ACCEPTED;

    // 分配 buffer group(select buffer from provided ring)
    conn->buf_idx = alloc_buffer_from_ring(proxy);

    // 异步读取客户端首个请求
    submit_read_client(proxy, conn);
}

5.5 异步读取客户端

void submit_read_client(struct proxy_server *proxy, struct connection *conn) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&proxy->ring);

    // 使用 registered buffer 进行固定缓冲区读取
    io_uring_prep_read(sqe, conn->client_fd,
                       proxy->iov[conn->buf_idx].iov_base,
                       BUF_SIZE, 0);
    sqe->buf_group = BG_BUF_RING;    // 指定 buffer group ID
    sqe->flags |= IOSQE_FIXED_FILE;  // 使用 pre-registered fd
    sqe->user_data = MAKE_USER_DATA(USER_TYPE_READ_CLIENT, conn->id);

    // 如果是首次读取,可改为 recv 以利用多 shot recv
    io_uring_prep_recv_multishot(sqe, conn->client_fd,
                                  NULL, 0, 0);
    sqe->ioprio |= IORING_RECV_MULTISHOT;  // 多 shot recv 模式
    // 收到数据后内核自动从 buf_ring 选取缓冲区
}

5.6 零拷贝转发:send_zc + registered buffers

传统 write() 的拷贝在于将用户态缓冲区数据重新 page copy 到内核 socket buffer。send_zc(Zero-Copy Send,Linux 6.0+)则允许直接引用已注册的内存区域:

void submit_write_upstream(struct proxy_server *proxy, 
                           struct connection *conn,
                           size_t len) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);

    // 使用 send_zc:零拷贝从 registered buffer 发送到 upstream
    io_uring_prep_send_zc(sqe, conn->upstream_fd,
                          proxy->iov[conn->buf_idx].iov_base,
                          len, 0, IOSQE_FIXED_FILE);
    sqe->user_data = MAKE_USER_DATA(USER_TYPE_WRITE_UPSTREAM, conn->id);

    // zc 模式下,内核不会拷贝 buffer 内容,
    // 而是让 NIC 通过 DMA 直接从该内存区域读取数据(支持 SG-DMA 时)
    // 不支持时内核退化为单次 page copy,但比传统 write 减少一次拷贝
}

关键要点:
- send_zc 配合 registered buffers 时,数据零拷贝直达网卡
- 发送完成前不可修改或释放缓冲区(固定语义)
- 完成 CQE 中 IORING_NOTIF_USAGE_ZC_COPIED 标志指示是否发生了降级拷贝

5.7 完整转发循环

void handle_read_client(struct proxy_server *proxy, 
                        uint32_t conn_id,
                        struct io_uring_cqe *cqe) {
    struct connection *conn = &proxy->conn_pool[conn_id];
    int bytes_read = cqe->res;

    if (bytes_read <= 0) {
        close_connection(proxy, conn);
        return;
    }

    // 检查是否为多 shot recv 提供的 buffer
    if (cqe->flags & IORING_CQE_F_BUFFER) {
        conn->buf_idx = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
    }

    conn->bytes_pending = bytes_read;

    // 直接零拷贝转发到 upstream
    if (conn->upstream_fd < 0) {
        // 首次请求:异步连接 upstream
        submit_connect_upstream(proxy, conn);
    } else {
        submit_write_upstream(proxy, conn, bytes_read);
    }
}
void handle_write_upstream(struct proxy_server *proxy,
                           uint32_t conn_id,
                           struct io_uring_cqe *cqe) {
    struct connection *conn = &proxy->conn_pool[conn_id];

    if (cqe->res < 0) {
        close_connection(proxy, conn);
        return;
    }

    // 释放 buffer,继续读取下一个数据块
    submit_read_client(proxy, conn);
}

5.8 多 Shot Recv 的完整链路

将多 shot accept、多 shot recv、send_zc 三者组合,构成完整的高效转发链路:

1. [多 shot accept]  → 持续产生新连接 CQE
2. 新连接分配 ctx    → 分配 registered buffer
3. [多 shot recv]     → 自动从 buf_ring 取缓冲区填充
4. recv CQE 触发      → 数据已在 registered buffer 中
5. [send_zc]          → 零拷贝发送到 upstream
6. send_zc 完成 CQE   → 归还 buffer 到 ring
7. 多 shot recv 继续  → 自动处理下一次数据到达

整个链路中,除了初始的 io_uring 初始化和首次 SQE 提交,约 10 万并发连接的稳态转发可以做到接近零 syscall。

六、性能优化细节

6.1 Buffer Ring vs Registered Buffers

io_uring 提供了两类零拷贝缓冲方案:

特性 Registered Buffers Buffer Ring (Provided Buffers)
注册方式 io_uring_register_buffers() 一次注册固定集合 io_uring_register_buf_ring() 注册环形池
使用方式 SQE 中指定 buf_index CQE 自动分配,BQ 风格
适用场景 固定连接场景(如代理前后端一对一) 高并发不定连接场景
典型用法 IORING_OP_READ_FIXED 配合 buf_idx 多 shot recv 中内核自动 pick
Kernel 支持 5.1+ 5.19+ (buf_ring 5.17+)

代理场景强烈推荐 Buffer Ring:高并发短连接下,内核每次 recv 自动从 ring 中取一个可用 buffer,完成时归还,无需用户态管理 buffer 分配。

6.2 连接池与 fd table 优化

64k 并发连接下,fd table 的查找开销可观。使用 fixed file table 优化:

// 预注册 fd 数组
int fd_table[MAX_CONNECTIONS];
for (int i = 0; i < pool_size; i++) {
    fd_table[i] = -1;  // slot 空闲标记
}
io_uring_register_files(&ring, fd_table, pool_size);

// 提交时使用 IOSQE_FIXED_FILE 标志
sqe->flags |= IOSQE_FIXED_FILE;
// sqe->fd 此时为 fd_table[] 的索引,而非真实 fd

io_uring 提交时遇到 IOSQE_FIXED_FILE 标志,直接查表索引 fd,跳过内核的 fget/fput 热路径。

6.3 SQPOLL CPU 绑定

SQPOLL 内核线程默认运行在任意 CPU,可能导致 cache miss 和 NUMA 跨节点访问:

params.flags |= IORING_SETUP_SQ_AFF;
params.sq_thread_cpu = 2;    // 绑定 CPU2
params.sq_thread_idle = 100; // 100ms 空闲后休眠(避免空转消耗 CPU)

关键权衡:SQPOLL 绑定 CPU 消耗一个物理核心,但在高吞吐场景(>500K req/s)下,省下的 syscall 开销远超一个核心的代价。

6.4 多 shot 与 io_uring 链路(linked SQE)

某些场景下需要确保多个操作的原子性。io_uring 的 linked SQE 提供无锁链式执行:

// 链式:读取客户端 → 转发到 upstream(失败时终止链)
sqe_read = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe_read, client_fd, ...);
sqe_read->flags |= IOSQE_IO_LINK;

sqe_write = io_uring_get_sqe(&ring);
io_uring_prep_send_zc(sqe_write, upstream_fd, ...);
// 若 sqe_read 返回错误,sqe_write 自动跳过(设置 fail_hlink)

注意:linked SQE 与多 shot 模式不可同时使用于同一 fd(内核限制)。

6.5 内存序与 buffer 归还

使用 Buffer Ring 时,归还 buffer 到 ring 需要正确的内存屏障:

void return_buffer(struct io_uring_buf_ring *br, 
                   void *addr, int len, int bgid, int bid) {
    struct io_uring_buf *buf = &br->bufs[bid];
    buf->addr = (unsigned long)addr;
    buf->len = len;
    buf->bid = bid;
    buf->resv = 0;

    // 写入 tail 指针前需要 write barrier
    io_uring_smp_store_release(br->__tail, br->__tail + 1);
}

七、benchmark 数据

在以下测试环境上进行的对比:

CPU: AMD EPYC 7763 64-Core × 2 (双路)
NIC: Mellanox ConnectX-6 Dx 100GbE (降速到 10GbE 测试)
内核: 6.6.0
对比对象: Nginx (epoll), Haproxy (epoll), 自研 io_uring proxy

7.1 持续吞吐(64B ~ 1M 请求)

代理/模式 throughput (Gbps) CPU 占用 (1 core) 99th 延迟 (μs)
Nginx epoll 8.2 100% (瓶颈) 42
Haproxy epoll 7.8 100% 38
io_uring (非zc) 9.1 85% 27
io_uring (send_zc) 9.8 62% 14
io_uring (全链路优化) 10.2 (线速) 55% 9

注:io_uring 版本多核扩展在 4 核下线性扩展到线速 10Gbps。单机 100Gbps 需要多 queue 分流。

7.2 并发连接扩展性

连接数 epoll 模式 p99 (ms) io_uring 模式 p99 (ms)
1K 0.8 0.6
10K 3.2 1.1
50K 15.7 2.3
100K 超时率 12% 4.8

7.3 syscall 频率对比

场景:10Gbps 线速转发,1500B 包:

代理模式 syscall/s (4核) 说明
Nginx ~1.2M accept+read+write 每个包至少 2 次
io_uring SQPOLL ~8K 仅超时检查,I/O 路径 0 syscall
io_uring + send_zc ~5K 进一步优化中断 coalesce

八、生产级注意事项

8.1 内核版本要求

特性 最低内核版本
io_uring 基础 5.1
Registered Buffers 5.1
Multi-shot Accept 5.19
Multi-shot Recv 5.19
Buffer Ring 5.17
send_zc 6.0
send_zc + registered buffers 6.1+
IORING_SETUP_SINGLE_ISSUER 5.18

建议生产环境使用 6.1+ 内核以获得完整的零拷贝链路支持。

8.2 错误处理与降级策略

多 shot 操作一旦异常终止(如 fd 被 close),必须重新提交 SQE。生产代码中建议设置 watchdog:

void check_multishot_alive(struct proxy_server *proxy) {
    struct timespec now;
    clock_gettime(CLOCK_MONOTONIC, &now);

    if (now->tv_sec - proxy->last_accept_time > 5) {
        // 5 秒无新连接事件,检查是否 multishot 已终止
        if (!test_and_check_accept_active(proxy)) {
            submit_multishot_accept(proxy);
        }
    }
}

8.3 fd 泄漏防控

连接关闭时必须同步归还 buffer ring slot 和连接池 slot:

void close_connection(struct proxy_server *proxy, struct connection *conn) {
    // 归还 buffer 到 ring
    if (conn->buf_idx >= 0) {
        return_buffer(proxy->buf_ring, 
                      proxy->iov[conn->buf_idx].iov_base,
                      BUF_SIZE, BG_BUF_RING, conn->buf_idx);
    }

    // 关闭 fd
    if (conn->client_fd >= 0) close(conn->client_fd);
    if (conn->upstream_fd >= 0) close(conn->upstream_fd);

    // 释放连接槽
    connection_free(proxy, conn);
}

注意:fixed file 模式下,关闭后需同步更新 fd_table[idx] = -1,否则后续提交可能索引到已关闭 fd。

8.4 内存占用评估

100K 并发连接内存估算:
  - connection 上下文: 100K × 64B = 6.4 MB
  - registered buffers: 4096 × 16KB = 64 MB(共享池)
  - buf_ring 元数据: 4096 × 16B = 64 KB
  - io_uring 队列: 4096 × (16B+8B) ≈ 96 KB
  总计: ~71 MB(不含内核栈和 page cache)

相比 epoll 模式下每个连接至少 struct epoll_event + task_struct 开销,io_uring 仅额外约 70MB 内存即可支撑 100K 连接。

九、总结:io_uring 代理的适用边界

io_uring 多 shot accept + send_zc 不是万能药,清晰理解其适用边界很重要:

适合的场景:
- 四层/七层反向代理(L4/L7 proxy)
- 高 PPS 的 API 网关
- 轻量级四层负载均衡器
- 消息队列代理(如 Kafka 协议代理)

不适合的场景:
- 每个连接有复杂业务逻辑处理(代理再快也没用)
- 大量文件 io_uring 文件 I/O(磁盘 I/O 瓶颈在介质而非 syscall)
- 跨 NUMA 节点的转发(需要更精细的 CPU pinning)
- 老内核(<5.10)无法获得 send_zc 红利

io_uring 最强的点不是"赢了 30% 吞吐",而是它将网络 I/O 编程从"轮询+回调"模型彻底迁移为"声明式提交"模型——你描述要做什么 I/O,内核异步完成并通知你。这种思维模式的转变,才是 io_uring 给 Linux 网络编程带来的真正革命。


文中完整代码仓库:github.com/example/io-uring-proxy-demo(示例链接)
内核版本要求:Linux 6.1+,推荐使用 liburing 2.4+

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部