土法炼钢兴趣小组的算法知识备份

io_uring 多线程编程模式:从线程安全到架构选型

目录

前几篇文章中,我们的 io_uring 程序都运行在单线程事件循环里。单线程模型简洁可靠,但当单核 CPU 成为瓶颈、或者你需要利用多核并行处理计算密集型负载时,就必须引入多线程。

io_uring 的共享内存环形缓冲区设计天然对多线程友好——但”友好”不等于”随便用”。本文将系统梳理 io_uring 的线程安全边界,介绍四种常见的多线程架构模式,并通过一个完整的多线程 Echo Server 实战来展示推荐方案。

先给结论

如果你是在写高并发网络服务,默认答案通常不是“让很多线程共享一个 ring”,而是:每个工作线程一个 ring,再用 SO_REUSEPORT 做连接分流。这几乎总是最容易写对、也最容易跑快的方案。

先把结论压缩成三句话:

  1. io_uring 并不自动解决多线程同步问题;单个 ring 最稳妥的组织方式仍然是“单提交者 + 单收割者”。
  2. 默认优先选 Thread-per-Ring + SO_REUSEPORT;只有在连接之间必须频繁协作时,才认真考虑共享 ring。
  3. SQPOLL、CPU 亲和性、NUMA 优化都属于“把方案跑快”的手段,不是替代所有权设计和同步边界的捷径。

1. 为什么需要多线程 + io_uring

1.1 单线程的天花板

单线程 io_uring 事件循环的典型瓶颈:

1.2 io_uring 的并发友好设计

与 epoll 相比,io_uring 在设计上对多线程更加友好:

特性 epoll io_uring
惊群问题 EPOLLEXCLUSIVE 缓解,但仍需应用层协调 完成驱动,内核直接分发结果,无惊群
线程模型 倾向单线程事件循环,多线程需额外同步 内核维护 worker 线程池,SQ/CQ 基于 lock-free 设计
系统调用 典型路径是 epoll_wait + 实际 I/O,两次或更多 syscall 批量提交 + SQPOLL 可降至零 syscall
状态管理 fd 注册到 epoll 实例后可被多线程共享操作同一 fd(需要自己保证安全) 每个 SQE 是独立的提交单元,完成后通过 CQE 返回

关键区别:epoll 的多线程模型本质上是多个线程竞争同一个 epoll_wait,内核需要做唤醒决策;而 io_uring 的多线程模型可以做到每个线程拥有独立的 ring,彻底消除竞争。

2. io_uring 线程安全模型

在写多线程代码之前,必须搞清楚哪些操作是线程安全的,哪些不是。

2.1 内核侧的保证

从单个 ring 的用户态视角看,最稳妥的理解方式是把 SQ 和 CQ 都当成“单一推进者”的数据结构:

内核和 liburing 通过 head/tail 指针与内存屏障(memory barrier)协作,保证单提交者、单收割者场景下可以不加锁地推进队列。这是 io_uring 高性能的基石。

但注意:这并不意味着“内核替你处理好多线程同步”。如果多个线程同时调用 io_uring_get_sqe() 去申请 SQE 槽位,或者同时推进 CQ head,就会出现用户态竞态条件。

2.2 liburing 的线程安全边界

// 不安全示意:多线程同时获取 SQE
// Thread A                    // Thread B
sqe = io_uring_get_sqe(&ring); sqe = io_uring_get_sqe(&ring);
//  ↑ 两者可能拿到同一个 sqe 槽位!

liburing 中的关键函数线程安全性:

函数 线程安全? 说明
io_uring_get_sqe() 修改 SQ tail 指针,无内部同步
io_uring_submit() 推进提交流程,本身不提供多线程串行化
io_uring_wait_cqe() 消费 CQ 时涉及共享 head 状态
io_uring_cqe_seen() 推进 CQ head
io_uring_peek_cqe() 读取 CQ,共享消费方时也需要同步
io_uring_prep_*() 只写入已获取的 SQE,不涉及 ring 的共享游标

核心规则:一个 io_uring 实例的 SQ 操作(get_sqe + submit)必须由同一个线程完成,或者由外部锁保护。CQ 操作同理。

2.3 IORING_SETUP_SQPOLL 的特殊性

当使用 SQPOLL 模式时,内核 SQ 轮询线程充当 SQ 的消费者。用户态只需要写 SQE 到 SQ ring buffer 中,不需要调用 io_uring_enter()(大多数情况下)。

但这不改变用户态侧的线程安全模型——多个用户态线程仍然不能并发调用 io_uring_get_sqe()。SQPOLL 优化的是内核侧的系统调用开销,不是用户态的同步问题。

3. 四种多线程架构模式

先看一张总览图,再展开各模式的适用边界:

io_uring 多线程架构模式对比

3.1 模式一:Thread-per-Ring(推荐)

核心思想:每个工作线程创建自己独立的 io_uring 实例,线程间零共享。

你可以把它理解成“把单线程 event loop 横向复制 N 份”:每个线程各自持有 listener socket、ring、连接上下文和统计信息,线程之间不直接共享 I/O 路径,只在更高层用消息队列或共享状态做协作。

通过 SO_REUSEPORT 让每个线程绑定同一个端口,内核自动将新连接分发到不同的 listener socket:

int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));

如果内核版本较新,还可以给这种模式再加两个很实用的 flag:

优点: - 零锁竞争,每个线程完全独立 - Cache 局部性最好,连接的所有数据都在同一个核的 cache 中 - 线性扩展性,加线程 ≈ 加吞吐

缺点: - 内存开销较大(每个 ring 独立的 SQ/CQ buffer) - 连接分配不完全均匀(依赖内核的 SO_REUSEPORT 调度策略) - 不适合需要跨连接协作的场景(如广播)

3.2 模式二:Shared Ring + Mutex

核心思想:所有线程共享一个 io_uring 实例,通过互斥锁保护 SQ 和 CQ 操作。

pthread_mutex_t sq_lock = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t cq_lock = PTHREAD_MUTEX_INITIALIZER;

// 提交线程
void submit_request(struct io_uring *ring, ...) {
    pthread_mutex_lock(&sq_lock);
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    if (!sqe) {
        pthread_mutex_unlock(&sq_lock);
        return; // 或者等待/批量 flush 后重试
    }
    io_uring_prep_read(sqe, fd, buf, len, 0);
    io_uring_sqe_set_data(sqe, ctx);
    io_uring_submit(ring);
    pthread_mutex_unlock(&sq_lock);
}

// 收割线程
void reap_completions(struct io_uring *ring) {
    struct io_uring_cqe *cqe;
    pthread_mutex_lock(&cq_lock);
    while (io_uring_peek_cqe(ring, &cqe) == 0) {
        // 处理完成事件
        process_cqe(cqe);
        io_uring_cqe_seen(ring, cqe);
    }
    pthread_mutex_unlock(&cq_lock);
}

优点: - 实现简单,概念清晰 - 只需要一个 ring 实例,内存开销小 - 很容易做跨连接协作

缺点: - 锁竞争是性能瓶颈,高并发下退化严重 - SQ 锁和 CQ 锁分离只是缓解,不能消除 - 一个线程持锁时其他线程必须等待

适用场景:低并发、连接间需要频繁交互的应用(如游戏服务器房间广播)。

3.3 模式三:Submit/Reap 分离

核心思想:一个线程专门负责 SQ 提交,另一个线程专门负责 CQ 收割。它的目标不是“绝对无锁”,而是把共享面缩小到最小。

// Thread A: 专职提交
void *submit_thread(void *arg) {
    struct io_uring *ring = arg;
    while (running) {
        // 从工作队列取任务
        struct task *t = dequeue(&work_queue);
        struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
        io_uring_prep_read(sqe, t->fd, t->buf, t->len, 0);
        io_uring_sqe_set_data(sqe, t);
        io_uring_submit(ring);
    }
    return NULL;
}

// Thread B: 专职收割
void *reap_thread(void *arg) {
    struct io_uring *ring = arg;
    struct io_uring_cqe *cqe;
    while (running) {
        io_uring_wait_cqe(ring, &cqe);
        struct task *t = io_uring_cqe_get_data(cqe);
        // 处理完成事件,可能生成新的提交任务
        process_completion(t);
        io_uring_cqe_seen(ring, cqe);
    }
    return NULL;
}

但要注意一个微妙的问题:这种模式只是把“谁负责写 SQ、谁负责收 CQ”分开了,并没有消灭线程间协调。收割线程如果想立刻派生新的 I/O,通常仍然要通过工作队列把任务送回提交线程;如果多个线程都能直接碰 ring,锁就又回来了。

实践里,这种模式通常还会配一个 eventfd 或条件变量:提交线程把新任务塞进队列后顺手唤醒收割线程,或者通过 io_uring_register_eventfd() 把 ring 的完成事件桥接到别的等待机制里。否则“提交线程有活、收割线程在睡”和“收割线程生成了新任务、提交线程没被及时唤醒”这两类协调延迟会比较难看。

优点: - 提交和收割可以并行,减少延迟 - 比全锁方案竞争更少

缺点: - 仍然是单 ring,SQ 带宽是瓶颈 - 如果收割线程需要触发新的提交,又需要回到提交线程(增加线程间通信开销) - 边界条件复杂(SQ 满时的行为)

3.4 模式四:SQPOLL + 多线程提交

核心思想:开启 IORING_SETUP_SQPOLL,内核轮询线程负责消费 SQ。用户态多线程通过外部锁保护 io_uring_get_sqe(),但不需要调用 io_uring_submit()(内核线程自动提取)。

struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_idle = 2000; // 2s 无活动后休眠
io_uring_queue_init_params(4096, &ring, &params);

// 多线程提交(仍需锁保护 get_sqe)
pthread_mutex_lock(&sq_lock);
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, len, 0);
io_uring_sqe_set_data(sqe, ctx);
// 不需要 io_uring_submit()——内核线程会自动拿走
pthread_mutex_unlock(&sq_lock);

优点: - 零系统调用开销(大部分情况) - 适合极低延迟场景

缺点: - 用户态仍需锁保护 get_sqe() - SQPOLL 线程占用一个 CPU 核(idle 后可休眠但唤醒有延迟) - 需要 CAP_SYS_NICE 或 root 权限

3.5 模式对比

维度 Thread-per-Ring Shared Ring + Mutex Submit/Reap 分离 SQPOLL + 多线程
锁竞争 中(SQ 锁)
内存开销 高(N 个 ring) 低(1 个 ring) 低(1 个 ring) 低(1 个 ring)
实现复杂度
扩展性 线性
延迟 最低 极低
适用场景 高并发网络服务 低并发协作型 批量 I/O 流水线 极低延迟交易系统

推荐:绝大多数网络服务应用选择 Thread-per-Ring 模式。它实现简单、性能最优、可扩展性最好。

4. 实战:多线程 Echo Server

下面用 Thread-per-Ring + SO_REUSEPORT 实现一个多线程 Echo Server,完整代码见 examples/io_uring/05-mt-echo-server.c。这一节的重点不是把单线程示例再抄一遍,而是展示:把单线程 event loop 复制到多个线程时,哪些地方要保持独占,哪些地方可以完全不变。

示例目录里还附了一个 Makefile,可以直接编译、运行和查看 benchmark 提示,后面自己压测时就不用每次手敲编译命令了。

4.1 整体架构

main()
 ├── 解析线程数(默认 = CPU 核数)
 ├── for i in 0..nthreads:
 │     pthread_create(worker_thread, i)
 └── pthread_join() 等待所有线程

worker_thread(thread_id):
 ├── 初始化 worker_ctx
 ├── socket() + SO_REUSEPORT + bind() + listen()
 ├── io_uring_queue_init_params()
 └── event_loop:
     io_uring_wait_cqe_timeout()
       ├── ACCEPT → add_read + re-arm accept
       ├── READ   → add_write (echo back)
       └── WRITE  → add_read (continue)

shutdown:
 ├── close(listener)
 └── drain_completions() 直到 inflight = 0

4.2 关键改动

完整代码已经放在 examples/io_uring/05-mt-echo-server.c。这里不再把单线程版本整段重贴,而只看“从单线程版演进到 Thread-per-Ring”时真正变化的地方。

改动一:把“线程私有状态”收进 worker_ctx

当前示例不是把 server_fdring、统计和停机状态散在多个局部变量里,而是显式收进一个线程私有的 worker_ctx

struct worker_ctx {
    int thread_id;
    int server_fd;
    struct io_uring ring;
    atomic_int inflight;
};

这样做的好处很直接:所有权边界一眼就能看清。Thread-per-Ring 模式最重要的不是“线程多”,而是“每个线程自己拥有自己的 listener、ring 和在途请求计数”。后面不管是做优雅停机、统计还是继续加 CPU pinning,这个结构都能兜得住。

改动二:每个线程独立创建 listener socket 和 ring

#define PORT 8000
#define RING_SIZE 256
#define BUF_SIZE 1024
#define BACKLOG 512

struct conn_info {
    int fd;
    int type; // EVENT_ACCEPT, EVENT_READ, EVENT_WRITE
    char buf[BUF_SIZE];
    struct iovec iov;
};

void *worker_thread(void *arg) {
    int thread_id = *(int *)arg;
    struct worker_ctx ctx = {
        .thread_id = thread_id,
        .server_fd = -1,
    };
    atomic_init(&ctx.inflight, 0);

    // 每个线程独立创建 listener socket
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_addr.s_addr = INADDR_ANY,
        .sin_port = htons(PORT),
    };
    bind(server_fd, (struct sockaddr *)&addr, sizeof(addr));
    listen(server_fd, BACKLOG);

    struct io_uring_params params = {0};
#ifdef IORING_SETUP_SINGLE_ISSUER
    params.flags |= IORING_SETUP_SINGLE_ISSUER;
#endif
    io_uring_queue_init_params(RING_SIZE, &ctx.ring, &params);

    ctx.server_fd = server_fd;
    event_loop(&ctx);
}

这里和文章前面结论也对上了:如果你已经决定走 Thread-per-Ring,就应该顺手把“单线程提交者”的假设体现在初始化参数里,而不是只停留在口头约定上。

改动三:停机时不是直接跳出循环,而是 drain 在途请求

单线程示例里最容易被忽略的地方是停机。当前版本的示例已经把这一点补上了:收到退出信号后,不再接受新连接,也不再派生新的 read/write,而是继续收割现有 CQE,直到 inflight 归零。

static void event_loop(struct worker_ctx *ctx) {
    struct sockaddr_in client_addr;
    socklen_t client_len = sizeof(client_addr);
    struct __kernel_timespec timeout = {
        .tv_sec = 0,
        .tv_nsec = 500 * 1000 * 1000,
    };

    add_accept_request(&ctx->ring, ctx->server_fd,
                       &client_addr, &client_len, &ctx->inflight);
    io_uring_submit(&ctx->ring);

    while (running) {
        struct io_uring_cqe *cqe;
        int ret = io_uring_wait_cqe_timeout(&ctx->ring, &cqe, &timeout);
        if (ret == -EINTR || ret == -ETIME) {
            continue;
        }
        process_cqe(ctx, cqe, &client_addr, &client_len, 1);
        io_uring_submit(&ctx->ring);
    }

    close(ctx->server_fd);
    ctx->server_fd = -1;
    drain_completions(ctx, &client_addr, &client_len);
}

这正是前面第 7.4 节“优雅停机”的代码化版本:停机不是 break 掉循环,而是进入只收割、不派生新 I/O 的收口阶段。如果示例代码里没有这一层,正文虽然说得对,读者抄过去还是会在现实里踩坑。

改动四:主线程只负责拉起多个 worker

int main(int argc, char *argv[]) {
    int nthreads = sysconf(_SC_NPROCESSORS_ONLN);
    if (argc > 1) {
        nthreads = atoi(argv[1]);
        if (nthreads <= 0) nthreads = 1;
    }

    printf("Starting %d worker threads\n", nthreads);

    pthread_t *threads = calloc(nthreads, sizeof(pthread_t));
    int *thread_ids = calloc(nthreads, sizeof(int));

    for (int i = 0; i < nthreads; i++) {
        thread_ids[i] = i;
        pthread_create(&threads[i], NULL, worker_thread, &thread_ids[i]);
    }

    for (int i = 0; i < nthreads; i++) {
        pthread_join(threads[i], NULL);
    }

    free(threads);
    free(thread_ids);
    return 0;
}

主线程不碰任何 I/O 路径,只负责线程生命周期管理。这样可以避免“主线程也想顺手 submit 一点请求”这类后期常见的所有权污染。

4.4 编译与压测入口

示例目录已经可以直接这样用:

cd ../../examples/io_uring
make
./05-mt-echo-server 4

如果只是想看常用压测命令,不想翻正文,可以直接:

make benchmark-help

它会打印一组最基本的压测建议,包括:

4.3 SO_REUSEPORT 的连接分发

SO_REUSEPORT 允许多个 socket 绑定同一个 IP:Port。内核会基于连接四元组做哈希分发,把新连接送到其中一个 listener socket 上。

需要注意的行为:

5. 线程亲和性与 NUMA 优化

5.1 CPU 亲和性绑定

将工作线程绑定到固定 CPU 核上,可以减少调度器的核间迁移、提高 cache 命中率:

void pin_thread_to_cpu(int cpu_id) {
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    CPU_SET(cpu_id, &cpuset);
    pthread_setaffinity_np(pthread_self(), sizeof(cpuset), &cpuset);
}

// 在 worker_thread 入口调用
void *worker_thread(void *arg) {
    int thread_id = *(int *)arg;
    pin_thread_to_cpu(thread_id);
    // ...
}

这里最好不要把 thread_id 直接等价成“物理核编号”来理解。在开启超线程的机器上,连续两个 CPU 编号很可能落在同一个物理核上。做性能测试前,至少先用 lscpu --extended 或拓扑信息确认绑定策略。

如果同时使用 SQPOLL,还可以通过 IORING_SETUP_SQ_AFF 将内核轮询线程也绑定到指定核上(参见第五篇):

struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL | IORING_SETUP_SQ_AFF;
params.sq_thread_cpu = thread_id; // 绑定到与工作线程同一个核

5.2 NUMA 感知的内存分配

在 NUMA 架构下,跨节点访问内存的延迟是本地内存的 1.5-3 倍。确保线程使用的 buffer 和 ring 都分配在本地节点上:

#include <numa.h>
#include <numaif.h>

void *worker_thread(void *arg) {
    int thread_id = *(int *)arg;
    int cpu_id = thread_id;
    int node = numa_node_of_cpu(cpu_id);

    // 在本地 NUMA 节点上分配连接池
    size_t pool_size = MAX_CONNS * sizeof(struct conn_info);
    struct conn_info *pool = numa_alloc_onnode(pool_size, node);

    pin_thread_to_cpu(cpu_id);
    // ...使用 pool 分配连接上下文...

    numa_free(pool, pool_size);
    return NULL;
}

5.3 避免 False Sharing

多线程共享的统计计数器是 false sharing 的重灾区。每个线程的计数器应该对齐到 cache line(通常 64 字节):

struct __attribute__((aligned(64))) thread_stats {
    uint64_t connections;
    uint64_t bytes_in;
    uint64_t bytes_out;
    uint64_t errors;
    char _pad[64 - 4 * sizeof(uint64_t)]; // 填充到完整 cache line
};

// 全局统计数组——每个元素独占一个 cache line
struct thread_stats global_stats[MAX_THREADS];

不要把所有线程的统计塞进一个 struct 然后用原子变量——即使原子操作本身是安全的,cache line bouncing 会杀死性能。

6. 性能特征与测试方法

6.1 预期扩展行为

Thread-per-Ring 模式在网络服务里经常能接近线性扩展,但别把它理解成“线程翻倍,吞吐就必然翻倍”。实际效果主要取决于:

6.2 复现实验清单

性能测试的价值在于可复现。下面是一份可以直接拷贝执行的清单,目标是让结果在不同机器、不同时间都能稳定复现。

第一步 · 环境基线

# 记录内核、CPU、NIC、NUMA 拓扑(建议把输出重定向到 env.txt)
uname -r
lscpu
cat /proc/cpuinfo | grep "model name" | head -1
lspci | grep -i ethernet
numactl --hardware
cat /sys/devices/system/cpu/vulnerabilities/* | paste -s

测试前确认内核 >= 5.11(IORING_SETUP_SINGLE_ISSUER 需要 6.0+),liburing >= 2.3。

第二步 · 固定 CPU 频率

Turbo Boost 和自动调频会让每次结果偏差 10-20%。

# 切到 performance governor(全核固定最高频率)
sudo cpupower frequency-set -g performance

# 确认生效
cpupower frequency-info | grep "current CPU frequency"

# 关闭 Intel Turbo Boost(AMD 对应 amd_pstate)
echo 1 | sudo tee /sys/devices/system/cpu/intel_pstate/no_turbo
# 测试后恢复:echo 0 | sudo tee ...

第三步 · 核绑定

避免线程在核之间迁移导致 cache line bouncing;确保服务端线程不与客户端 / OS 后台任务抢核。

# 假设 8 核机器,CPU 0-3 给服务端,4-7 给客户端 / 系统
taskset -c 0-3 ./05-mt-echo-server 4

# 更精细地绑单核(用于 1-thread 基线):
numactl --physcpubind=0 --membind=0 ./05-mt-echo-server 1

NUMA 机器额外检查 NIC 是否和服务端线程在同一 node:

cat /sys/class/net/eth0/device/numa_node
cat /proc/interrupts | grep eth0

第四步 · 关闭干扰源

# 关闭超线程(避免物理核/逻辑核差异混淆结果)
echo off | sudo tee /sys/devices/system/cpu/smt/control

# 停 irqbalance,防止中断被动态迁核
sudo systemctl stop irqbalance

# 如果有 Docker / cgroup 竞争也先停掉

第五步 · 启动服务端

taskset -c 0-3 ./05-mt-echo-server 4

# 服务端每 3 秒打印 per-thread 统计,类似:
# [Thread 0] accepts=12345 reads=98765 writes=98760 bytes_in=12345678 bytes_out=12345600 errors=5 inflight=3

统计由示例代码内置的 report_stats() 输出到 stderr,无需额外工具。

第六步 · 启动压测客户端

客户端必须不能成为瓶颈。建议用独立机器,或至少绑到另一组 CPU:

# tcpkali:高性能 TCP 压测
taskset -c 4-7 tcpkali -c 1000 -T 60s -m "PING\n" 127.0.0.1:8000

# sockperf:延迟精度更高
taskset -c 4-7 sockperf throughput -i 127.0.0.1 -p 8000 -t 60 --mps=max

# 不要用 HTTP 工具(wrk/ab)测纯 TCP echo

注意事项: - 预热 5-10 秒,前几秒数据丢弃。 - 每轮至少跑 30 秒,短连接场景 60 秒更稳。 - 至少跑 3 轮取中位数,单次受 OS 调度干扰大。

第七步 · 结果记录格式

建议用固定 CSV 格式存结果,方便画图和回归对比:

# benchmark_results.csv
date,kernel,cpu_model,governor,turbo,smt,threads,connections,duration_s,rps,p50_us,p99_us,errors,notes
2025-03-14,6.8.0,i7-12700K,performance,off,off,4,1000,60,152340,82,310,0,"baseline"
2025-03-14,6.8.0,i7-12700K,performance,off,off,2,1000,60,78200,160,620,0,"2-thread comparison"

字段说明: - rps:每秒完成的 echo 往返次数(从客户端统计)。 - p50_us / p99_us:延迟分位数(微秒),从 tcpkali/sockperf 输出中提取。 - errors:服务端 stats 输出中的 error 计数。 - notes:本轮测试改了什么变量。

测试后恢复

sudo cpupower frequency-set -g powersave
echo 0 | sudo tee /sys/devices/system/cpu/intel_pstate/no_turbo
echo on | sudo tee /sys/devices/system/cpu/smt/control
sudo systemctl start irqbalance

6.3 与 epoll 多线程的对比

epoll 多线程方案通常使用 EPOLLEXCLUSIVE + 多个线程共享一个 epoll fd:

// epoll 多线程:所有线程 epoll_wait 同一个 epfd
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);  // ev.events |= EPOLLEXCLUSIVE
// N 个线程同时 epoll_wait(epfd, ...)

对比:

维度 io_uring Thread-per-Ring epoll EPOLLEXCLUSIVE
新连接分发 SO_REUSEPORT(内核哈希) EPOLLEXCLUSIVE(内核选一个线程唤醒)
I/O 模型 完成驱动,批量提交 就绪通知 + 同步 I/O
syscall 开销 1 次(或 0 次 SQPOLL) 至少 2 次(wait + read/write)
连接亲和性 天然绑定到建立连接的线程 需要额外逻辑维护

在高并发短连接场景下,Thread-per-Ring 往往更容易跑出更高吞吐和更平滑的尾延迟,主要原因是 syscall 更少、连接亲和性更强、跨核缓存抖动更少。至于具体能领先多少,强烈依赖 NIC、内核版本、连接模型和业务负载,不适合在文章里写成固定数字。

7. 生产环境避坑指南

7.1 跨线程的 Buffer 所有权

规则:谁提交,谁拥有 buffer,直到 CQE 返回。

在 Thread-per-Ring 模式下这不是问题——每个线程独立管理自己的 buffer。但如果你使用 Shared Ring 或需要在线程间传递数据:

// 危险示意:Thread A 提交后 Thread B 修改了 buffer
// Thread A
io_uring_prep_write(sqe, fd, shared_buf, len, 0);
io_uring_submit(ring);

// Thread B(在 CQE 返回之前)
memcpy(shared_buf, new_data, new_len); // 可能造成数据损坏;若 shared_buf 已被释放则会演变成 UAF

正确做法:要么每次提交前拷贝一份独立的 buffer,要么通过 ownership 协议确保 buffer 在飞行中不被修改。

7.2 取消与完成的竞态

这个问题在调试篇第 8 节详细分析过。多线程环境下尤其容易踩坑:

Thread A: io_uring_prep_cancel(sqe, ctx)   // 请求取消
Thread B: (内核已完成) → CQE 返回 → free(ctx) // 释放上下文
Thread A: io_uring_submit() → cancel 的 CQE 返回 -ENOENT / -EALREADY / 成功

这里不要把返回码背成一个固定值。不同内核版本、不同取消时机下,常见结果包括:目标请求已经完成、目标请求尚未找到、取消请求本身成功但原始 CQE 已经在别处被消费。真正危险的不是某个 errno,而是另一个线程以为“取消已经处理完了”,于是提前释放上下文

防御策略: - 引用计数(atomic_int refcount),提交时 +1,CQE 返回时 -1,归零才真正释放。 - 或使用 epoch-based reclamation,延迟释放。

7.3 信号处理

多线程程序中,信号会被投递到”任意一个未屏蔽该信号的线程”。这在事件循环中非常不确定。

推荐做法:

// 主线程:阻塞所有信号
sigset_t mask;
sigfillset(&mask);
pthread_sigmask(SIG_BLOCK, &mask, NULL);

// 创建 signalfd 并纳入 io_uring 事件循环
int sfd = signalfd(-1, &mask, SFD_NONBLOCK);
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, sfd, &sigbuf, sizeof(sigbuf), 0);

这样所有信号都通过 signalfd 进入事件循环,避免了信号打断 io_uring_wait_cqe() 的不确定行为。

7.4 优雅停机

多线程停机的顺序很重要:

  1. 停止接受新连接:关闭所有 listener socket
  2. 等待在途请求完成:停止产生新 I/O,只继续收割现有 CQE,直到 in-flight 请求计数归零
  3. 清理资源:释放连接上下文、关闭 client fd
  4. 退出 ringio_uring_queue_exit()
volatile sig_atomic_t running = 1;
atomic_int inflight = 0;

// 提交前 +1,收割完成后 -1
void submit_read(struct io_uring *ring, struct conn_info *conn) {
    atomic_fetch_add(&inflight, 1);
    // ... io_uring_get_sqe + prep + submit ...
}

void event_loop(struct io_uring *ring, int server_fd) {
    struct __kernel_timespec timeout = {
        .tv_sec = 0,
        .tv_nsec = 500 * 1000 * 1000,
    };

    while (running) {
        struct io_uring_cqe *cqe;
        int ret = io_uring_wait_cqe_timeout(ring, &cqe, &timeout);
        if (ret == -ETIME) {
            if (!running) break; // 收到停机信号
            continue;
        }
        // ...处理事件...
        io_uring_cqe_seen(ring, cqe);
        atomic_fetch_sub(&inflight, 1);
    }

    while (atomic_load(&inflight) > 0) {
        struct io_uring_cqe *cqe;
        if (io_uring_wait_cqe(ring, &cqe) < 0) {
            break;
        }
        // ...仅收割,不再派生新的 I/O...
        io_uring_cqe_seen(ring, cqe);
        atomic_fetch_sub(&inflight, 1);
    }
}

很多人会想到“提交一个 IORING_OP_NOP 当 sentinel”。这在这里并不可靠,因为 CQE 的完成顺序不保证和提交顺序严格一致;看到 NOP 返回,并不能证明它前面的慢 I/O 也都结束了。对多线程程序来说,显式维护 in-flight 计数会稳得多。

7.5 容器化环境下的注意事项

容器中运行多线程 io_uring 程序需要注意:

8. 总结与选型建议

选型决策流程

你需要多线程吗?
├── 单核 CPU 未跑满 → 不需要,单线程即可
└── 需要利用多核
    ├── 连接间是否需要频繁交互?
    │   ├── 是 → Shared Ring + Mutex(简单)
    │   │       或 Thread-per-Ring + 线程间消息队列(性能优先)
    │   └── 否 → Thread-per-Ring + SO_REUSEPORT,通常是默认首选
    └── 是否要求极低延迟(<10μs)?
        └── 是 → Thread-per-Ring + SQPOLL + CPU 亲和性

如果要把这篇文章压缩成一份实际选型清单,可以直接按下面四条走:

  1. 先定所有权:单个 ring 默认只给一个提交者和一个收割者,多线程共享前先想清楚锁和生命周期。
  2. 默认选 Thread-per-Ring:配合 SO_REUSEPORTIORING_SETUP_SINGLE_ISSUER,大多数网络服务已经够用。
  3. 再做性能优化:需要时补上 CPU 亲和性、NUMA 落点、IORING_SETUP_ATTACH_WQ、SQPOLL。
  4. 最后补生产防线:重点检查 buffer 所有权、取消竞态、停机 drain、容器 seccomp。

参考链接


上一篇: 09-debugging-event-driven.md - 事件驱动代码的调试艺术:当回调成为迷宫 返回 io_uring 系列索引


By .