前言:为什么我们要学 io_uring?
在 Linux 网络编程的世界里,很长一段时间
epoll都是当之无愧的王者。但随着硬件性能的提升,epoll在超高并发下的系统调用开销 (System Call Overhead)和数据拷贝问题逐渐显现。于是,Linux 5.1 内核引入了 io_uring 。它不是
epoll的升级版,而是彻底的颠覆者。它实现了真正的异步 I/O (Asynchronous I/O)。今天,我们就通过一段不到 150 行的 C 语言代码,带你一步步拆解 io_uring 的运行机制,实现一个高性能的 Echo 服务器。
一、 核心概念:什么是 io_uring?
在看代码之前,必须先理解 io_uring 的"双环"设计。
传统的 Socket 编程是你去问 内核:"数据来了吗?"(同步非阻塞)。 而 io_uring 是你告诉内核:"我要读数据,读完了放这儿,我去忙别的了。"(异步)。
它使用了两个环形队列(Ring Buffer),在用户态和内核态之间共享内存:
-
SQ (Submission Queue - 提交队列):我们把要做的任务(如:读、写、接受连接)放到这里。
-
CQ (Completion Queue - 完成队列):内核把处理完的结果(如:读到了多少字节)放到这里。
这样做的好处?
-
零拷贝 :利用
mmap共享内存,减少用户态和内核态的数据拷贝。 -
批处理:可以一次性提交多个任务,一次性获取多个结果,大幅减少系统调用(System Call)次数。
1.传统方式 和 mmap 的区别
1. 传统方式 (read/write) ------ 像是"抄书"
比如你要修改硬盘上一个文件里的数据:
-
步骤: 你告诉操作系统(内核)"我要读这个文件"。
-
过程: 操作系统先把文件从硬盘读到它自己的地盘(内核缓冲),然后再复制一份给你(用户缓冲)。
-
缺点: 数据搬来搬去,多了一次复制(Copy),慢!
2. mmap 方式 ------ 像是"开窗"
-
步骤: 你告诉操作系统"把这个文件映射给我"。
-
过程: 操作系统直接在你的内存里开了一个"窗口",对应的就是硬盘上的那个文件。
-
操作: 你直接读写这块内存,就等于直接读写了那个文件。
-
优点: 操作系统不再当"中间商赚差价",少了那次复制,极快! (这就是传说中的零拷贝技术之一)。
总结 mmap 的核心特点:
-
直接访问:像操作内存变量一样操作文件。
-
零拷贝:数据不需要在"内核态"和"用户态"之间来回拷贝,省CPU,省时间。
-
io_uring 中的应用:在 io_uring 中,mmap 被用来在用户和内核之间共享"任务队列",这样大家都能直接看到任务进度,不需要互相发消息通知。
二、 代码全解析:从定义到运行
这段代码实现了一个 Echo Server :客户端发什么,服务器就回什么。在异步编程中,最难的是状态管理。当内核通知你"有个任务完成了",你得知道这个任务是哪个 Socket 的,是读还是写?
同步: 像是烧水,你必须站在壶旁边死盯着,水不开你什么都干不了。
异步: 像是用洗衣机,把衣服扔进去按一下(发起任务),你就去看电视了(干别的事),洗完了洗衣机会"滴滴"叫(通知你)
第一部分:基础准备与数据结构
这一部分负责引入头文件、定义用于追踪状态的数据结构,以及创建一个标准的 TCP 服务端 Socket。这部分和传统的 Socket 编程区别不大。
cpp
#include <stdio.h>
#include <liburing.h> // io_uring 的核心库头文件
#include <netinet/in.h> // 网络地址结构体定义的头文件
#include <string.h>
#include <unistd.h>
// 定义三个状态常量,用于在回调时区分发生了什么事
#define EVENT_ACCEPT 0 // 标识:这是"建立连接"事件
#define EVENT_READ 1 // 标识:这是"读取数据"事件
#define EVENT_WRITE 2 // 标识:这是"发送数据"事件
// 上下文结构体:这是我们在异步操作中用来"传递信息"的小纸条
struct conn_info {
int fd; // 记录是哪个文件描述符(Socket)
int event; // 记录这个 Socket 刚才在做什么(Accept/Read/Write?)
};
// 初始化服务器 Socket 的函数(标准的 TCP 流程,非 io_uring 特有)
int init_server(unsigned short port) {
int sockfd = socket(AF_INET, SOCK_STREAM, 0); // 创建 TCP Socket
struct sockaddr_in serveraddr;
memset(&serveraddr, 0, sizeof(struct sockaddr_in)); //以此结构体清零
serveraddr.sin_family = AF_INET; // 使用 IPv4
serveraddr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听本机所有网卡
serveraddr.sin_port = htons(port); // 设置端口号
// 绑定端口,如果失败打印错误并返回
if (-1 == bind(sockfd, (struct sockaddr*)&serveraddr, sizeof(struct sockaddr))) {
perror("bind");
return -1;
}
listen(sockfd, 10); // 开始监听,设置 backlog 为 10
return sockfd; // 返回监听套接字
}
struct conn_info :这是全场最重要的结构体。因为 io_uring 是异步的,当你把任务扔给内核后,内核做完了会通知你。但内核只告诉你"做完了",不会告诉你"这是谁的任务"。所以我们需要把这个结构体作为 user_data(用户数据)贴在任务上,等任务回来时,一看这个结构体,就知道是哪个 FD,以及刚才做的是读还是写。
第二部分:封装 io_uring 任务提交函数
这部分将"提交任务"的操作封装成了三个函数。这是 io_uring 编程的核心:获取空位 -> 填写任务 -> 贴上标签。
cpp
#define ENTRIES_LENGTH 1024 // 定义环形队列的长度
#define BUFFER_LENGTH 1024 // 定义数据缓冲区大小
// 【封装1】准备"接收数据"的任务
int set_event_recv(struct io_uring *ring, int sockfd, void *buf, size_t len, int flags) {
// 1. 从提交队列(SQ)中拿一个空白的格子(SQE)
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
// 2. 准备好我们的"标签",记录 fd 和当前要做的事(EVENT_READ)
struct conn_info accept_info = {
.fd = sockfd,
.event = EVENT_READ,
};
// 3. 填写 SQE:告诉内核,请对 sockfd 执行 recv 操作,数据存入 buf
io_uring_prep_recv(sqe, sockfd, buf, len, flags);
// 4. 【关键】把"标签"的内容拷贝到 SQE 的 user_data 字段中
// 这样当任务完成时,我们能从结果中把这个 info 拿回来
memcpy(&sqe->user_data, &accept_info, sizeof(struct conn_info));
return 0;
}
// 【封装2】准备"发送数据"的任务
int set_event_send(struct io_uring *ring, int sockfd, void *buf, size_t len, int flags) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring); // 获取格子
struct conn_info accept_info = {
.fd = sockfd,
.event = EVENT_WRITE, // 标记为写事件
};
// 填写 SQE:告诉内核,请对 sockfd 执行 send 操作,发送 buf 里的数据
io_uring_prep_send(sqe, sockfd, buf, len, flags);
memcpy(&sqe->user_data, &accept_info, sizeof(struct conn_info)); // 贴标签
return 0;
}
// 【封装3】准备"接受连接"的任务
int set_event_accept(struct io_uring *ring, int sockfd, struct sockaddr *addr,
socklen_t *addrlen, int flags) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring); // 获取格子
struct conn_info accept_info = {
.fd = sockfd,
.event = EVENT_ACCEPT, // 标记为 Accept 事件
};
// 填写 SQE:告诉内核,监听 sockfd,有新连接就把地址存入 addr
io_uring_prep_accept(sqe, sockfd, (struct sockaddr*)addr, addrlen, flags);
memcpy(&sqe->user_data, &accept_info, sizeof(struct conn_info)); // 贴标签
return 0;
}
🔍 核心讲解:
-
io_uring_get_sqe:想象你去银行办事,得先去取号机拿一张空白的单子。这就是那个拿单子的操作。 -
io_uring_prep_xxx:在单子上填业务。是取钱(Read)、存钱(Write)还是开户(Accept)? -
memcpy(&sqe->user_data...):这一步最关键。相当于你在单子上别了一张回形针,夹着你的联系方式。等银行办完业务叫号时,你一看单子上的回形针,就知道这是你的业务。
第三部分:初始化与启动监听
这里是 main 函数的开始,负责搭建 io_uring 的环境,并投递第一个任务。
cpp
int main(int argc, char *argv[]) {
unsigned short port = 9999;
int sockfd = init_server(port); // 初始化传统 Socket
// io_uring 的配置参数结构体,这里先清零,使用默认配置
struct io_uring_params params;
memset(¶ms, 0, sizeof(params));
struct io_uring ring;
// 初始化 io_uring 队列,长度为 1024,params 由内核填充
io_uring_queue_init_params(ENTRIES_LENGTH, &ring, ¶ms);
// 准备客户端地址结构体,用于存放新进来的连接信息
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
// 【启动引擎】投递第一个任务:监听 Accept
// 如果不投递这第一个任务,后面的循环什么都不会发生,程序会一直傻等
set_event_accept(&ring, sockfd, (struct sockaddr*)&clientaddr, &len, 0);
// 定义一个共用的缓冲区(注意:在生产环境中,应该为每个连接分配独立的 buffer)
char buffer[BUFFER_LENGTH] = {0};
🔍 核心讲解:
-
io_uring_queue_init_params:这是构建"传送门"的一步。它在内核和用户态之间建立了两个环形队列(SQ 和 CQ)。 -
set_event_accept:这是第一颗多米诺骨牌。我们必须先告诉内核"帮我盯着端口",后续的逻辑才能在循环中流转起来。
第四部分:核心事件循环 (Event Loop)
这是服务器的引擎,程序在这里死循环运行,处理所有 I/O 事件。
cpp
while(1) {
// 1. 【提交】将刚才准备好的 SQE 任务正式推给内核
io_uring_submit(&ring);
struct io_uring_cqe *cqe;
// 2. 【等待】阻塞在这里,直到至少有一个任务完成(生成了 CQE)
io_uring_wait_cqe(&ring, &cqe);
// 3. 【获取】批量获取完成的任务。cqes 数组用来存放结果,这里一次最多取 128 个
struct io_uring_cqe *cqes[128];
int nready = io_uring_peek_batch_cqe(&ring, cqes, 128);
int i = 0;
// 4. 【处理】遍历每一个完成的任务
for(i = 0; i < nready; i++) {
struct io_uring_cqe *entries = cqes[i];
struct conn_info result;
// 取出我们在提交任务时贴上去的"标签" (conn_info)
memcpy(&result, &entries->user_data, sizeof(struct conn_info));
// --- 分情况处理 ---
// 情况 A: 有新连接进来了
if(result.event == EVENT_ACCEPT) {
// 1. 重新注册监听任务(因为 Accept 是一次性的,要不断重新提交以便接收下一个人)
set_event_accept(&ring, sockfd, (struct sockaddr*)&clientaddr, &len, 0);
printf("set_event_accept\n");
// 2. entries->res 对于 Accept 来说,就是新连接的文件描述符 (connfd)
int connfd = entries->res;
// 3. 给这个新连接注册一个"读取数据"的任务
set_event_recv(&ring, connfd, buffer, BUFFER_LENGTH, 0);
}
// 情况 B: 读到数据了
else if(result.event == EVENT_READ) {
int ret = entries->res; // res 是读取到的字节数
printf("set_event_recv ret: %d, %s\n", ret, buffer);
if(ret == 0) {
close(result.fd); // 读到 0 表示客户端断开连接
} else if(ret > 0) {
// 读到了数据,马上注册一个"发送数据"的任务(Echo 回去)
set_event_send(&ring, result.fd, buffer, ret, 0);
}
}
// 情况 C: 数据发送完毕了
else if(result.event == EVENT_WRITE) {
int ret = entries->res; // res 是发送的字节数
printf("set_event_send ret: %d, %s\n", ret, buffer);
// 发完了,马上再注册一个"读取数据"的任务,等待客户端的下一句话
set_event_recv(&ring, result.fd, buffer, BUFFER_LENGTH, 0);
}
}
// 5. 【清理】告诉内核这 nready 个任务我已经处理完了,移动队列指针
io_uring_cq_advance(&ring, nready);
}
}
🔍 核心讲解:
-
io_uring_submit:相当于按下了"发送"键。在这之前,任务只是填在单子上(用户态内存),调用这个函数后,内核才真正看到任务。 -
io_uring_wait_cqe:这里是阻塞点。如果没有任何事件发生,CPU 就在这休息,不费电。 -
状态流转:
-
Accept 完成后 -> 触发 Recv。
-
Recv 完成后 -> 触发 Send。
-
Send 完成后 -> 再次触发 Recv。
-
这就是所谓的Proactor模式:一环扣一环,自动流转。
-
-
io_uring_cq_advance:非常重要!io_uring 的完成队列(CQ)是一个环。你读完了数据,必须告诉内核"我读了这几条",内核才会把这部分空间释放出来给新的结果用。如果不调用这个,队列满了程序就卡死了。
2. 三大核心操作封装
代码封装了三个函数,对应服务器的三个生命周期。
-
set_event_accept:-
功能:准备接收新连接。
-
实现 :获取一个 SQE(提交队列项),填充
io_uring_prep_accept,并将conn_info标记为EVENT_ACCEPT。
-
-
set_event_recv:-
功能:准备接收数据。
-
实现 :告诉内核,如果有数据发给
sockfd,请帮我读到buf里,读完告诉我。标记为EVENT_READ。
-
-
set_event_send:-
功能:准备发送数据。
-
实现 :告诉内核,把
buf里的数据发给sockfd,发完告诉我。标记为EVENT_WRITE。
-
3. 主流程图解 (Main Loop)
main 函数是程序的引擎。让我们画个图来看看它的流转:
代码逻辑拆解:
-
初始化:
-
init_server(9999): 建立传统的 TCP 监听。 -
io_uring_queue_init_params: 初始化 io_uring 实例,创建环形队列。
-
-
第一颗火种:
- 调用
set_event_accept。这是第一个任务,告诉内核:"开始监听端口,有人连就通知我"。
- 调用
-
死循环 (Event Loop):
-
io_uring_submit(&ring): 发射! 把刚才准备的任务推给内核。 -
io_uring_wait_cqe(&ring, &cqe): 等待。程序在这里阻塞,直到至少有一个任务完成。 -
io_uring_peek_batch_cqe: 收割。看一眼完成了多少任务(CQE)。
-
-
处理结果 (Switch Case) : 遍历完成的任务,根据
user_data里取出的result.event进行分发:-
如果是
EVENT_ACCEPT(有人连接了):-
续杯 :立刻再次调用
set_event_accept,保证能接受下一个用户的连接。 -
迎客 :拿到新连接的
connfd,调用set_event_recv,开始等待这个用户发数据。
-
-
如果是
EVENT_READ(读到数据了):-
ret是读取的字节数。 -
如果
ret > 0:说明有数据,马上调用set_event_send把数据发回去(Echo)。 -
如果
ret == 0:客户端断开了,关闭连接。
-
-
如果是
EVENT_WRITE(发送完毕了):- 数据发完了,不能闲着,马上再次调用
set_event_recv,继续监听该用户的下一句话。
- 数据发完了,不能闲着,马上再次调用
-
三、 总结
这段代码通过 io_uring 实现了一个高效的Proactor 模式服务器:
-
Proactor 模式:用户只需提交请求,内核负责执行 I/O 操作,完成后通知用户处理结果。
-
状态机驱动 :通过
EVENT_ACCEPT->EVENT_READ<->EVENT_WRITE的状态流转,实现了业务闭环。