前几篇文章中,我们的 io_uring 程序都运行在单线程事件循环里。单线程模型简洁可靠,但当单核 CPU 成为瓶颈、或者你需要利用多核并行处理计算密集型负载时,就必须引入多线程。
io_uring 的共享内存环形缓冲区设计天然对多线程友好——但”友好”不等于”随便用”。本文将系统梳理 io_uring 的线程安全边界,介绍四种常见的多线程架构模式,并通过一个完整的多线程 Echo Server 实战来展示推荐方案。
先给结论
如果你是在写高并发网络服务,默认答案通常不是“让很多线程共享一个
ring”,而是:每个工作线程一个 ring,再用
SO_REUSEPORT
做连接分流。这几乎总是最容易写对、也最容易跑快的方案。
先把结论压缩成三句话:
io_uring并不自动解决多线程同步问题;单个 ring 最稳妥的组织方式仍然是“单提交者 + 单收割者”。- 默认优先选 Thread-per-Ring +
SO_REUSEPORT;只有在连接之间必须频繁协作时,才认真考虑共享 ring。 - SQPOLL、CPU 亲和性、NUMA 优化都属于“把方案跑快”的手段,不是替代所有权设计和同步边界的捷径。
1. 为什么需要多线程 + io_uring
1.1 单线程的天花板
单线程 io_uring 事件循环的典型瓶颈:
- CPU 密集型处理:如果每个请求需要做加密、压缩、序列化等计算,单核跑满后吞吐量就到顶了。
- 内存带宽饱和:大量 buffer 拷贝和处理集中在一个核上,cache miss 增多。
- 长尾延迟:单线程模型下一个慢请求会阻塞后续所有 CQE 的处理。
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 都当成“单一推进者”的数据结构:
- SQ(Submission Queue):用户态是生产者,内核是消费者。
- CQ(Completion Queue):内核是生产者,用户态是消费者。
内核和 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. 四种多线程架构模式
先看一张总览图,再展开各模式的适用边界:
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:
IORING_SETUP_SINGLE_ISSUER:告诉内核这个 ring 只会被单个线程提交,正好契合 Thread-per-Ring 的所有权模型。IORING_SETUP_ATTACH_WQ:让多个 ring 共享同一个 async worker pool,避免“每线程一个 ring”时内核 worker 线程数膨胀。
优点: - 零锁竞争,每个线程完全独立 - 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, ¶ms);
// 多线程提交(仍需锁保护 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_fd、ring、统计和停机状态散在多个局部变量里,而是显式收进一个线程私有的
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, ¶ms);
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它会打印一组最基本的压测建议,包括:
- 用
tcpkali或sockperf,不要用 HTTP benchmark 去测纯 TCP echo - 连接数要明显大于线程数
- 先预热,再多次运行取中位数
- 优先固定 CPU 频率,避免把 turbo 波动当成程序优化效果
4.3 SO_REUSEPORT 的连接分发
SO_REUSEPORT 允许多个 socket 绑定同一个
IP:Port。内核会基于连接四元组做哈希分发,把新连接送到其中一个
listener socket 上。
需要注意的行为:
- 分发不完全均匀:如果部分线程处理较慢,它的
accept queue 可能堆积,但内核不会因此少分配连接给它。Linux
5.10+ 的 BPF
reuseport程序可以自定义分发策略。 - 连接迁移:一旦连接被分发到某个 socket(线程),后续所有该连接的 I/O 都由该线程处理,不会迁移。
- 线程退出:如果某个线程退出了,它的 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 模式在网络服务里经常能接近线性扩展,但别把它理解成“线程翻倍,吞吐就必然翻倍”。实际效果主要取决于:
- CPU 是否真的是瓶颈:如果瓶颈在网卡、内存带宽或磁盘,加线程没用。
- 连接数是否足够多:连接太少时线程间负载不均匀。
- 是否存在共享资源:全局锁、共享缓存、日志写入等都会破坏线性扩展。
- NUMA 与拓扑是否友好:线程绑核、内存落点、NIC 队列分布都会直接影响尾延迟和扩展效率。
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 1NUMA 机器额外检查 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 irqbalance6.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 优雅停机
多线程停机的顺序很重要:
- 停止接受新连接:关闭所有 listener socket
- 等待在途请求完成:停止产生新 I/O,只继续收割现有 CQE,直到 in-flight 请求计数归零
- 清理资源:释放连接上下文、关闭 client fd
- 退出
ring:
io_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 程序需要注意:
- seccomp 配置:Docker
和多数容器运行时的默认 seccomp profile 往往禁用
io_uring_setup、io_uring_enter、io_uring_register。需要在docker run时指定允许这些 syscall 的 profile,或在开发环境临时使用--security-opt seccomp=unconfined。 - 内核 Worker 线程限制:io_uring 的内核
worker 线程受
RLIMIT_NPROC限制。在 Kubernetes 中如果 Pod 的 PID limit 设得太低,io_uring 可能无法创建足够的 worker 线程。 - cgroup CPU 限制:如果容器限制了 CPU 核数,开太多线程会适得其反,线程数应与 cgroup 分配的核数匹配。
8. 总结与选型建议
选型决策流程
你需要多线程吗?
├── 单核 CPU 未跑满 → 不需要,单线程即可
└── 需要利用多核
├── 连接间是否需要频繁交互?
│ ├── 是 → Shared Ring + Mutex(简单)
│ │ 或 Thread-per-Ring + 线程间消息队列(性能优先)
│ └── 否 → Thread-per-Ring + SO_REUSEPORT,通常是默认首选
└── 是否要求极低延迟(<10μs)?
└── 是 → Thread-per-Ring + SQPOLL + CPU 亲和性
如果要把这篇文章压缩成一份实际选型清单,可以直接按下面四条走:
- 先定所有权:单个 ring 默认只给一个提交者和一个收割者,多线程共享前先想清楚锁和生命周期。
- 默认选 Thread-per-Ring:配合
SO_REUSEPORT、IORING_SETUP_SINGLE_ISSUER,大多数网络服务已经够用。 - 再做性能优化:需要时补上 CPU
亲和性、NUMA
落点、
IORING_SETUP_ATTACH_WQ、SQPOLL。 - 最后补生产防线:重点检查 buffer 所有权、取消竞态、停机 drain、容器 seccomp。
参考链接
- io_uring(7) man page
- io_uring_setup(2) man page
- liburing GitHub Repository
- SO_REUSEPORT 内核文档
- Efficient IO with io_uring (Jens Axboe)
- Lord of the io_uring - Submission Queue Polling
- Linux NUMA 架构优化指南
- kernel: io_uring worker thread pool
上一篇: 09-debugging-event-driven.md - 事件驱动代码的调试艺术:当回调成为迷宫 返回 io_uring 系列索引