select 与 epoll 的区别以我之见
最近在准备游戏客户端开发的面试,发现 select 和 epoll 的区别是一个面试高频考点,去网上搜过很多资料,发现它们大多数讲的不够清晰,尽管把该有的技术细节都写了出来,但很难在初学者心目中形成一个具体的脉络。今天我跟 DeepSeek 激辩了许久,终于形成了一套对二者的系统认识,在此写成博客,一来方便后来者学习,二来留作笔记以免未来我自己忘了。
解决什么问题?
如果你没写过网络开发或者只在非 C/C++ 语言上写过网络开发,你大概率连 C/C++ 进行网络开发时为什么需要 select 和 epoll 都不知道,令人遗憾的是,我个人认为网上的大多数教程连这一点都没有解释清楚。
众所周知,Linux 操作系统内核的一大设计哲学就是“一切皆文件”,有人可能认为这句话的意思是一切资源都要挂载到根文件目录下,但这种理解不完全正确,这句话实际上是文件的定义,它是对“文件”这个词语是什么意思的解释,在 Linux 中,资源就是文件,文件就是资源,比如:
- 一个磁盘中的常规文件当然是文件
- 进程和操作系统状态是文件,因为它们是一个可访问的资源
- 网络连接是一个文件
- 标准输入和标准输出都是文件
那你可能会问,既然“文件”是一个纯粹的虚幻概念,将“一切”定义为文件的意义是什么呢,实际上这么做的意义就在于,POSIX 标准规定 write 和 read 这两个 API 是用来读写所有文件的,这当然也包括网络连接和标准输入输出这种抽象文件。
比如,这是一段用 write 实现的 Hello World,C 库的 printf 底层其实也是用 write 实现的:
#include <unistd.h>
int main() {
// STDOUT_FILENO 是标准输出的文件描述符(通常为 1)
write(STDOUT_FILENO, "Hello World\n", 12);
return 0;
}
而文件描述符,也就是这段代码中的 STDOUT_FILENO,在更常见的情况下是一个被命名为 fd的变量,它是一个用来告诉操作系统内核“我要操作哪个文件”的句柄。
说了这么多跟 select 和 epoll 要解决什么问题有什么关系呢,select 和 epoll 的作用实际上是“我有一大堆文件描述符,我希望操作系统告诉我,这其中有哪些文件描述符是有内容可以读取的”。
你可以不向操作系统提出这个问题,如果你的文件描述符是非阻塞的,你可以大大方方用 read 把所有文件描述符都读一遍,比如像下面这样:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <stdlib.h>
#define FILE_COUNT 1000
FILE *fps[FILE_COUNT]; // 假设已完成文件打开过程
int fds[FILE_COUNT];
int main() {
for (int i = 0; i < FILE_COUNT; i++) {
fds[i] = fileno(fps[i]); // 获取 fd
int flags = fcntl(fds[i], F_GETFL, 0);
fcntl(fds[i], F_SETFL, flags | O_NONBLOCK); // 设为非阻塞
}
char buffer[1024];
for (int i = 0; i < FILE_COUNT; i++) {
ssize_t n = read(fds[i], buffer, sizeof(buffer) - 1);
if (n > 0) {
buffer[n] = '\0';
printf("fd %d 有数据:%s\n", fds[i], buffer);
} else if (n == 0) {
// EOF
} else {
// 出错或 EAGAIN/EWOULDBLOCK(无数据可读)
}
}
return 0;
}
然而这么做有一个非常严重的性能问题:作为系统调用,read的开销是非常大的,如此大的开销再加上循环 1000 遍,就算没那么高并发量的系统也受不了。
如何解决“检查 fd 是否有消息可读”的问题
最简单直观的方法,设计一个 int can_read(int fd)方法,传入的 fd能读就返回 1,不能读就返回 0:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <stdlib.h>
#define FD_COUNT 1000
int fds[FD_COUNT]; // 假设已初始化
int can_read(int fd);
int main() {
// 将所有 fd 设置为非阻塞
for (int i = 0; i < FD_COUNT; i++) {
int flags = fcntl(fds[i], F_GETFL, 0);
fcntl(fds[i], F_SETFL, flags | O_NONBLOCK);
}
char buffer[1024];
for (int i = 0; i < FD_COUNT; i++) {
if (can_read(fds[i])) {
// 可读,进行读取
ssize_t n = read(fds[i], buffer, sizeof(buffer) - 1);
if (n > 0) {
buffer[n] = '\0';
printf("fd %d 有数据:%s\n", fds[i], buffer);
}
}
}
return 0;
}
这么做解决问题了吗,它确实解决了 read函数不能用来判断阻塞文件是否可读的问题,但性能上没有丝毫优化,因为 can_read也是一个系统调用,开销一点也不比 read小。
你可能会想到,既然我要一次性判断很多 fd 是否可读,能不能一次系统调用询问操作系统内核所有 fd 的可读性,于是有 void can_read_all(int n, int *fds, int *out_readable):
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#define FD_COUNT 1000
int fds[FD_COUNT];
int readable[FD_COUNT];
int can_read_all(int n, int *fds, int *out_readable);
int main() {
if(!can_read_all(FD_COUNT, fds, readable)) {
return -1;
}
char buffer[1024];
for (int i = 0; i < FD_COUNT; i++) {
if (readable[i]) {
ssize_t n = read(fds[i], buffer, sizeof(buffer) - 1);
if (n > 0) {
buffer[n] = '\0';
printf("fd %d 有数据:%s\n", fds[i], buffer);
}
}
}
return 0;
}
这其实就是 select 在干的事。
与标准的 select 的区别在于,系统调用要把用户态变量拷贝一份到内核态,如果你真的传这么一个大数组过去,开销还是不低。select 的解决方法是使用“位图(Bitmap)”,简单地说就是,搞一个 1024 位(二进制位)的超大整数(并不一定是 1024 位,可由宏 FD_SETSIZE决定,默认是 1024 位),然后如果你要问 fd=0 的文件能不能读,就让这个整数的第一位(二进制位)置1,如果你不在乎 fd=1 的文件能不能读,就让这个整数的第二位置零,以此类推,把这个大整数传给操作系统,操作系统也返回这样一个大整数,1就是这个 fd 可以读,0就是这个 fd 不能读。
int select(int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
这里 fd_set类型就是这个位图。
为什么 epoll 比 select 好
想象一个场景:你是一个读数爱好者,你所居住的城镇有一个经常被借走图书的图书馆,你每次去看书都要管图书管理员借阅指定的几本书,以下是两种借阅方式:
-
场景1:
第一周: 你:管理员,我要《C++ Primer Plus》《select 与 epoll 的区别以我之见》《有哪些优秀的百合同人作品》这三本书 管理员:(经过一番辛苦的搜索)《有哪些优秀的百合同人作品》被人借走了,剩下两本给你 第二周: 你:管理员,我要《C++ Primer Plus》《select 与 epoll 的区别以我之见》《有哪些优秀的百合同人作品》这三本书 管理员:(经过一番辛苦的搜索)《C++ Primer Plus》被人借走了,剩下两本给你 -
场景2:
第一周: 你:管理员大哥,我未来几周要借阅《C++ Primer Plus》《select 与 epoll 的区别以我之见》《有哪些优秀的百合同人作品》这三本书,你要不要把它们整理到一块,我一来就能带走 管理员:收到 第二周: 你:管理员我来了 管理员:(拿起办公桌上的两本书)《有哪些优秀的百合同人作品》被人借走了,剩下两本给你 第三周: 你:管理员我来了 管理员:(拿起办公桌上的两本书)《C++ Primer Plus》被人借走了,剩下两本给你
请问,哪种借阅方式,借书的流程更快?
我认为我说完了,这就是 epoll 为什么比 select 好,相比无状态的 select,这种有状态设计有一个专有名词叫做“事件通知机制”。
#include <sys/epoll.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#define MAX_EVENTS 100
#define FD_COUNT 1000
int fds[FD_COUNT];
int main() {
// 创建 epoll 实例(相当于“管理员大哥”)
int epfd = epoll_create1(0);
if (epfd == -1) {
perror("epoll_create1");
exit(EXIT_FAILURE);
}
// 告诉管理员我要借哪些书(注册感兴趣的事件)
for (int i = 0; i < FD_COUNT; i++) {
struct epoll_event ev;
ev.events = EPOLLIN; // 关注可读事件
ev.data.fd = fds[i];
if (epoll_ctl(epfd, EPOLL_CTL_ADD, fds[i], &ev) == -1) {
perror("epoll_ctl: add");
exit(EXIT_FAILURE);
}
}
// 循环“来领书”(等待并获取就绪的 fd)
struct epoll_event events[MAX_EVENTS];
while (1) {
// epoll_wait 返回就绪的 fd 个数,就绪的 fd 存放在 events 数组中
// events 也被称为“就绪列表”,只包含可读的 fd
int nready = epoll_wait(epfd, events, MAX_EVENTS, -1); // -1 表示无限等待
if (nready == -1) {
perror("epoll_wait");
break;
}
for (int i = 0; i < nready; i++) {
int fd = events[i].data.fd;
char buf[1024];
ssize_t n = read(fd, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("fd %d 有数据:%s\n", fd, buf);
} else {
// 处理错误或关闭等
}
}
}
close(epfd);
return 0;
}
聪明的你一定可以看出来,如果你每周来借的书都不一样,epoll 就没办法发挥其优势,但 u1s1,这种场景确实非常非常罕见,尤其是在 Web 开发中。
等等,我红黑树呢
如果你去搜过 epoll,你一定还会搜到“红黑树”,有些资料还说它是 epoll 之所以快的关键原因,但我以上的内容似乎压根没提到有关树的内容,这是为什么。
“有状态”是 epoll 相比 select 最重要的区别,操作系统内核这个“图书管理员”需要自己保存进程正在监听的文件描述符,我们考虑操作系统使用哪种数据结构进行保存。
-
数组行不行?
乍一看是可以的,但是 Web 开发中,每个网络连接都是一个 fd,这意味着 fd 不仅多,而且数量会一直变化,如果使用数组,每次往 epoll 里添加和删除 fd 都要完整复制整个数组,性能那是相当的差。
-
链表行不行?
链表解决了完整复制的开销,而且支持 O(1) 时间复杂度插入,如果你要监听的 fd 只增不减,那我觉得链表比红黑树要好,但事实不是这样的,随着连接的关闭,链表需要 O(n) 的时间复杂度来删除 fd,在高并发场景下,O(n) 的时间复杂度已经相当的耗时了。
-
哈希表行不行?
我觉得是可以的,哈希表有 O(1) 插入和删除效率,理想哈希表肯定要好过红黑树,DeepSeek 告诉我,使用红黑树而不是哈希表的主要原因有两点,首先是极端情况下 fd 可能会发生大量哈希碰撞,导致哈希表跌落回 O(n) 效率;其次作为操作系统内核,Linux 必须要考虑这种极端场景下的稳定性,其次哈希表在频繁增删时可能引发动态扩容/缩容,会导致中断上下文的执行时长不稳定,这也是操作系统内核不太能接受的。
-
选择红黑树的理由
红黑树有 O(log n) 的插入和删除,就算 n 非常大,一般也认为 O(log n) 这样的时间复杂度足够小,相比哈希表,它也能做到插入和删除时间复杂度的稳定,所以是最优人选。
所以红黑树对 epoll 性能的加持主要体现在“增删”fd 的过程中,尽管一般认为 epoll 解决了 select 最多只能监听 1024 个 fd 的问题也要归功于红黑树,然而我认为,epoll 突破数量限制主要是因为将监听的 fd 存储在了内核内存里,跟具体是用的哪种数据结构来存储关系不大。