1. 为什么单靠阻塞I/O撑不住高并发场景很多刚接触网络编程的朋友写出来的第一个服务端程序大概长这样主线程accept一个连接然后开一个线程或者进程去read、处理、write处理完关闭。连接数一上来机器就扛不住了。我在早期做一个小型内部工具时就踩过这个坑——几十个长连接同时挂着CPU 没跑满内存先炸了因为每个线程默认栈空间就是 8MB 起步几百个线程光栈就吃掉几个 G。《Linux高性能服务器编程》第九章讲 I/O 复用本质上就是在回答一个问题当你要同时盯住成百上千个文件描述符但又不希望为每个描述符配一个线程时该怎么办答案是把“等待”这件事从“每个连接各自阻塞”变成“集中交给一个或少数几个线程去等”等到了再分发处理。这就是 I/O 复用的核心思想。这一章涉及三个主角select、poll、epoll。它们不是互相替代的简单关系而是有清晰的历史演进脉络和适用边界。很多人背下了“epoll 比 select 快”这句话但真到线上出问题的时候说不清楚为什么快、快在哪、什么情况下反而不能用 epoll。这篇内容我就按自己的理解把这一章拆开揉碎配上能直接跑的代码和踩坑记录帮你把 I/O 复用真正用起来。适合谁看写过 socket 但没系统用过 I/O 复用的用过 epoll 但说不清 ET 和 LT 区别的被select的 1024 限制坑过的以及想搞明白“为什么 Redis、Nginx 都选 epoll”的。下面从整体设计思路开始讲。2. I/O 复用的整体设计思路与三种机制选型2.1 从“阻塞等待”到“事件通知”的思维转变先建立一个心智模型。阻塞 I/O 就像你去一家只有一个服务员的餐厅点完菜你就坐在那儿干等服务员全程只服务你一个人。多线程阻塞 I/O 是给每桌都配一个服务员人多了服务员比客人还多成本爆炸。I/O 复用则是一个服务员一个线程同时照看所有桌子谁举手事件就绪就去谁那儿。这里的关键角色是内核。应用程序把“我关心哪些 fd 上的哪些事件”告诉内核然后阻塞在一个系统调用上。内核负责监视这些 fd一旦有事件发生就把“哪些 fd 就绪了”返回给应用。应用再针对就绪的 fd 做真正的读写。注意I/O 复用本身不负责读写数据它只负责告诉你“现在可以读了/写了”真正的read/write还是你自己调。这一点新手特别容易混淆以为epoll_wait返回了数据就到手了其实数据还在内核缓冲区里。2.2 select、poll、epoll 的选型逻辑三者解决的是同一个问题但实现机制和性能特征差别很大。选型时我一般看三个维度连接规模、活跃比例、跨平台需求。维度selectpollepoll最大 fd 数通常 1024FD_SETSIZE无硬限制受内存约束无硬限制数据结构位图 fd_setpollfd 数组红黑树 就绪链表每次调用开销全量拷贝 线性扫描 O(n)全量拷贝 线性扫描 O(n)仅注册一次O(1) 返回就绪触发模式仅 LT仅 LTLT ET跨平台好POSIX较好Linux 专属选型结论很直接Linux 上做高并发无脑 epoll需要跨平台或连接数很少几十个以内select/poll 够用。我见过有人在 Windows 上硬套 epoll 的封装库结果性能还不如原生 IOCP这就是没搞清适用边界。注意select 的 1024 限制不是内核强制的而是fd_set这个结构体用固定长度位图实现的FD_SETSIZE默认 1024。你可以改宏重新编译但那样就失去了可移植性而且性能依然差不推荐。2.3 为什么 epoll 能做到 O(1) 返回就绪这是面试高频问题也是理解 epoll 的关键。select 和 poll 每次调用都要把整个 fd 集合从用户态拷贝到内核态内核再线性遍历一遍看谁就绪返回后应用还得再遍历一遍找出就绪的 fd。连接数 n 越大这个 O(n) 越致命。epoll 把“注册”和“等待”拆成了两个系统调用epoll_ctl负责增删改关注的 fdepoll_wait只负责等。内核里用一棵红黑树维护所有注册的 fd增删改 O(log n)同时用一个就绪链表记录已经触发事件的 fd。epoll_wait直接返回就绪链表里的内容不需要遍历全部 fd。所以它的开销只和“活跃连接数”相关而不是“总连接数”。这就是为什么一万个连接里只有十个活跃时epoll 几乎无压力而 select 每次都要扫一万个。3. 核心细节解析与实操要点3.1 select 的 fd_set 位图操作与三个返回集合select 的函数原型是int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout)。这里有几个细节必须搞清楚。nfds不是 fd 的个数而是最大 fd 值加一。因为内核要遍历0到nfds-1的所有位。如果你最大 fd 是 5nfds 传 100内核就白扫 95 个位置。fd_set是位图用FD_ZERO清空、FD_SET置位、FD_CLR清除、FD_ISSET判断。最容易踩的坑是select 返回后readfds/writefds 会被内核修改成“就绪的集合”所以每次调用前都必须重新 FD_SET 一遍。我当年写循环时忘了重置导致第二次循环监听的 fd 全乱了排查了大半天。fd_set rset; FD_ZERO(rset); FD_SET(listenfd, rset); FD_SET(connfd, rset); int maxfd connfd listenfd ? connfd : listenfd; int n select(maxfd 1, rset, NULL, NULL, NULL); if (n 0) { if (FD_ISSET(listenfd, rset)) { /* 有新连接 */ } if (FD_ISSET(connfd, rset)) { /* 有数据可读 */ } }timeout传 NULL 表示永久阻塞传{0,0}表示非阻塞轮询传具体值表示最多等这么久。返回 0 表示超时-1 表示出错注意 EINTR 要处理。3.2 poll 的 pollfd 数组与事件掩码poll 用struct pollfd数组替代位图结构是{int fd; short events; short revents;}。events是你要监听的事件POLLIN、POLLOUT等revents是内核返回的就绪事件。poll 把“关注”和“就绪”分成两个字段所以不需要像 select 那样每次重置这是它比 select 好用的地方。struct pollfd fds[2]; fds[0].fd listenfd; fds[0].events POLLIN; fds[1].fd connfd; fds[1].events POLLIN; int n poll(fds, 2, -1); if (fds[0].revents POLLIN) { /* 新连接 */ } if (fds[1].revents POLLIN) { /* 可读 */ }poll 没有 1024 限制因为它用的是数组长度由你决定。但每次调用依然要把整个数组拷进内核并线性扫描所以连接数上万时性能依然不行。poll 的定位是“select 的无限制版”适合连接数中等几百到几千且不想折腾 epoll 的场景。3.3 epoll 的三个系统调用与红黑树机制epoll 的接口是三个函数int epoll_create(int size)创建一个 epoll 实例返回 epoll fd。size 参数在现代内核里已被忽略但必须传一个大于 0 的值。int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)op 有EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL。int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)返回就绪的事件数。struct epoll_event里有个data联合体通常用来存 fd 或者自定义指针这样epoll_wait返回后你能直接知道是哪个 fd 就绪不用再遍历查找。这个设计非常关键是 epoll 高效的原因之一。int epfd epoll_create(1); struct epoll_event ev; ev.events EPOLLIN; ev.data.fd listenfd; epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, 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; /* 处理 fd */ }内核里epoll 实例维护一棵红黑树存所有注册的 fd还有一个就绪链表。当某个 fd 上有事件时内核通过回调把它挂到就绪链表上。epoll_wait只需要检查就绪链表是否为空非空就返回。这就是 O(1) 的来源——它不遍历红黑树只取链表。3.4 LT 与 ET 的本质区别与选择水平触发LT和边缘触发ET是 epoll 最容易被问倒的点。LT默认只要缓冲区里还有数据epoll_wait就会一直返回这个 fd。你这次没读完下次还会通知你。行为上跟 poll 类似编程简单。ET只在状态变化时通知一次。比如从“无数据”变成“有数据”时通知一次之后你不读完它就不再通知了直到下次有新数据到达。ET 的效率更高因为减少了不必要的唤醒。但代价是你必须一次性把数据读干净否则剩下的数据会“卡”在缓冲区里没人处理。读干净的标准做法是循环read直到返回EAGAIN。/* ET 模式下的读循环 */ while (1) { ssize_t n read(fd, buf, sizeof(buf)); if (n -1) { if (errno EAGAIN || errno EWOULDBLOCK) break; /* 读完了 */ else { /* 真错误 */ break; } } else if (n 0) { /* 对端关闭 */ break; } else { /* 处理数据 */ } }注意ET 模式下监听 fd 必须设置成非阻塞O_NONBLOCK否则那个read循环会永久阻塞在最后一次读上。这是新手用 ET 最常见的翻车点。LT 和 ET 怎么选我的经验是业务逻辑简单、不想处理读循环用 LT追求极致性能、能保证正确读完用 ET。Nginx 默认用 ETRedis 用的是 LT它自己封装了 ae 事件循环。两者都能撑起高并发关键看实现质量。4. 实操过程与核心环节实现4.1 一个可运行的 epoll LT 回显服务器光看原理不够直接上一个能跑的。这是一个基于 epoll LT 模式的 TCP 回显服务器把收到的数据原样发回去。#include sys/epoll.h #include sys/socket.h #include netinet/in.h #include fcntl.h #include unistd.h #include string.h #include stdio.h #include errno.h #define MAX_EVENTS 1024 #define BUF_SIZE 4096 static int set_nonblocking(int fd) { int flags fcntl(fd, F_GETFL, 0); return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main() { int listenfd socket(AF_INET, SOCK_STREAM, 0); int opt 1; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(9000); bind(listenfd, (struct sockaddr *)addr, sizeof(addr)); listen(listenfd, 128); set_nonblocking(listenfd); int epfd epoll_create(1); struct epoll_event ev, events[MAX_EVENTS]; ev.events EPOLLIN; ev.data.fd listenfd; epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, ev); char buf[BUF_SIZE]; while (1) { int n epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i 0; i n; i) { int fd events[i].data.fd; if (fd listenfd) { struct sockaddr_in cli; socklen_t len sizeof(cli); int connfd accept(listenfd, (struct sockaddr *)cli, len); if (connfd 0) continue; set_nonblocking(connfd); ev.events EPOLLIN; ev.data.fd connfd; epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, ev); } else { ssize_t rn read(fd, buf, sizeof(buf)); if (rn 0) { epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); } else { write(fd, buf, rn); } } } } return 0; }编译gcc -o echo_server echo_server.c。跑起来后用nc 127.0.0.1 9000连上去发数据能看到原样返回。4.2 从 LT 改成 ET 的关键改动把上面的代码改成 ET只需要改两处注册事件时加EPOLLET读数据时改成循环读到EAGAIN。/* 注册时 */ ev.events EPOLLIN | EPOLLET; /* 读数据时 */ while (1) { ssize_t rn read(fd, buf, sizeof(buf)); if (rn 0) { write(fd, buf, rn); } else if (rn -1 (errno EAGAIN || errno EWOULDBLOCK)) { break; /* 数据读完了 */ } else { /* rn 0 对端关闭或真错误 */ epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); break; } }改完之后你会发现同样的压测流量下epoll_wait的返回次数明显减少因为不再有“数据没读完反复通知”的情况。但如果你忘了那个while循环客户端发一大坨数据时就会只收到一部分剩下的永远卡住——这就是 ET 的代价。4.3 参数计算maxevents 和缓冲区大小怎么定epoll_wait的maxevents决定一次最多返回多少个就绪事件。设太小一次处理不完得多次调用设太大栈上数组占内存。我的经验值是1024 到 4096配合一个足够大的events数组。如果活跃连接特别多可以设成 4096 甚至更大但要注意这个数组是在栈上分配的别搞到几 MB 导致栈溢出必要时用malloc放到堆上。读缓冲区BUF_SIZE也不是越大越好。4096 是个稳妥的默认值正好一个内存页。如果业务是传输大文件可以调到 64KB 甚至更大减少read调用次数。但要注意ET 模式下缓冲区大小不影响正确性因为你会循环读到 EAGAIN只影响系统调用次数。4.4 用 strace 观察三种机制的实际行为想真正理解差异用strace跟一下最直观。跑 select 版本时你会看到每次循环都有一次select系统调用参数里带着整个 fd_set。跑 epoll 版本时epoll_ctl只在连接建立和关闭时出现循环里只有epoll_wait而且参数很干净。strace -e traceepoll_wait,epoll_ctl,select,poll -p pid我实测过一个场景1000 个连接只有 5 个活跃。select 版本每秒的select调用里内核要扫 1000 个位epoll 版本epoll_wait几乎瞬间返回。CPU 占用差距能到十倍以上。这个实验建议你自己动手做一遍比看十篇文章都管用。5. 常见问题与排查技巧实录5.1 常见问题速查表现象可能原因排查方向select 返回 -1errnoEINTR被信号中断检查是否有信号处理循环重试select 监听不到新连接忘了每次重置 fd_set确认循环内重新 FD_ZERO/FD_SETepoll_wait 一直返回同一个 fdLT 模式数据没读完检查读逻辑或改用 ETET 模式下数据收不全没循环读到 EAGAIN加 while 循环ET 模式下 read 卡死fd 没设非阻塞用 fcntl 设 O_NONBLOCKepoll_ctl 返回 EEXIST重复 ADD 同一个 fd先 DEL 再 ADD或改 MOD连接数上万后内存暴涨每个连接缓冲区太大缩小 BUF_SIZE用连接池客户端断开后 fd 泄漏没处理 read 返回 0返回 0 时 DEL 并 close5.2 独家避坑技巧技巧一EPOLLOUT 不要常驻监听。很多人一上来就把EPOLLIN | EPOLLOUT都注册上结果epoll_wait疯狂返回可写事件CPU 直接跑满。正确做法是默认只监听 EPOLLIN需要写数据且一次没写完时才临时加上 EPOLLOUT写完立刻去掉。因为 socket 大部分时间都是可写的常驻监听 EPOLLOUT 等于空转。技巧二accept 也要循环。在 ET 模式下如果同时来了多个连接accept一次只取一个剩下的可能不再触发通知。所以监听 fd 用 ET 时accept也要循环到EAGAIN。LT 模式下虽然不会丢但循环 accept 能减少epoll_wait次数也是好习惯。技巧三小心 EPOLLRDHUP。对端关闭连接时如果只监听 EPOLLINread返回 0 你才知道。加上EPOLLRDHUP可以在对端半关闭时更早收到通知处理更及时。这个事件在长连接场景特别有用。技巧四epoll 实例的 fd 也要记得关。epoll_create返回的也是一个 fd程序退出或不再使用时close掉否则会泄漏。虽然进程退出时内核会回收但长期运行的服务里这是隐患。技巧五ET 模式下 write 也可能需要循环。虽然不常见但如果发送缓冲区满了write会返回部分写入或 EAGAIN。ET 模式下你需要注册 EPOLLOUT 并在可写时继续写剩余部分。这个逻辑比读复杂建议封装成一个发送缓冲区队列。5.3 一个真实的排查案例之前有个服务压测到 8000 连接时开始出现“部分客户端收不到响应”。日志显示epoll_wait正常返回read也读到了数据但write之后客户端没收到。排查发现用的是 ET 模式write时发送缓冲区满了返回了部分写入代码没处理剩余部分直接丢弃了。改成“写不完就注册 EPOLLOUT在可写事件里继续写”之后问题消失。这个坑的教训是ET 模式下读和写都必须假设“一次操作不一定完成”。读要循环到 EAGAIN写要处理部分写入。任何“一次 read/write 就搞定”的假设在 ET 下都是危险的。6. 三种机制的横向对比与生产环境选型建议6.1 性能对比的实测数据参考我在一台 4 核 8G 的测试机上做过简单对比服务端只做回显客户端用工具模拟长连接每个连接每秒发一次小包。结果大致如下仅作趋势参考具体数值因环境而异连接数select CPUpoll CPUepoll LT CPUepoll ET CPU1002%2%1%1%100018%17%3%2%5000无法支撑75%8%5%10000无法支撑无法支撑15%9%可以看到连接数上去之后select 和 poll 的 CPU 占用是线性增长的而 epoll 增长平缓得多。ET 比 LT 略优因为减少了无效唤醒。这个差距在活跃比例低的时候更明显。6.2 生产环境的选型决策树实际项目里怎么选我总结了一个简单的决策路径是不是 Linux 专属是进第 2 步需要跨平台用 pollselect 限制太多。连接数会不会超过 1000会用 epoll不会poll 也够但 epoll 没坏处。团队能不能驾驭 ET能用 ET 追求性能不能或业务逻辑复杂用 LT稳。有没有现成的网络库有如 libevent、libuv、muduo直接用别自己造轮子。我个人的偏好是新项目一律 epoll LT 起步压测发现瓶颈再考虑 ET。LT 的编程模型简单出 bug 的概率低而大多数业务的瓶颈根本不在事件通知这一层。过早优化成 ET反而容易引入难排查的问题。6.3 什么时候不该用 epollepoll 不是银弹。有几种情况我会避开它连接数很少几十个且都是活跃的这时 select/poll 的开销可以忽略用它们代码更简单跨平台也方便。需要跨平台epoll 是 Linux 专属Windows 要用 IOCPmacOS 用 kqueue。如果项目要跨平台用 libevent 这类封装库统一接口。文件描述符是普通文件epoll 对普通文件的就绪判断总是返回就绪因为文件永远可读没有意义。epoll 主要针对 socket、管道这类会阻塞的 fd。理解这些边界比记住“epoll 最快”重要得多。技术选型从来不是选最强的而是选最合适的。7. 从这一章延伸出去的学习路径把第九章吃透之后你会发现它其实是通往更高阶内容的入口。往下走有几个方向值得深入。第一个方向是反应堆模式Reactor。epoll 只是提供了事件通知能力怎么组织代码结构、怎么分发事件、怎么管理连接生命周期这些是 Reactor 模式要解决的。muduo 网络库就是基于 Reactor 的经典实现主线程负责 accept多个 sub-reactor 线程各自跑一个 epoll 循环处理读写。理解了这一层你就能看懂大多数高性能网络库的骨架。第二个方向是定时器管理。实际服务里经常需要处理超时连接、心跳检测。epoll 的timeout参数只能做粗粒度控制精细的定时需求要用时间轮或最小堆来管理。这也是 muduo 里专门有一章讲的内容。第三个方向是多线程与锁。单线程 epoll 再快也有上限多核机器上要用多线程扩展。但多线程下 fd 的增删改涉及并发需要仔细设计锁的粒度或者用“一个连接固定绑定一个线程”的方式避免竞争。我自己的学习路径是先把单线程 epoll 写熟能独立实现一个稳定的回显服务器然后读 muduo 源码理解 Reactor 的分层最后动手改造加上定时器和多线程。每一步都动手写代码光看是学不会的。第九章的代码示例建议全部敲一遍尤其是那个 ET 模式的读循环敲过和看过完全是两回事。最后分享一个我自己的小习惯每次学完一个系统调用我都会用strace跟一遍自己的程序看看内核层面到底发生了什么。这个习惯帮我发现了无数“想当然”的错误。I/O 复用这一块内核的行为和你的直觉经常不一致多观察、多验证比背结论靠谱得多。