最近在准备游戏客户端开发的面试,发现 select 和 epoll 的区别是一个面试高频考点,去网上搜过很多资料,发现它们大多数讲的不够清晰,尽管把该有的技术细节都写了出来,但很难在初学者心目中形成一个具体的脉络。今天我跟 DeepSeek 激辩了许久,终于形成了一套对二者的系统认识,在此写成博客,一来方便后来者学习,二来留作笔记以免未来我自己忘了。

解决什么问题?

如果你没写过网络开发或者只在非 C/C++ 语言上写过网络开发,你大概率连 C/C++ 进行网络开发时为什么需要 select 和 epoll 都不知道,令人遗憾的是,我个人认为网上的大多数教程连这一点都没有解释清楚。

众所周知,Linux 操作系统内核的一大设计哲学就是“一切皆文件”,有人可能认为这句话的意思是一切资源都要挂载到根文件目录下,但这种理解不完全正确,这句话实际上是文件的定义,它是对“文件”这个词语是什么意思的解释,在 Linux 中,资源就是文件,文件就是资源,比如:

  1. 一个磁盘中的常规文件当然是文件
  2. 进程和操作系统状态是文件,因为它们是一个可访问的资源
  3. 网络连接是一个文件
  4. 标准输入和标准输出都是文件

那你可能会问,既然“文件”是一个纯粹的虚幻概念,将“一切”定义为文件的意义是什么呢,实际上这么做的意义就在于,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 存储在了内核内存里,跟具体是用的哪种数据结构来存储关系不大。

文章作者: 卡比三卖萌KirCute
本文链接:
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 卡比三卖萌KirCute的博客
语言特性杂谈
喜欢就支持一下吧