哈喽大家好,我是内核喵。
很多同学在写高性能网络库的时候,张口闭口就是「epoll + 线程池 + 非阻塞 I/O」。但只要你用 perf 仔细剖过高并发场景下的 CPU 热点,就会发现大量的时间片其实都浪费在系统调用的上下文切换(Context Switch)、内存拷贝以及内核态数据结构(如 struct file 的原子引用计数)的争用上了。
Linux 老旧的 POSIX AIO 甚至不支持普通文件读写走异步,还必须得开 O_DIRECT,体验极度割裂。直到 Jens Axboe 在 Linux 5.1 引入了 io_uring,Linux 异步 I/O 才算真正站起来了。
今天我们就来刨根问底,聊聊 io_uring 的底层机制,并手把手看一个基于它的服务端改造与调优实践。
一、 核心架构:为什么 io_uring 可以把 syscall 砍到 0?
传统系统调用的核心瓶颈在于:一次 I/O 至少对应一次用户态到内核态的切换。即便是非阻塞的 epoll_wait,你依然需要循环执行 read() / write()。
io_uring 解决这个问题的思路极其纯粹且暴力:基于共享内存的无锁环形队列(Ring Buffer)。
内核与用户态共同映射了两块内存区域:
- SQ (Submission Queue):提交队列,用户态写,内核态读。
- CQ (Completion Queue):完成队列,内核态写,用户态读。
+-------------------+ +-------------------+
| User Space App | | Kernel Core |
+---------+---------+ +---------+---------+
| |
| [1] 填充 SQE (Submission Queue Entry) |
v |
+-----------+ Shared Memory (mmap) +----+------+
| SQ | ============================> | SQ Kernel | (读取并派发任务)
+-----------+ +-----------+
|
+-----------+ Shared Memory (mmap) +----+------+
| CQ | <============================ | CQ Kernel | (完成写回 CQE)
+-----------+ +-----------+
^ |
| [2] 消费 CQE (Completion Queue Entry) |
| |
每个 Ring 都有一个 head 和 tail 索引,靠内存屏障(Memory Barrier)和原子操作实现单生产者单消费者(SPSC)的无锁安全访问。
这意味着:只要环没满,用户态提交请求不需要调用任何 syscall!
二、 真机验证:抓一把 strace 和 perf 看看
光说不练假把式,我们来看看在实际网络流量打满时,epoll 和 io_uring(开启 IORING_SETUP_SQPOLL 模式)的差异。
1. 传统 epoll 的 strace 现场
$ strace -c -p $(pidof epoll_server)
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
42.18 0.852100 3 284033 read
35.12 0.709320 2 284030 write
22.70 0.458440 5 91688 epoll_wait
------ ----------- ----------- --------- --------- ----------------
100.00 2.019860 659751 total
在百万级 QPS 下,每秒数十万次 syscall,CPU 大量周期耗死在进入内核的模式切换、寄存器现场保存与恢复(Context Switch)上。
2. io_uring (SQPOLL) 的 strace 现场
开启内核轮询线程(SQPOLL)后,内核会启动一个内核线程专门去消耗 SQ 里的任务:
$ strace -c -p $(pidof io_uring_server)
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
------ ----------- ----------- --------- --------- ----------------
100.00 0.000000 0 total
0 次 syscall! 此时用户态和内核完全靠内存交互,吞吐直接起飞。
我们再抓一把 perf stat:
# epoll 模式下:
382,109 context-switches # 0.038 M/sec
# io_uring SQPOLL 模式下:
124 context-switches # 0.000 M/sec
上下文切换数量直接呈现出数量级的断崖式下降。
三、 实战:基于 liburing 的网络服务端核心模型
手写原生的 io_uring_enter 系统调用与 mmap 初始化非常繁琐,生产中推荐使用官方封装的 liburing。
下面是一个极其精简的 TCP 数据处理逻辑框架:
#include <stdio.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <liburing.h>
#include <string.h>
#include <unistd.h>
#define QUEUE_DEPTH 1024
#define BUF_SIZE 1024
enum { EVENT_ACCEPT, EVENT_READ, EVENT_WRITE };
struct conn_info {
int fd;
int event_type;
char buffer[BUF_SIZE];
int bytes;
};
int main() {
struct io_uring ring;
// 1. 初始化 io_uring,队列深度 1024
io_uring_queue_init(QUEUE_DEPTH, &ring, 0);
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(8080);
serv_addr.sin_addr.s_addr = INADDR_ANY;
bind(listen_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
listen(listen_fd, 512);
// 提交第一个 Accept 任务
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
struct conn_info accept_conn = { .fd = listen_fd, .event_type = EVENT_ACCEPT };
io_uring_prep_accept(sqe, listen_fd, NULL, NULL, 0);
io_uring_sqe_set_data(sqe, &accept_conn);
io_uring_submit(&ring);
printf("Server listening on port 8080...\n");
while (1) {
struct io_uring_cqe *cqe;
// 等待 CQ 产生完成事件
int ret = io_uring_wait_cqe(&ring, &cqe);
if (ret < 0) break;
struct conn_info *conn = (struct conn_info *)io_uring_cqe_get_data(cqe);
int res = cqe->res; // 返回值(如 read 到的字节数,或 accept 的新 fd)
if (conn->event_type == EVENT_ACCEPT) {
int client_fd = res;
// 为新连接提交异步 Read 请求
struct conn_info *read_conn = malloc(sizeof(struct conn_info));
read_conn->fd = client_fd;
read_conn->event_type = EVENT_READ;
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, client_fd, read_conn->buffer, BUF_SIZE, 0);
io_uring_sqe_set_data(sqe, read_conn);
// 重新挂起 Accept 监听
sqe = io_uring_get_sqe(&ring);
io_uring_prep_accept(sqe, listen_fd, NULL, NULL, 0);
io_uring_sqe_set_data(sqe, &accept_conn);
io_uring_submit(&ring);
}
else if (conn->event_type == EVENT_READ) {
if (res <= 0) {
close(conn->fd);
free(conn);
} else {
// Echo 回写数据
conn->bytes = res;
conn->event_type = EVENT_WRITE;
sqe = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe, conn->fd, conn->buffer, conn->bytes, 0);
io_uring_sqe_set_data(sqe, conn);
io_uring_submit(&ring);
}
}
else if (conn->event_type == EVENT_WRITE) {
// 写完后继续读
conn->event_type = EVENT_READ;
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, conn->fd, conn->buffer, BUF_SIZE, 0);
io_uring_sqe_set_data(sqe, conn);
io_uring_submit(&ring);
}
io_uring_cqe_seen(&ring, cqe); // 标记 CQE 已经被消费
}
io_uring_queue_exit(&ring);
return 0;
}
四、 内核级硬核调优:榨干最后一点性能
写出 Demo 只是入门,要达到极致吞吐,必须在内核层面抠细节。以下是做服务改造时必须关注的 3 个高级特性:
1. 固定文件描述符(Fixed Files)
每次你向内核提交带有 fd 的请求时,内核底层都必须执行 fget() 和 fput() 来更新内核文件表中的原子引用计数。
在高并发大量短连接/频繁读写下,原子操作锁总线(Bus Lock)的开销极度明显。
使用 io_uring_register_files() 把一组 fd 注册进内核,后续的 SQE 直接使用数组下标索引,内核会跳过 fget/fput:
int files[2] = { client_fd, server_fd };
io_uring_register_files(&ring, files, 2);
// 在 sqe 中标记 IOSQE_FIXED_FILE
sqe->flags |= IOSQE_FIXED_FILE;
2. 固定缓冲区(Fixed Buffers / Registered Buffers)
普通的 read/write 提交时,内核需要把用户态的虚拟内存页锁定(pin_user_pages / get_user_pages),并在内核页表中做映射。
使用 io_uring_register_buffers() 提前将内存块注册并固定(Pin)在内核,配合 io_uring_prep_read_fixed(),内核直接读写预分配的物理页,避免了繁重的 Page Pinning 开销。
3. 谨慎开启 SQPOLL
IORING_SETUP_SQPOLL 确实强无敌,但它本质是在内核启动了一个内核线程(iou-sqp-*)进行忙轮询(Busy Polling)。
- 优点:真正做到 0 syscall 提交。
- 陷阱:它会跑满一个 CPU 核心!如果连接数和吞吐量不高,开启 SQPOLL 反而会导致严重的 CPU 浪费。
- 调优手段:配合
sq_thread_idle参数使用。如果在规定时间内没有新任务提交,内核轮询线程会自动休眠,此时下一次提交仍需一次io_uring_enter系统调用来唤醒它。另外,建议使用taskset或pthread_setaffinity_np把内核轮询线程绑定到指定的隔离核心(Isolated CPU)上。
总结
io_uring 不仅是一套异步 I/O 接口,更代表了 Linux 内核处理「用户态-内核态数据交互」的架构演进。它抹平了网络 I/O、磁盘 I/O(甚至 splice / sync 等操作)的异构性,统一收敛在 Ring Buffer 模型之下。
如果你正在维护高吞吐网关、KV 存储引擎或自研网络通信框架,是时候拉个分支,用 io_uring 彻底重构老旧的事件循环了。
内核代码有疑问?翻翻 fs/io_uring.c,所有的秘密都在里面。
许可协议:CC BY-NC 4.0
更新于 2 小时前
觉得文章有帮助?点个赞吧!
0 条评论


