如果你写过网络服务器,迟早会被这么一个问题卡住:一个进程到底怎么同时照顾几百上千个连接?很多初学者第一反应是开线程,一个连接一个线程,结果连接一多,CPU先飙了,内存也吃不消。这个问题的标准答案,就是 I/O 多路复用。它让一个线程同时盯着多个文件描述符,哪个 fd 上有数据可读、可写,就处理哪个,而不是傻等某一个连接。这篇文章我想从底层原理讲到实战,把 select、poll、epoll 这三兄弟掰开揉碎对比一遍,然后用 epoll 手写一个高并发回声服务器,最后聊聊那些文档里不会写的坑。适合正在学网络编程、准备面试,或者想理解高并发框架底层逻辑的朋友参考。
1. 先搞清楚:I/O 多路复用到底解决了什么问题
1.1 从阻塞 I/O 的限制说起
一个传统的服务端程序,核心代码往往就是 socket、bind、listen、accept,然后进入 while 循环:accept 阻塞等待新连接,拿到连接后又阻塞地 read 等待对方数据。问题出在哪儿?如果第一个客户端连上之后一直不说话,read 就一直卡在那里,第二个客户端即使已经连上了,也没人 accept,更别提处理数据了。
这就像只有一个客服窗口,第一个顾客进来之后把问题问了一半就停住不走,后面排队的顾客全部被堵死。阻塞式网络模型天然就是“一对一”的,处理完一个连接才能处理下一个。早期网络环境连接数少,问题不明显,但到了高并发场景,这种模型完全撑不住。
要想让一个进程同时服务大量连接,核心就是要打破“一个人占住一个位置就卡死整条流水线”的僵局。I/O 多路复用正是为了解决这个僵局而生的:进程先把自己的关注列表交给内核,然后阻塞等待内核告知“哪个连接上有事发生”。注意,这里的“多路”指的是一堆 socket 连接,“复用”则是指用一个线程统一管理这堆连接,而不是一个连接对应一个线程。
1.2 多线程方案为什么不是银弹
最容易想到的解决办法是:来一个连接就开一个线程,连接被阻塞了就让它阻塞在自己的线程里,反正互不影响。这个方法在连接数几百个的时候确实能跑,但再往上走就难受了。
每条线程需要独立的栈空间,Linux 上默认的线程栈大小往往是 8MB,即使按虚拟内存算,几千条线程也是几十 GB 级别的地址空间消耗。更麻烦的是上下文切换:线程数量一多,CPU 时间大量花在保存、恢复寄存器上,真正干活的时间比例就下来了。再加上锁竞争、线程创建销毁的开销,一个连接一个线程的模型在面对 C10K 问题时基本是死路。
还有个不可忽略的点:大量线程阻塞在各自的 read 上,每次有数据进来,操作系统要唤醒对应线程;如果同一时间有一千个连接有数据,就有一千次唤醒和调度。这种“无差别唤醒”本身就是巨大的性能浪费。所以高并发场景下,大家最终都会回到同一个思路上来:用少量线程,甚至一个线程,去管理大量连接,只处理那些真正有事件发生的连接。
1.3 “让内核替我们盯着”的非阻塞思路
如果把 socket 改成非阻塞模式,read 不会傻等,没有数据就立刻返回错误。看起来很美好,但问题立刻转到应用层:你怎么知道该对哪个 socket 调 read?唯一简单的办法就是遍历所有连接,挨个尝试读一次。假如有一万个连接,大部分时间其实没有数据,但你仍然要一万一万地轮询,每次都要走一遍系统调用,CPU 全浪费在“确认没事发生”上了。
所以理想的机制,是内核提供一种“批处理订阅”能力:应用把自己关心的 fd 一次性注册给内核,然后内核负责监视这些 fd,一旦某个 fd 有读、写或异常事件,内核主动通知应用。应用只需要在通知里取事件、处理事件。这才是 I/O 多路复用的本质,也解释了为什么它比“多线程 + 阻塞”和“非阻塞 + 轮询”都更适合高并发。
理解了这一点,再看 select、poll、epoll 的历史演进就顺了:它们都是“让内核帮我们盯着 fd”的方案,差别在于内核的实现方式、可扩展性和性能。
2. select、poll、epoll:三种监视方式的前世今生
2.1 select:用一个位图赌每次轮询
select 是最老的多路复用接口,核心思路是让人维护三个 fd_set 位图,分别表示可读、可写、异常事件。调用 select 前,把关心的 fd 在位图里置 1;调用时,把这三个位图从用户态拷贝到内核态;内核遍历所有 fd,发现有事件就保留对应位;返回后,用户再用 FD_ISSET 逐个检查哪个 fd 还在位图里。
这套逻辑有两个硬伤。第一是 fd 数量上限:FD_SETSIZE 通常定义成 1024,也就是说 select 能同时监视的 fd 绝大多数情况下最多也就 1024 个。第二个问题更致命:每次调用 select,不管你有没有事件,内核都要把三个位图整体拷一遍,然后全部 fd 从头到尾扫一遍,复杂度是 O(n)。连接越多,空扫的浪费越大。
还有一个特别容易踩的坑:fd_set 是值传递语义,内核会修改位图,所以每次调用 select 前都必须重新把所有 fd 加到集合里。很多人第一次写多路复用代码,在循环里只初始化一次 fd_set,结果第二次 select 之后集合被内核改了,后面的事件全乱套,排查半天才发现是没有重新赋值。
2.2 poll:去掉上限,但内核仍然“全量扫”
poll 是 select 的改进版,核心改动是把 fd_set 换成了一个 pollfd 数组,每个数组项里有 fd、事件掩码和返回事件掩码。用户用 events 字段声明关注的事件,内核用 revents 字段返回实际发生的事件,互不干扰,所以不需要像 select 那样每次重新构造集合。
另外,poll 没有 1024 的连接上限,只要内存装得下,监视几万个 fd 理论上也没问题。看起来解决了 select 的痛点,但性能瓶颈还在:每次 poll 调用,内核还是要遍历这个数组,检查每个 fd 的状态。连接数到了几千、几万以后,一次 poll 的 CPU 开销会跟着线性上升,而且每次 poll 都要把整个 pollfd 数组从用户态拷到内核态,这部分拷贝开销也不小。
很多人以为有了 poll 就可以放心干高并发,实际上它在几千连接的时候已经明显吃力。我自己曾在一台普通服务器上做过对比,同样一万个空闲连接,poll 的一次空调用扫描时间比事件驱动的方案高出好几个数量级。所以 poll 更像是一个“修正了 select 表面问题”的过渡方案,并不是高并发的终点。
2.3 epoll:内核帮你维护监视名单,来了事件才通知
epoll 是 Linux 上的事件驱动方案,也是目前主流高并发服务器的底层基础。它从根本上改变了工作方式:不再每次调用都全量扫描所有 fd,而是由内核维护一棵红黑树来管理用户注册的 fd,同时用一条就绪链表保存真正发生了事件的 fd。
关键点在于“注册”和“通知”是分离的。注册用的 epoll_ctl 只在 fd 状态变化时调用,比如新连接加入、连接关闭、需要修改关注事件;而主循环里的 epoll_wait 只负责等待并返回就绪链表。一个 fd 上发生事件时,内核通过回调机制把它挂到就绪链表上,epoll_wait 返回时只需要把“已经就绪的 fd 列表”拷贝给用户,不需要再遍历所有的 fd。这就把复杂度从 O(n) 降到了近似 O(就绪事件数),连接再多,只要同时就绪的事件不多,epoll_wait 的代价就非常小。
同时,epoll 还有一个额外优势:它返回的是“哪些 fd 就绪了”的结果数组,用户直接遍历结果处理即可,不需要像 select 那样对所有 fd 做实打实的轮询检查。所以高并发场景下,epoll 几乎成了 Linux 上的默认答案。
2.4 一张表看三者的核心差异
对比项 | select | poll | epoll 数据结构 | fd_set 位图 | pollfd 数组 | 红黑树 + 就绪链表 fd 上限 | 受 FD_SETSIZE 限制,通常 1024 | 无明确上限 | 无明确上限 每次调用拷贝 | 拷贝全部 fd_set | 拷贝全部 pollfd 数组 | 只拷贝就绪事件 内核扫描范围 | 全部 fd | 全部 fd | 只处理就绪 fd 触发方式 | 水平触发 | 水平触发 | 支持水平触发和边沿触发 主要开销 | 随 fd 数线性增长 | 随 fd 数线性增长 | 与活跃连接数相关这个差异直接决定了选型:如果只是几十个连接,select 完全够用,代码也简单;如果数量到了几千但业务逻辑简单,poll 可以撑一撑;想在 Linux 上做上万甚至上百万并发,epoll 基本是唯一务实的选择。
3. 手写一个基于 epoll 的回声服务器
3.1 三个关键 API 和两个容易看漏的细节
epoll 的核心调用只有三个:epoll_create1、epoll_ctl、epoll_wait。
epoll_create1 负责创建 epoll 实例,参数一般传 EPOLL_CLOEXEC,作用是让 epoll 文件描述符在 fork 之后自动关闭,避免子进程意外继承导致 fd 泄漏。很多人用 epoll_create 但忽略了这一点,我自己就在一个常驻后台服务里踩过这个坑,跑了一段时间后发现 fd 数缓慢上涨,排查好久才定位到是 fork 的子进程没有清理 epoll fd。
epoll_ctl 负责增删改。真正决定性能的不是 epoll_wait,而是 epoll_ctl 的调用频率。如果每次有事件就反复 ADD 和 DEL,红黑树的插入删除也会变成瓶颈。合理做法是在连接建立时 ADD 一次,连接关闭时 DEL 一次,平时只需要用 EPOLL_CTL_MOD 修改关注的读、写事件。
epoll_wait 负责等待事件。timeout 参数是毫秒,设为 -1 表示永久阻塞;设为 0 表示立即返回,适合需要非阻塞检查的场景;设为具体数值可以支持定时任务。很多人把它当成常规阻塞调用,却忽略了它在中途被信号打断的情况,返回值可能是 -1 且 errno 为 EINTR,需要继续等待。
3.2 C 语言核心实现:每行代码背后的原因
下面是一个用 C 语言实现的简易回声服务器,只保留了核心逻辑,你可以直接拿来跑:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <fcntl.h> #include <errno.h> #include <sys/epoll.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #define MAX_EVENTS 1024 #define BUF_SIZE 4096 static void set_nonblocking(int fd) { int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main() { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); return 1; } int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(9000); addr.sin_addr.s_addr = htonl(INADDR_ANY); if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; } if (listen(listen_fd, 1024) < 0) { perror("listen"); return 1; } set_nonblocking(listen_fd); int epfd = epoll_create1(EPOLL_CLOEXEC); if (epfd < 0) { perror("epoll_create1"); return 1; } struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev); struct epoll_event events[MAX_EVENTS]; char buf[BUF_SIZE]; while (1) { int n = epoll_wait(epfd, events, MAX_EVENTS, -1); if (n < 0) { if (errno == EINTR) { continue; } perror("epoll_wait"); break; } for (int i = 0; i < n; i++) { int fd = events[i].data.fd; if (fd == listen_fd) { while (1) { int client_fd = accept(listen_fd, NULL, NULL); if (client_fd < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) { break; } perror("accept"); break; } set_nonblocking(client_fd); ev.events = EPOLLIN; ev.data.fd = client_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev); } } else { ssize_t rn = read(fd, buf, sizeof(buf)); if (rn <= 0) { if (rn < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { continue; } epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); continue; } ssize_t wn = write(fd, buf, rn); if (wn < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) { continue; } epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); } } } } close(epfd); close(listen_fd); return 0; }这里有几个设计点值得解释:
- listen_fd 本身也设成非阻塞,同时用 while 循环 accept,直到返回 EAGAIN。原因是 accept 的可读事件只能说明“当时至少有一个连接可以接受”,如果有多个连接排队,系统只会拿到第一个,剩下的交由下一次事件触发效率是够的,但既然都读到这里,直接用 while 循环把当前能 accept 的都处理掉,可以减少内核事件重发和无效唤醒。
- 客户端 fd 一 accept 就立刻设成非阻塞,因为后续 read、write 都可能因缓冲区满、无数据而返回 EAGAIN,如果不是非阻塞,某一方不发送数据时线程就卡住了。
- read 返回 0 表示对方关闭连接,这种情况必须从 epoll 里删掉 fd 并 close,否则会一直触发可读事件,形成死循环。这个判断漏掉是新手最容易犯的错误。
- 这里没有处理 EPOLLOUT,因为回声场景几乎不需要主动塞数据;真正要推送消息的业务,需要把写事件和发送队列配合使用,避免一次 write 没写完导致数据丢失。
3.3 为什么没有用 SIGPIPE 处理
默认情况下,向一个已经关闭的连接写数据,进程会收到 SIGPIPE 信号,如果不处理,进程会直接退出。在高并发服务器里,这种情况非常常见:客户端断开了,但服务端还没来得及关闭 fd,然后写事件触发了。所以线上代码里一般都要设置 signal(SIGPIPE, SIG_IGN),或者用 send 的 MSG_NOSIGNAL 选项。上面 demo 里没加,是因为它在主线程直接处理,即使收到 SIGPIPE 也会终止,不适合久跑,你作为练习项目跑完就关,问题不大;真正要部署,第一件事就得把 SIGPIPE 屏蔽掉。
3.4 用 Python selectors 快速搭建原型
如果 C 语言不是你的主要语言,或者只是想验证 epoll 的行为,可以考虑 Python 的 selectors 模块。它把 epoll、poll、select 封装成了统一接口,默认会用系统里最好的实现,在 Linux 上就是 epoll。
import selectors import socket sel = selectors.DefaultSelector() def accept_conn(sock): conn, _ = sock.accept() conn.setblocking(False) sel.register(conn, selectors.EVENT_READ, handle_read) def handle_read(conn): data = conn.recv(4096) if data: conn.sendall(data) else: sel.unregister(conn) conn.close() server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(("0.0.0.0", 9000)) server.listen(1024) server.setblocking(False) sel.register(server, selectors.EVENT_READ, accept_conn) while True: events = sel.select(timeout=None) for key, mask in events: key.data(key.fileobj)这段代码思路和上面 C 版本完全一致:注册监听 socket,新连接再注册客户端 fd,数据到了就回显。select 出来的是一个事件列表,事件发生时通过回调分发到对应处理函数。原型阶段用 Python 验证逻辑,生产再用 C 或 C++ 重新实现,是很多项目团队的常规节奏。
4. 实战中绕不开的坑:LT、ET、惊群与并发模型
4.1 LT 和 ET 怎么选
epoll 支持两种触发模式,长见识的选择题在这里才真正出现。
水平触发(Level-Triggered,简称 LT)下,只要一个事件没有处理完,内核就会反复提醒你。比如缓冲区里有 100 字节数据,你只读了 20 字节,下次调用 epoll_wait 时该 fd 还是会出现在就绪列表里,提醒你继续读。边沿触发(Edge-Triggered,简称 ET)则只在状态变化的那一刻通知一次,之后即使缓冲区还有数据,也不会再通知你。
打个比方,LT 像客服每隔一段时间就打电话催你:“有包裹到驿站了,记得取”,你会不断收到提醒直到取完;ET 像驿站只在包裹第一次入柜时发一条短信,之后不管你怎么忘记,它都不会再提醒。ET 的问题是,如果收到通知后没有把数据一次读完,剩下的数据可能永远漏掉。所以 ET 模式下,socket 必须是非阻塞的,并且读操作要循环执行,一直读到 read 返回 EAGAIN 为止;写操作也要一直写完,包括处理部分写和缓冲区满的情况。
基于我自己的经验,初学者和大多数业务场景直接用 LT 就好。LT 的语义符合直觉,很多边缘情况都不容易出 bug;ET 省的是“重复通知”的开销,但前提是你在代码里真的用非阻塞 + 循环读把这点优势换回来。如果业务逻辑默认读到一点数据就处理,然后依赖内核再次通知,那 LT 显然更合适。
4.2 accept 惊群与 EPOLLEXCLUSIVE
当多个线程或进程同时阻塞在同一个 epoll 实例的 epoll_wait 上,一个新的连接事件到达时,可能出现多个线程同时被唤醒,但最终只有一个线程能成功 accept,其他线程只能空跑一圈。这就是经典的惊群现象。
内核针对这个问题提供了 EPOLLEXCLUSIVE 选项,在注册事件时加上这个标记,内核会尽量只唤醒一个等待者来处理该事件,降低惊群概率。不过要注意,EPOLLEXCLUSIVE 是给“多线程共享同一个 epoll fd”的场景用的,如果你已经使用主线程单独 accept,再把 fd 分发给 worker,就不需要它。
另一种更常见的做法是完全不用共享 epoll:主线程负责 accept,然后把新的 fd 直接交给某个 worker 线程,worker 有自己独立的 epoll 实例。这样每个 worker 只处理属于自己的连接,不仅能避免惊群,还天然避免多线程并发操作同一个 fd 的问题。代价是编码更重一些,需要设计好线程模型和 fd 传递方式。
4.3 EPOLLONESHOT:保证同一时刻只有一个线程在碰这个 fd
在 Reactor 模式里,事件循环通常会把读到的数据交给线程池处理。问题来了:一个 fd 可能同时有读事件和写事件,或者业务处理较慢还没来得及重新注册,下一次读事件又来了。如果多个线程同时对同一个 fd 执行读或写,数据顺序会乱,甚至引发竞态条件。
EPOLLONESHOT 的作用是:该 fd 被唤醒一次后,除非你显式重新注册,否则它不会再触发事件。这保证了同一 fd 同一时刻只被一个线程处理。处理完业务后,再用 epoll_ctl 的 EPOLL_CTL_MOD 重新把 fd 加回来。使用 EPOLLONESHOT 时,一定要在重新注册前确认所有读写都结束,否则 fd 会一直处于休眠状态,连接就像卡死了一样。
4.4 线程模型要避开的一个致命习惯
基于 epoll 的 I/O 密集服务,最常见的问题不是 epoll 本身,而是事件处理里面藏了“阻塞”操作。比如在 epoll_wait 返回后的回调里执行 sleep、同步读磁盘、请求下游数据库,这些操作会阻塞当前线程。如果只有一条事件循环线程,这一个连接卡住,其他所有连接全部停摆。
正确做法是:事件循环只负责收发数据和解析协议,把耗时业务放到独立线程池。不要让任何一个慢操作进入事件循环线程的同步路径,这是 epoll 高并发架构的原则性约束,也是很多号称“用了 epoll 但性能还是上不去”的案例背后真正的原因。
5. 常见问题排查实录
5.1 为什么我的服务同时只能处理那么点连接
先检查系统对文件描述符数量的限制。Linux 下用 ulimit -n 查看,默认可能只有 1024。意味着同时最多只能打开 1024 个 fd,其中还要算上标准输入输出和监听 socket。在高并发测试中,第一个症状通常就是 accept 返回 EMFILE。解决办法是调高 ulimit,或在系统配置里把 nofile 的上限调大,同时确认进程的实际限制。一个容易漏掉的点:即使主进程调高了限制,如果服务 fork 了子进程,子进程也会继承这个限制,记得一起检查。
5.2 数据收到了但业务没触发,问题大多出在 ET 漏读
客户端发来几 KB 数据,服务端第一次触发可读事件时只读了一点,剩余数据还在缓冲区里。LT 模式下内核会继续提醒,所以没问题;ET 模式下由于只通知一次,如果不循环读,剩下的数据就永远躺在缓冲区里,直到对端再次发送新的数据才可能继续触发。排查时看两处:一是注册事件时是否用了 EPOLLET,二是读代码里是否在 read 返回 EAGAIN 之前一直循环。不要只在代码上怀疑内核丢事件,绝大多数所谓“丢事件”都是 ET 模式自己没读完。
5.3 epoll_wait 返回 0 是出 Bug 了吗
不是。timeout 时间到了,没有任何事件发生,epoll_wait 就会返回 0,这是正常行为。真正要小心的是另一种情况:timeout 设置成了 0,返回 0 就成了“立刻空转一次”,如果主循环加了个忙循环,CPU 会瞬间打满。很多高性能服务希望做定时检测,应该用合理的超时时间,比如 100ms 或 1s,然后在返回后检查系统时间,判断是否有定时任务需要执行。
5.4 定位 CPU 100% 的实用方法
epoll 模型下 CPU 打满,往往不是系统负载高,而是某些 fd 被反复标记为就绪。最常见的原因是:fd 已经关闭,但没有从 epoll 中删除,内核以为它还可读,于是每轮都通知;或者 read 循环没有正确退出,EAGAIN 没有被识别。排查时可以在事件处理开头打印 fd 和事件类型,同时记录该 fd 最近几秒被触发的次数。如果一个 fd 每秒触发几千次且 read 都返回 EAGAIN,那就是典型的“事件风暴”。另外记得检查是否忘了对 socket 设置非阻塞,阻塞 fd 在某些异常情况下也会触发非常奇怪的调度行为。
6. 从踩坑到顺手:多年使用 epoll 的一些心得
我个人现在的习惯是,凡是刚开始做的新项目,一律先用 LT 模式,配合 epoll_create1 和清晰的事件分发,把代码跑稳,再把热点路径改成 ET 做性能优化。原因很简单,LT 模式的代码更容易被后来接手的人理解,也不容易因为漏读数据而产生诡异的线上问题。真正面对百万级连接且单连接数据吞吐偏大的场景时,再仔细处理 ET 的循环读写,收益会非常明显。
另外一个被低估的技巧是,把 epoll 的 timeout 用起来,让它兼职做定时器。比如 timeout 设置为 200ms,每次 epoll_wait 返回后检查当前时间,发现超时连接就主动清理。这样既不用单独起线程做心跳,又能减少无效唤醒。虽然是老办法,但实现简单、效果直接,很多框架的定时器底层都是这个概念。
最后分享一个小建议:排查 epoll 问题时,别急着怀疑内核,先把你自己的事件处理逻辑彻底读一遍。按我的观察,九成以上的问题都出在用户态代码——ET 没读干净、EPOLLONESHOT 忘了重新注册、关了 fd 没删除注册、EAGAIN 判断错,这些都踩过。理解 epoll 的触发语义,比记住 API 参数重要得多。把这几个模型装进脑子里,再写高并发服务,就能少走很多弯路。