从 select 到 io_uring

最近在学习 Redis 源代码时, 被它极其高效的网络通信机制深深吸引。Redis 的核心架构是单线程的, 却能轻松支撑起海量的客户端请求。如何用 简单 C 语言 + Linux 接口做到这一点的?是一个服务器开发领域的经典挑战:怎样才能让一个线程高效地监控成千上万个连接, 这就是著名的 C10K 问题(即单机同时处理 10, 000 个客户端连接)?

传统的阻塞 I/O 模型要求每个连接都有自己的线程, 这在连接数量较少时还能应付, 但当连接数达到几千甚至上万时, 系统就会因为线程上下文切换和内存消耗而不堪重负。Linux 提供了多种 I/O 多路复用机制来, 从 select/poll, 再到 epoll, 最后到 io_uring(还不成熟,需要新内核), 每一代技术都在前一代的基础上做出了重要改进。借着探索 Redis 源码的契机, 学习 Linux 中的 I/O 轮询机制(主要是epoll)。

select

select 最早是由 BSD Unix 引入的, 它的兼容性是最好的, 几乎所有的类 Unix 系统都支持. 它是第一个允许程序监控多个文件描述符的机制。它的使用方式很直观:你把想要监控的文件描述符放到一个集合里, 然后调用 select, 它会阻塞等待, 直到至少有一个文件描述符准备好进行 I/O 操作。

fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sockfd,  &readfds);

int ready = select(max_fd + 1,  &readfds,  NULL,  NULL,  NULL);

for (int i = 0; i <= max_fd; i++) {
    if (FD_ISSET(i,  &readfds)) {
        // ready to read
    }
}

看起来不错, 但 select 有几个致命的问题。首先, 它有一个硬编码的限制:最多只能监控 1024 个文件描述符。这个限制来自于 FD_SETSIZE 宏定义, 虽然理论上可以修改, 但需要重新编译内核和所有相关的库, 实际上几乎没人这么做。

更严重的问题是性能。每次调用 select, 你都需要把整个 fd_set 从用户空间拷贝到内核空间。内核检查完哪些文件描述符就绪后, 又要把结果拷贝回用户空间。然后你还得遍历所有的文件描述符来找出哪些是真正就绪的。这意味着即使只有一个连接有数据到达, 你也要检查所有 1024 个文件描述符。这是一个 O(n) 的操作, 当连接数增加时, 性能会线性下降。

poll

poll 解决了 select 的一些问题, 但核心的性能问题依然存在。poll 使用一个 pollfd 结构体数组, 而不是位图, 这样就没有了 1024 的限制。你可以监控任意数量的文件描述符, 只要内存够用。

struct pollfd fds[1000];

while (1) {
    int n = poll(fds, 1000, -1);

    for (int i = 0; i < 1000; i++) {
        if (fds[i].revents & POLLIN) {
            read(fds[i].fd, buf, sizeof(buf));
        }
    }
}

poll 的接口确实比 select 更清晰, pollfd 结构体让代码更容易理解。但遗憾的是, 它仍然需要在每次调用时拷贝整个数组到内核, 内核仍然需要遍历所有文件描述符来检查哪些就绪, 你仍然需要遍历所有文件描述符来找出就绪的那些。本质上, poll 只是换了个接口, 性能问题并没有得到解决。

epoll

Linux 2.5 引入了 epoll, 这是一个真正的革命性改进。epoll 的设计者们意识到, select/poll 的根本问题在于每次调用都要告诉内核”我对这些文件描述符感兴趣”, 然后内核检查完后告诉你”这些文件描述符就绪了”。这个过程中有大量的重复工作。

epoll 采用了完全不同的思路:你先创建一个 epoll 实例, 然后告诉内核你对哪些文件描述符感兴趣, 这个信息(sockfd)会被保存在内核中, 内核维护 ready list, 之后, 你只需要调用 epoll_wait 等待事件, 内核只会返回那些真正就绪的文件描述符。

int epfd = epoll_create(1);

epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);

while (1) {
    int n = epoll_wait(epfd, events, 1024, -1);
}

这个设计带来了巨大的性能提升。首先, 不需要每次都拷贝文件描述符列表, 因为内核已经记住了。其次, epoll_wait 只返回就绪的文件描述符, 不需要遍历所有的连接。如果有 10, 000 个连接, 但只有 10 个有数据到达, epoll_wait 只会返回这 10 个, 而不是遍历所有 10, 000 个。这是一个从 O(n) 到 O(m) 的优化, m 是就绪的文件描述符数量。

epoll 还提供了两种触发模式:水平触发(Level Triggered)和边缘触发(Edge Triggered)。水平触发是默认模式, 只要文件描述符处于就绪状态, epoll_wait 就会返回它。边缘触发则只在状态变化时通知一次, 这要求你必须一次性读取所有可用数据, 否则可能会错过通知。边缘触发模式性能更高, 但编程难度也更大, 需要仔细处理各种边界情况。Redis/Nginx 都是使用的边缘触发模式。

io_uring

Linux 5.1 引入了 io_uring, 这是一个全新的异步 I/O 接口。io_uring 的设计理念与 epoll 完全不同, 它不是基于”就绪通知”, 而是基于”完成通知”。

epoll 告诉你”这个 socket 可以读了”, 然后你调用 read 去读取数据。io_uring 则是你告诉内核”请帮我读取这个 socket 的数据”, 然后内核完成读取后通知你”数据已经读好了, 在这个缓冲区里”。这是一个从同步非阻塞到真正异步的转变。

io_uring 使用两个环形缓冲区(ring buffer):提交队列(Submission Queue)和完成队列(Completion Queue)。这两个队列通过共享内存映射在用户空间和内核空间之间, 避免了数据拷贝。你把 I/O 请求放到提交队列, 内核从提交队列取出请求执行, 然后把结果放到完成队列, 你从完成队列取出结果。整个过程可以完全不需要系统调用, 这在高频 I/O 场景下能带来显著的性能提升。

struct io_uring ring;
io_uring_queue_init(32,  &ring,  0);

// 提交一个 accept 请求
struct io_uring_sqe* sqe = io_uring_get_sqe(&ring);
io_uring_prep_accept(sqe,  sockfd,  NULL,  NULL,  0);
io_uring_sqe_set_data(sqe,  accept_info);
io_uring_submit(&ring);

// 等待完成
struct io_uring_cqe* cqe;
io_uring_wait_cqe(&ring,  &cqe);

conn_info* info = io_uring_cqe_get_data(cqe);
int result = cqe->res;
io_uring_cqe_seen(&ring,  cqe);

io_uring 的另一个强大之处在于它支持批量操作。你可以一次性提交多个 I/O 请求, 内核会并行处理它们, 然后一次性返回所有完成的结果。这种批量处理能够显著减少系统调用的次数, 在某些场景下甚至可以完全避免系统调用。

当然, io_uring 也不是没有缺点。它的 API 相对复杂, 学习曲线比较陡峭。而且它需要 Linux 5.1+ 的内核, 这在一些老旧系统上可能是个问题。但从长远来看, io_uring 代表了 Linux I/O 的未来方向。

TEST

下面是一个简单的 echo 服务器, 分别用 epoll 和 io_uring 实现。这个服务器的功能很简单:接受客户端连接, 读取客户端发送的数据, 然后把数据原样发回去。

  1. epoll 的实现

代码的核心逻辑很清晰:创建 epoll 实例, 添加监听 socket, 然后进入事件循环。当有新连接到达时, accept 并把新的 socket 添加到 epoll。当有数据到达时, 读取并发送回去。

int epfd = epoll_create1(EPOLL_CLOEXEC);

struct epoll_event event;
event.events = EPOLLIN;
event.data.fd = sockfd;
epoll_ctl(epfd,  EPOLL_CTL_ADD,  sockfd,  &event);

while (1) {
    int num_fds = epoll_wait(epfd,  events,  MAX_EVENTS,  -1);
    
    for (int i = 0; i < num_fds; i++) {
        if (events[i].data.fd == sockfd) {
            int client_fd = accept(sockfd,  NULL,  NULL);
            event.events = EPOLLIN;
            event.data.fd = client_fd;
            epoll_ctl(epfd,  EPOLL_CTL_ADD,  client_fd,  &event);
        } else {
            char buffer[1024];
            ssize_t n = read(events[i].data.fd,  buffer,  sizeof(buffer));
            if (n > 0) {
                write(events[i].data.fd,  buffer,  n);
            } else {
                close(events[i].data.fd);
            }
        }
    }
}
  1. io_uring 的实现。

这里的思路有所不同, 我们使用一个状态机来跟踪每个连接的状态:ACCEPT、READ、WRITE。每个状态对应一个异步操作, 操作完成后转到下一个状态。

struct io_uring ring;
io_uring_queue_init(32,  &ring,  0);

// 提交初始 accept
conn_info* accept_info = malloc(sizeof(conn_info));
accept_info->type = EVENT_ACCEPT;
accept_info->fd = sockfd;

struct io_uring_sqe* sqe = io_uring_get_sqe(&ring);
io_uring_prep_accept(sqe,  sockfd,  NULL,  NULL,  0);
io_uring_sqe_set_data(sqe,  accept_info);
io_uring_submit(&ring);

while (1) {
    struct io_uring_cqe* cqe;
    io_uring_wait_cqe(&ring,  &cqe);
    
    conn_info* info = io_uring_cqe_get_data(cqe);
    int res = cqe->res;
    io_uring_cqe_seen(&ring,  cqe);

    switch (info->type) {
        case EVENT_ACCEPT:
            // 新连接, 提交 read 请求
            conn_info* client_info = malloc(sizeof(conn_info));
            client_info->type = EVENT_READ;
            client_info->fd = res;
            
            sqe = io_uring_get_sqe(&ring);
            io_uring_prep_read(sqe,  res,  client_info->buffer,  1024,  0);
            io_uring_sqe_set_data(sqe,  client_info);
            io_uring_submit(&ring);
            
            // 继续 accept
            sqe = io_uring_get_sqe(&ring);
            io_uring_prep_accept(sqe,  sockfd,  NULL,  NULL,  0);
            io_uring_sqe_set_data(sqe,  accept_info);
            io_uring_submit(&ring);
            break;
            
        case EVENT_READ:
            if (res > 0) {
                // 提交 write 请求
                info->type = EVENT_WRITE;
                sqe = io_uring_get_sqe(&ring);
                io_uring_prep_write(sqe,  info->fd,  info->buffer,  res,  0);
                io_uring_sqe_set_data(sqe,  info);
                io_uring_submit(&ring);
            } else {
                close(info->fd);
                free(info);
            }
            break;
            
        case EVENT_WRITE:
            close(info->fd);
            free(info);
            break;
    }
}

我在本地做了一个简单的性能测试, 用 10, 000 个并发连接, 每个连接发送 100 个请求。结果很有意思:epoll 版本表现很好, CPU 使用率在 60% 左右, io_uring 类似, 甚至更差一些。 io_uring 主要优势应该在 批量操作 和 能同时统一处理socket/文件/定时器

[NanoRedis]: 一个极简的类Redis内存数据库
brpc 中引入 io_uring 的探索与实践