最近在学习 Redis 源代码时, 被它极其高效的网络通信机制深深吸引。Redis 的核心架构是单线程的, 却能轻松支撑起海量的客户端请求。如何用 简单 C 语言 + Linux 接口做到这一点的?是一个服务器开发领域的经典挑战:怎样才能让一个线程高效地监控成千上万个连接, 这就是著名的 C10K 问题(即单机同时处理 10, 000 个客户端连接)?
传统的阻塞 I/O 模型要求每个连接都有自己的线程, 这在连接数量较少时还能应付, 但当连接数达到几千甚至上万时, 系统就会因为线程上下文切换和内存消耗而不堪重负。Linux 提供了多种 I/O 多路复用机制来, 从 select/poll, 再到 epoll, 最后到 io_uring(还不成熟,需要新内核), 每一代技术都在前一代的基础上做出了重要改进。借着探索 Redis 源码的契机, 学习 Linux 中的 I/O 轮询机制(主要是epoll)。
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 解决了 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 只是换了个接口, 性能问题并没有得到解决。
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 都是使用的边缘触发模式。
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 的未来方向。
下面是一个简单的 echo 服务器, 分别用 epoll 和 io_uring 实现。这个服务器的功能很简单:接受客户端连接, 读取客户端发送的数据, 然后把数据原样发回去。
代码的核心逻辑很清晰:创建 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);
}
}
}
}
这里的思路有所不同, 我们使用一个状态机来跟踪每个连接的状态: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/文件/定时器