一、 引言
io_uring 自 Linux 5.1 起引入,是一种高性能、低系统调用开销的异步 I/O 机制。与传统 epoll 事件通知模型不同,io_uring 提供了真正的"完成式 I/O"(completion-based I/O),能够让内核与用户态共享环形队列,以极小成本提交与获取 I/O 任务,大幅提升网络服务吞吐能力。
本文通过一份 io_uring TCP Echo Server 示例代码,讲解 io_uring 的整体工作机制,重点分析异步 accept 的实现过程,并逐步构建一个清晰的事件驱动模型。
二、创建普通 TCP 服务端
int init_server(unsigned short port) {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in serveraddr;
memset(&serveraddr, 0, sizeof(struct sockaddr_in));
serveraddr.sin_family = AF_INET;
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);
return sockfd;
}
init_server 这一部分完全是传统的 TCP 服务端初始化逻辑,用来创建监听 socket。它只与 io_uring 共享一个东西:返回的 sockfd。
函数内部的逻辑如下:首先调用 socket(AF_INET, SOCK_STREAM, 0) 创建一个 TCP 套接字,然后通过 sockaddr_in 填入协议族、监听地址和端口。INADDR_ANY 表示监听机器上的所有网卡地址。接着调用 bind 将这个 socket 与本机 0.0.0.0:9999 绑定,如果绑定失败则打印错误并返回 -1。最后调用 listen 让这个 socket 进入监听状态,最多保留 10 个排队的连接请求。成功之后返回监听 socket 的文件描述符 sockfd。
到这里为止,和传统 accept + recv + send 的服务器完全一样,只是后面我们不会直接调用阻塞的 accept、recv、send,而是把这些操作包装成 io_uring 的异步任务
三、io_uring 初始化
在 main 里,init_server 返回 sockfd 后,紧接着构造了一个 io_uring 实例。这部分代码是:
struct io_uring_params params;
memset(¶ms, 0, sizeof(params));
struct io_uring ring;
io_uring_queue_init_params(ENTRIES_LENGTH, &ring, ¶ms);
这行 io_uring_queue_init_params 做的事情比较多,但可以理解为一个"高级初始化函数":它内部调用 io_uring_setup 向内核申请一个 io_uring 实例,获取 ring 的各种偏移信息;然后用 mmap 将提交队列(SQ)、完成队列(CQ)和 SQE 数组映射到用户态;最后把队列、指针、文件描述符等信息全部填进 ring 这个结构体。
从此以后,ring 就是编程人员和内核 io_uring 交互的"句柄"。所有的 io_uring_get_sqe、io_uring_submit、io_uring_wait_cqe 都是围着它转。ring 自己并不存 I/O 任务,它只是帮你管理那块由内核分配、映射到用户态的共享内存区域。
四、事件标识
io_uring 本身并不会告诉你"这是一个 accept 完成的事件"还是"这是一个 recv 完成的事件"。它只会给你一个 CQE,其中包含两个关键信息:
-
cqe->res:对应于底层系统调用的返回值,比如 accept 返回的connfd,recv 返回的读到的字节数。 -
cqe->user_data:这是你在 SQE 里写进去的一块自定义数据,内核保证原样带回。
为了在事件循环里区分不同类型的 I/O 操作,这段代码定义了一个简单的 struct conn_info:
#define EVENT_ACCEPT 0
#define EVENT_READ 1
#define EVENT_WRITE 2
struct conn_info {
int fd;
int event;
};
fd 标记这个任务关联的是哪个文件描述符,event 用三个宏标记这是一个 accept 事件、读事件还是写事件。每一次向 ring 提交任务时,都会构造一个 conn_info,然后用 memcpy 塞进 SQE 的 user_data 字段。等 I/O 完成了,从 CQE 的 user_data 中取出这块区域再还原成 conn_info,就又知道"这是什么任务、对应哪个 fd"了。
五、异步 accept, recv, send
现在重点来看最关键的部分:异步 accept 是怎么实现的。
主函数里有一段 #if 0,对比了传统阻塞 accept 和 io_uring 版本的 accept:
#if 0
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
accept(sockfd, (struct sockaddr*)&clientaddr, &len);
#else
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
set_event_accept(&ring, sockfd, (struct sockaddr*)&clientaddr, &len, 0);
#endif
#if 0 那支路是最熟悉的写法:直接在这里阻塞等待一个客户端连接。我们关心的是 #else 分支里那句 set_event_accept。展开它:
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,
};
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(ring) 从提交队列中取出一个空闲的 SQE。可以把 SQ 想成是一个"提交任务的队列",而 SQE 就是其中的一个任务条目。这个函数会根据当前的 tail/head,从 SQE 数组中返回一个可以写入的新条目。此时 SQE 还是"空白"的,需要你填入具体要执行的 I/O 操作。
然后构造一份 conn_info,把监听 socket 的 fd 和事件类型 EVENT_ACCEPT 记录进去。这一步是给这个任务贴上应用层的"标签",未来任务完成后,你就靠这两个字段来判断"这是一个 accept 完成事件,对应的是哪个监听 fd"。
接着调用 io_uring_prep_accept,这是 liburing 提供的一个封装函数,它会把 SQE 填成一条"accept 系统调用"的请求。内部大致会给 SQE 的 opcode 赋值为 IORING_OP_ACCEPT,把 fd 设置为你传进来的 sockfd,把 addr 和 addrlen 填入 SQE 中。这里没有系统调用发生,它只是把这条 I/O 请求描述好写进了共享内存中的 SQE 结构体里。
最后用 memcpy 把 accept_info 拷贝到 sqe->user_data 中。user_data 在 SQE 和 CQE 里都是一个 64bit 的字段,这里用一个小 struct 塞进去,实际上就是把 fd 和 event 两个字段编码到了这 8 个字节中。这一步不影响内核执行 accept,它存在的意义是:当内核把这条 SQE 执行完后,会在对应的 CQE 中把这个 user_data 原样带回来,你就能在完成队列里根据它识别这条事件是什么。
到这一步为止,其实只是"把一个 accept 请求准备好了,放到了 SQ 的某个位置上",真正让内核开始处理这条 I/O 任务是在后面的循环里调用 io_uring_submit(&ring) 的时候。
在事件循环第一轮运行时,io_uring_submit 会把所有已经准备好的 SQE(其中就包括这条 accept SQE)提交给内核。内核从 SQ 中读出这条 SQE,根据其中的 opcode 和参数调用对应的 I/O 操作,也就是执行一次 accept(sockfd, addr, addrlen, flags)。如果此时没有客户端连接,内核会按照 io_uring 的机制进行等待(这里涉及到内核线程或 poll 机制,属于更底层的实现细节);一旦有客户端完成 TCP 三次握手,accept 返回,新连接 fd 作为结果写入 CQE 的 res 字段,同时把你当初设置的 user_data 原样写进 CQE 的 user_data 字段。
在用户态,事件循环通过 io_uring_wait_cqe 和 io_uring_peek_batch_cqe 拿到了这一条完成事件。对于一个 accept 完成的 CQE,它的 res 就是新连接的 connfd,而 user_data 经过 memcpy 还原成 conn_info 后,你可以看到其中的 event 字段是 EVENT_ACCEPT。这就是从 set_event_accept 把任务丢进 SQ,到内核执行 accept,再到 CQE 把结果带回来的完整流程。
set_event_recv 和 set_event_send 的写法和 set_event_accept 完全同构,只是换成了不同的 prep 函数和事件类型。
set_event_recv 的代码是:
int set_event_recv(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_READ,
};
io_uring_prep_recv(sqe, sockfd, buf, len, flags);
memcpy(&sqe->user_data, &accept_info, sizeof(struct conn_info));
return 0;
}
只是把 prep_accept 换成了 prep_recv,事件类型换成了 EVENT_READ,含义变为"请在这个 fd 上执行一次 recv,把数据读到 buf 中,完成后告诉我这是一个 READ 事件"。
set_event_send 也是一样的套路:
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,
};
io_uring_prep_send(sqe, sockfd, buf, len, flags);
memcpy(&sqe->user_data, &accept_info, sizeof(struct conn_info));
return 0;
}
把 prep_send 和 EVENT_WRITE 组合起来,同样是构造出一条写操作任务,委托 io_uring 异步执行。
这三种操作共同构成了 io_uring 版本的"网络 I/O 原语":
监听 socket 上用 accept,连接 socket 上用 recv 和 send,事件类型靠 EVENT_* 区分,fd 存在 conn_info 里面。
六、事件循环
现在来看主循环,这一段可以当成整个服务器的大脑。先把代码再看一遍:
char buffer[BUFFER_LENGTH] = {0};
while (1) {
io_uring_submit(&ring);
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
struct io_uring_cqe *cqes[128];
int nready = io_uring_peek_batch_cqe(&ring, cqes, 128); // epoll_wait
int i = 0;
for (i = 0; i < nready; i++) {
struct io_uring_cqe *entries = cqes[i];
struct conn_info result;
memcpy(&result, &entries->user_data, sizeof(struct conn_info));
if (result.event == EVENT_ACCEPT) {
set_event_accept(&ring, sockfd,
(struct sockaddr*)&clientaddr, &len, 0);
int connfd = entries->res;
set_event_recv(&ring, connfd, buffer, BUFFER_LENGTH, 0);
} else if (result.event == EVENT_READ) {
int ret = entries->res;
if (ret == 0) {
close(result.fd);
} else if (ret > 0) {
set_event_send(&ring, result.fd, buffer, ret, 0);
}
} else if (result.event == EVENT_WRITE) {
int ret = entries->res;
set_event_recv(&ring, result.fd, buffer, BUFFER_LENGTH, 0);
}
}
io_uring_cq_advance(&ring, nready);
}
这一整个循环不断重复几个动作。
开头的 io_uring_submit(&ring) 是一个"统一提交点"。它会把你在上一轮循环中通过 set_event_accept、set_event_recv、set_event_send 填入 SQ 的所有 SQE 一次性提交给内核,让内核开始执行这些 I/O 任务。如果本轮没有新任务填入 SQ,那这次 submit 也可以提交为 0 个任务,开销很小。
接下来调用 io_uring_wait_cqe(&ring, &cqe),这一步会阻塞等待,直到至少有一个 I/O 操作完成。它返回一个 cqe 指针,你马上又调用 io_uring_peek_batch_cqe 把当前完成队列中的所有 CQE 一次批量取出来。这里把 peek_batch_cqe 类比成 epoll 里的 epoll_wait 是很自然的,它告诉你这一轮一共有多少个 I/O 完成了,每个完成事件对应一个 CQE。
然后进入一个 for 循环,逐个处理这一批完成事件。对每一个 entries(也就是一条 CQE),先从 entries->user_data 中把你之前封装进去的 conn_info 还原出来,放在 result 里。这个 result 的 event 字段就决定了这条 CQE 是哪一类操作:ACCEPT / READ / WRITE。
当 result.event == EVENT_ACCEPT 时,说明有新连接完成了握手,内核已经帮你执行完 accept。这时候 entries->res 就是新连接的 connfd。处理逻辑分两步:第一步是再次调用 set_event_accept,继续在监听 socket 上排一个新的 accept 任务,以便后续还有新的客户端连接可以被接受。这一点和 epoll 里不断去监听 sockfd 的行为类似,只是这里是一次一次地提交异步 accept。第二步是对刚刚建立的这个连接调用 set_event_recv,在它上面异步排一个 recv 任务,这样当客户端发来第一批数据时,io_uring 就会在 CQ 中产生一条 EVENT_READ 的完成事件。
当 result.event == EVENT_READ 时,说明某个连接上的 recv 完成了。这时 entries->res 是 recv 的返回值,也就是读到的字节数。如果是 0,说明对端已经关闭连接,于是直接 close(result.fd)。如果返回值大于 0,说明成功读到了一些数据,此处的服务器逻辑非常简单:直接调用 set_event_send 把刚刚读到的数据原样发回客户端,实现 echo 的效果。因此对于 READ 事件来说,处理过程是"读到数据 → 提交一个对应 fd 的 SEND 任务"。
当 result.event == EVENT_WRITE 时,说明某个连接上的 send 操作完成了。这时明显没有必要立即关掉连接,典型的 echo 模型会继续等待客户端发送下一条消息,于是再次调用 set_event_recv 在这个 fd 上排一个新的 recv 任务。这样对于每一个存活的连接来说,它在 READ 和 WRITE 事件之间来回切换:收到数据就写回,写回完成后再等下一次读取。
for 循环结束后调用 io_uring_cq_advance(&ring, nready),这一步是告诉内核:"这 n 条 CQE 我已经处理完了,可以把完成队列的 head 往前挪,把空间留给后续新的完成事件。"如果不调用这一步,完成队列迟早会被占满,新的完成事件将无法写入。
把这整个循环串起来看,一条连接的生命周期大致是这样的:监听 socket 上的 accept 完成时,创建 connfd,并给这个 connfd 安排一次 recv;recv 完成时,如果有数据则安排一次 send;send 完成时重新安排 recv;当某次 recv 返回 0 时,关闭 fd,连接生命周期结束。这一切都在 io_uring 的事件循环之上完成,所有 I/O 操作都是异步提交、异步完成,通过 CQE 驱动状态机推进。
七、结语
通过上述代码,可以清晰看到 io_uring 异步服务器的整体模式:
-
所有 I/O 操作(accept/recv/send)由 SQE 描述并提交
-
内核异步执行操作,结果通过 CQE 报告
-
user_data 是驱动事件分发的关键
-
while 循环负责从 CQE 中取事件并构建状态机
-
accept、recv、send 按事件不断级联,形成无阻塞高性能服务
相比 epoll,io_uring 避免了系统调用频繁切换和用户态的 read/write 阶段,使 I/O 框架更趋近于真正意义上的异步完成模型。
该模式适用于构建高并发高吞吐的网络服务、代理系统或任何以 I/O 为瓶颈的应用。
更多相关知识可查看https://github.com/0voice。