先说结论:

select 是早期的 IO 多路复用方案,小规模连接时简单有效,但在高并发场景下性能瓶颈明显;epoll 是 Linux 专门为高并发设计的事件机制,通过“只关注活跃连接”大幅提升效率;Nginx 在 Linux 上之所以表现更强,很大程度就是因为它把 epoll 这一套机制用到了极致。


一、什么是 IO 多路复用

在做服务端开发时,我一开始也会困惑:当服务器同时维护大量 socket 连接时,CPU 并不知道哪个连接有数据、哪个可以写、哪个已经断开,如果只能一个个轮询检查,成本是很高的。

操作系统提供的解决方案是,把一批连接交给内核统一管理,当某个连接就绪时再通知应用程序,这就是 IO 多路复用(I/O Multiplexing)。从演进来看,主要经历了 select、poll,再到 epoll,其中 epoll 是目前 Linux 上最核心的方案。


二、select 是什么

select 的使用方式其实很直观,本质就是“把一堆 fd 交给内核,让它帮你筛选”。

典型调用形式大致是:

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);

使用时,一般会这样做:

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

select(maxfd + 1, &readfds, NULL, NULL, NULL);

if (FD_ISSET(sockfd, &readfds)) {
    // 有数据可读
}

我自己的理解是:应用程序每次都把一批连接“重新告诉”内核,让内核帮你判断哪些 fd 可读、可写或异常,然后再逐个处理。


三、select 的问题在哪里

在连接数一上来之后,select 的问题就会比较明显。

首先是重复提交集合。比如监听 10000 个连接,每次调用 select,都要把这 10000 个 fd 从用户态拷贝到内核态,这个过程是持续发生的。

其次是全量扫描。即使只有几个连接活跃,程序仍然需要遍历整个 fd_set 才能找出来,本质是 O(n) 的开销。

再就是数量限制,很多系统默认 FD_SETSIZE 是 1024,这意味着 select 天然不适合处理更大规模的连接。

这些点叠加在一起,使它更适合“能用”,但不适合“高并发”。


四、epoll 是什么

epoll 可以理解为把 select 的两件低效事情拆掉了:不再重复提交全集合,也不再全量扫描。

它把连接的维护放在内核中,应用程序只需要关心“哪些连接发生了事件”。


五、epoll 的基本用法

在代码层面,epoll 的流程会更清晰一些,我自己一般按这三步来理解。

第一步,创建实例:

int epfd = epoll_create(1);

第二步,注册事件:

struct epoll_event ev;
ev.events = EPOLLIN;      // 关注可读事件
ev.data.fd = sockfd;

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

第三步,等待事件:

struct epoll_event events[1024];
int n = epoll_wait(epfd, events, 1024, -1);

for (int i = 0; i < n; i++) {
    int fd = events[i].data.fd;
    // 直接处理活跃连接
}

这里有一个很关键的感受:epoll_wait 返回的已经是“活跃连接列表”,不需要再自己去扫描全部 fd,这一点在连接数大时差异非常明显。


六、epoll 的优势

对比下来,我更习惯从实际运行角度去看它的优势。

一是不再重复传递 fd 集合,select 每次都要做拷贝,而 epoll 注册一次就够了。

二是不做无意义遍历,select 需要遍历所有连接找活跃的,而 epoll 直接把活跃的给你。

三是特别适合“连接很多但活跃很少”的场景,比如 WebSocket、Keep-Alive、长连接 API,这类场景本质是“稀疏活跃”,正好符合 epoll 的设计思路。


七、LT 和 ET

epoll 还有一个很重要的点是触发模式。

LT(水平触发)是默认模式,只要缓冲区还有数据,就会一直通知:

ev.events = EPOLLIN;  // LT 模式(默认)

这种模式比较稳,哪怕你一次没读完,下次还会继续提醒。

ET(边缘触发)则只在状态变化时通知一次:

ev.events = EPOLLIN | EPOLLET;  // ET 模式

这种模式要求必须这样读取:

while (read(fd, buf, sizeof(buf)) > 0) {
    // 一直读,直到返回 EAGAIN
}

如果没读干净,后续可能不会再通知,这也是它更高性能但更容易出错的原因。


八、为什么 Nginx 在 Linux 上更强

这一点我在学习 Nginx 时感受很明显,它的性能并不是单一优化,而是和 Linux 的能力深度绑定。

Linux 提供了 epoll、sendfile、aio、reuseport、zero-copy 以及成熟的 TCP 栈调优能力,这些能力组合在一起,让 Nginx 能够在高并发场景下保持非常好的性能表现。


九、具体落在什么地方

从实际效果来看,主要体现在几个点。

首先是 epoll 带来的高效事件模型,可以支撑海量连接。

其次是 sendfile 的使用,比如:

sendfile(out_fd, in_fd, NULL, size);

在返回静态资源(如 js、图片、视频)时,可以减少用户态与内核态之间的数据拷贝。

再就是 TCP 参数调优,例如 backlog、keepalive、somaxconn、reuseport、TIME_WAIT 回收策略等,可以根据业务场景灵活配置。

这些能力叠加起来,才是 Nginx 在 Linux 上表现优秀的原因。


十一、最后总结

select 的问题在于:每次调用需要复制 fd 集合,并进行全量扫描,同时存在连接数限制。

epoll 的优势在于:由内核维护连接集合,只返回活跃连接,减少拷贝与遍历,适合高并发长连接场景。

Nginx 在 Linux 上更强,本质是因为它充分利用了 epoll 和一整套内核级优化能力。

整体看下来,我自己的理解是:select 更像是“解决有没有”的方案,而 epoll 是“解决好不好、快不快”的方案。

也正因为这一点,在实际生产中,高并发服务基本都会基于 epoll 构建,而 Nginx 在 Linux 上能够长期保持高性能,其实也是顺理成章的事情。