【网络编程】C语言手撸 io_uring 异步 Echo 服务器

前言:为什么我们要学 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),在用户态和内核态之间共享内存:

  1. SQ (Submission Queue - 提交队列):我们把要做的任务(如:读、写、接受连接)放到这里。

  2. CQ (Completion Queue - 完成队列):内核把处理完的结果(如:读到了多少字节)放到这里。

这样做的好处?

  • 零拷贝 :利用 mmap 共享内存,减少用户态和内核态的数据拷贝。

  • 批处理:可以一次性提交多个任务,一次性获取多个结果,大幅减少系统调用(System Call)次数。

1.传统方式mmap 的区别

1. 传统方式 (read/write) ------ 像是"抄书"

比如你要修改硬盘上一个文件里的数据:

  • 步骤: 你告诉操作系统(内核)"我要读这个文件"。

  • 过程: 操作系统先把文件从硬盘读到它自己的地盘(内核缓冲),然后再复制一份给你(用户缓冲)。

  • 缺点: 数据搬来搬去,多了一次复制(Copy),慢!

2. mmap 方式 ------ 像是"开窗"

  • 步骤: 你告诉操作系统"把这个文件映射给我"。

  • 过程: 操作系统直接在你的内存里开了一个"窗口",对应的就是硬盘上的那个文件。

  • 操作: 你直接读写这块内存,就等于直接读写了那个文件。

  • 优点: 操作系统不再当"中间商赚差价",少了那次复制,极快! (这就是传说中的零拷贝技术之一)。

总结 mmap 的核心特点:

  1. 直接访问:像操作内存变量一样操作文件。

  2. 零拷贝:数据不需要在"内核态"和"用户态"之间来回拷贝,省CPU,省时间。

  3. 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(&params, 0, sizeof(params));

    struct io_uring ring;
    // 初始化 io_uring 队列,长度为 1024,params 由内核填充
    io_uring_queue_init_params(ENTRIES_LENGTH, &ring, &params);

    // 准备客户端地址结构体,用于存放新进来的连接信息
    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 函数是程序的引擎。让我们画个图来看看它的流转:

代码逻辑拆解:

  1. 初始化

    • init_server(9999): 建立传统的 TCP 监听。

    • io_uring_queue_init_params: 初始化 io_uring 实例,创建环形队列。

  2. 第一颗火种

    • 调用 set_event_accept。这是第一个任务,告诉内核:"开始监听端口,有人连就通知我"。
  3. 死循环 (Event Loop)

    • io_uring_submit(&ring): 发射! 把刚才准备的任务推给内核。

    • io_uring_wait_cqe(&ring, &cqe): 等待。程序在这里阻塞,直到至少有一个任务完成。

    • io_uring_peek_batch_cqe: 收割。看一眼完成了多少任务(CQE)。

  4. 处理结果 (Switch Case) : 遍历完成的任务,根据 user_data 里取出的 result.event 进行分发:

    • 如果是 EVENT_ACCEPT (有人连接了):

      1. 续杯 :立刻再次调用 set_event_accept,保证能接受下一个用户的连接。

      2. 迎客 :拿到新连接的 connfd,调用 set_event_recv,开始等待这个用户发数据。

    • 如果是 EVENT_READ (读到数据了):

      1. ret 是读取的字节数。

      2. 如果 ret > 0:说明有数据,马上调用 set_event_send 把数据发回去(Echo)。

      3. 如果 ret == 0:客户端断开了,关闭连接。

    • 如果是 EVENT_WRITE (发送完毕了):

      1. 数据发完了,不能闲着,马上再次调用 set_event_recv,继续监听该用户的下一句话。

三、 总结

这段代码通过 io_uring 实现了一个高效的Proactor 模式服务器:

  1. Proactor 模式:用户只需提交请求,内核负责执行 I/O 操作,完成后通知用户处理结果。

  2. 状态机驱动 :通过 EVENT_ACCEPT -> EVENT_READ <-> EVENT_WRITE 的状态流转,实现了业务闭环。

0voice · GitHub

相关推荐
Y.O.U..1 小时前
Linux复习-用户和组管理
linux·服务器
lead520lyq2 小时前
Golang GPRC流式传输案例
服务器·开发语言·golang
饺子大魔王的男人2 小时前
告别服务器失联!Prometheus+Alertmanager+cpolar 让监控告警不局限于内网
运维·服务器·prometheus
吉普赛的歌2 小时前
【服务器】为安全考虑,已锁定该用户帐户,原因是登录尝试或密码更。改尝试过多。请稍候片刻再重试,或与系统管理员或技术支持联系。
运维·服务器·安全
m0_737302582 小时前
腾讯云TDSQL-C+CVM软硬协同,数据库性能三倍跃升
服务器
Fᴏʀ ʏ꯭ᴏ꯭ᴜ꯭.2 小时前
Keepalived高可用配置指南
服务器·网络·php
掘根2 小时前
【jsonRpc项目】Registry-Discovery模块
运维·服务器·数据库
图扑可视化2 小时前
HT 技术实现数字孪生智慧服务器信息安全监控平台
服务器·信息可视化·数字孪生·三维可视化
鸽芷咕2 小时前
无需额外运维!金仓KES V9一站式承接MongoDB全场景需求
运维·数据库·mongodb