
前阵子有个做服务端的同事问我并发连接起来之后到底要不要给每条连接都开一个线程去读数据。这个问题我八年前第一次写聊天室服务器时也纠结过后来被现实狠狠教育了一轮连接一多线程栈内存和上下文切换分分钟教做人。Linux网络编程里真正成熟的做法是用“多路转接I/O”让一个进程同时盯着成千上百个套接字其中出场率最高、也最容易被拿来当面试题开场的就是 select 和 poll 这两兄弟。这篇东西我会把 select、poll 的底层逻辑、函数参数、可复现的服务器代码、实战中的坑一次性讲明白。尤其是那些在教科书上写得很含蓄、但实际开发中一定会踩的细节比如 select 会把 timeout 改掉、poll 的数组空洞怎么处理、read 返回 0 和 -1 到底代表什么。如果你是刚接触 Linux socket 编程的初学者这篇文章可以当你的第二份教材来看如果你已经写过一些网络服务那直接跳到第四节看踩坑实录就行保证有收获。1. 为什么需要多路转接I/O——从阻塞式模型说起1.1 一个线程啃一个连接看起来简单死起来壮烈在 Java 或者 Python 里写网络程序最常见的直觉方案是“每个客户端来一个线程线程里阻塞读数据”。听起来很合理代码也简单// 伪代码示意每来一个连接pthread_create 一个线程去处理 while (1) { int client_fd accept(listen_fd, ...); pthread_create(tid, NULL, handle_client, client_fd); } void *handle_client(void *arg) { int fd *(int *)arg; while ((n read(fd, buf, sizeof(buf))) 0) { write(fd, buf, n); } close(fd); }但这套方案在连接量小的时候完全没问题问题出在连接量上去之后。每个线程默认的栈空间就是 8MB 左右线程开销不是免费的。一台 2C4G 的云主机开个三五百个线程虽然能跑但上下文切换带来的 CPU 浪费会非常明显等你试图撑到几千个连接系统先崩给你看。而且阻塞 read 会让线程一直挂在那绝大多数连接其实都是“没数据可读”的状态线程资源大量空转。这也是 C10K 问题里最关键的一个观察点并发模型的核心不是“怎么让每个连接都有人服务”而是“怎么让一个服务者能同时服务大量连接”。这时候就需要一种机制让内核代替我们盯着所有连接的文件描述符哪个就绪了再告诉我们去处理。这就是 I/O 多路转接I/O Multiplexing。1.2 多路转接I/O到底在转接什么多路转接这个词刚听有点绕其实可以这么理解你是一个餐厅服务员手上管着十张桌子。你不可能每张桌子前都站一个人所以你把十张桌子的号码都报给后厨后厨一旦有哪桌上菜了就叫一嗓子你再去处理。这里面“后厨”就是内核“号码集合”就是文件描述符集合“叫一嗓子”就是 select/poll 的返回。select 和 poll 做的事情本质上一样把一批 fd 交给内核让内核帮忙检测这些 fd 上有没有可读、可写或者异常事件。有任一事件发生系统调用就返回你拿到返回值之后再去逐个检查是哪些 fd 就绪了然后执行对应的 read/write/accept。这里有个关键点select/poll 只是“通知你哪些 fd 就绪了”真正的数据读写还是要你自己调用 read/write 去完成它们和阻塞 IO 一样都是同步 IO。这个区分在面试里经常被问实际上也决定了它们的性能模型调用一次 select内核就要把所有 fd 检查一遍你拿到结果后用户态又要再遍历一遍所有连接量的增长都对应线性开销。2. select一张位图走天下2.1 select函数签名与fd_set的背后逻辑select 函数长这样#include sys/select.h int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);第一眼看到 nfds 这个名字很多人会误以为是“文件描述符的数量”其实它是最大文件描述符编号 1。为什么这么传因为 fd_set 本质是位图内核要遍历从 0 到 nfds-1 的所有 fd 位你告诉它边界它就不用扫整张位图了。如果你最高的 fd 是 100那 nfds 就填 101。fd_set 是一个位图结构可以用四个宏来操作FD_ZERO(fd_set *set)把整个集合清空所有位都置 0。FD_SET(int fd, fd_set *set)把 fd 对应的位置 1。FD_CLR(int fd, fd_set *set)把 fd 对应的位置 0。FD_ISSET(int fd, fd_set *set)检查某个 fd 位是否为 1判断是否就绪。fd_set read_fds; FD_ZERO(read_fds); FD_SET(listen_fd, read_fds); // 监听 socket 也交给 select 监控 FD_SET(client_fd, read_fds); // 已连接的客户端 fd 也放进去timeout 是一个指向 struct timeval 的指针可以设置阻塞时间传 NULL永久阻塞直到至少一个 fd 就绪。传一个非零 timeval最多等待这么多时间超时返回 0。传一个 0 值 timeval非阻塞轮询立即返回。返回值有三种情况大于 0 表示就绪 fd 的数量等于 0 表示超时等于 -1 表示出错常见错误码是 EINTR被信号打断。这里有个大家第一次写都会掉进去的坑select 返回后内核会修改传入的 timeout 变量把它更新成剩余时间。这意味着你不能在函数外面初始化一个 timeout 后反复复用必须在每次调用前重新赋值。后面实操代码里我会展示正确姿势。2.2 手写一个select回显服务器下面这段代码是完整可编译的基于 select 实现一个回显服务器客户端发什么服务器原样写回去。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include sys/socket.h #include sys/select.h #include netinet/in.h #include arpa/inet.h #define MAX_CLIENTS 128 #define BUFFER_SIZE 1024 int main() { int listen_fd; struct sockaddr_in server_addr, client_addr; socklen_t client_len; fd_set all_fds, read_fds; int client_fds[MAX_CLIENTS]; int max_fd; int ret, i; listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); exit(1); } int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(8080); server_addr.sin_addr.s_addr htonl(INADDR_ANY); if (bind(listen_fd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(bind); exit(1); } if (listen(listen_fd, 128) 0) { perror(listen); exit(1); } for (i 0; i MAX_CLIENTS; i) { client_fds[i] -1; } FD_ZERO(all_fds); FD_SET(listen_fd, all_fds); max_fd listen_fd; printf(select echo server listening on 8080...\n); while (1) { read_fds all_fds; // 必须复制一份select 返回后会改动位图 struct timeval timeout {5, 0}; // 每次循环都重新初始化 ret select(max_fd 1, read_fds, NULL, NULL, timeout); if (ret 0) { if (errno EINTR) { continue; // 被信号打断重试即可 } perror(select); break; } if (ret 0) { printf(select timeout, still waiting...\n); continue; } // 1. 处理新连接 if (FD_ISSET(listen_fd, read_fds)) { client_len sizeof(client_addr); int client_fd accept(listen_fd, (struct sockaddr *)client_addr, client_len); if (client_fd 0) { perror(accept); continue; } printf(new client connected: %s:%d, fd%d\n, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), client_fd); // 把新 fd 记录到数组并加入 all_fds int placed 0; for (i 0; i MAX_CLIENTS; i) { if (client_fds[i] -1) { client_fds[i] client_fd; placed 1; break; } } if (!placed) { printf(client table full, reject new connection\n); close(client_fd); continue; } FD_SET(client_fd, all_fds); if (client_fd max_fd) { max_fd client_fd; // 更新 nfds 边界 } } // 2. 遍历所有客户端 fd处理可读事件 for (i 0; i MAX_CLIENTS; i) { int fd client_fds[i]; if (fd 0) { continue; } if (FD_ISSET(fd, read_fds)) { char buffer[BUFFER_SIZE]; ssize_t n read(fd, buffer, sizeof(buffer) - 1); if (n 0) { // 对端关闭或出错清理 fd if (n 0) { printf(client fd%d closed\n, fd); } else { perror(read); } close(fd); FD_CLR(fd, all_fds); client_fds[i] -1; } else { buffer[n] \0; printf(recv %zd bytes from fd%d: %s, n, fd, buffer); write(fd, buffer, n); // 原样回显 } } } } close(listen_fd); return 0; }这段代码有三个值得反复看的地方第一read_fds all_fds 必须每次循环重新复制。select 返回后内核会把没有就绪的 fd 位清零你要是直接复用 all_fds下一轮就会丢连接。第二timeout 放在循环体里初始化。select 会改写 timeout 的值Linux 内核会把剩余等待时间写回去不重新初始化的话第二次循环可能就变成非阻塞轮询了。第三max_fd 要随着新连接的加入动态更新。如果你的最高 fd 是 100却传了 50select 只会检查 0 到 49 的位监听 socket 的位可能正好在 100 上结果就是永远等不到事件。这个 bug 很难查因为不是必现的。2.3 select的软肋要心里有数select 最大的槽点是FD_SETSIZE 限制。在 Linux 下fd_set 中 fd 数量上限默认是 1024。也就是说select 能监控的 fd 编号不能超过 1023。你可能会想“那我定义了一万个客户端 fd哪怕分配了很多 fdselect 也管不了这么多”。这是硬限制除非你重新编译内核或者修改 FD_SETSIZE否则绕不开。第二个槽点是每次调用都要把整个 fd_set 从用户态拷贝到内核态内核还得线性扫描所有 fd。fd 数量一涨拷贝和扫描成本都是线性增长。第三个槽点是返回后你不知道具体哪些 fd 就绪了只能自己用 FD_ISSET 从头到尾扫一遍又是 O(n)。所以我的经验是select 适合连接数在几百以内的轻量服务或者代码里图简单、兼容性要好的场景。它跨平台能力最强Windows 也有 select门槛最低写起来也不容易出错。但如果你要单机扛上万连接就不要在 select 上死磕了那是 epoll 的主场。3. poll换一种结构放开数量上限3.1 pollfd数组与三个关键参数poll 函数和 select 最大的区别是数据结构它不再用位图而是用一个数组来承载要监控的 fd以及对每个 fd 要监听什么事件。#include poll.h int poll(struct pollfd *fds, nfds_t nfds, int timeout);struct pollfd 长这样struct pollfd { int fd; // 要监控的文件描述符 short events; // 调用前填入关心哪些事件 short revents; // 调用后内核填入实际发生了哪些事件 };events 和 revents 的核心区别是events 是你告诉内核你想知道什么revents 是内核告诉你实际发生了什么。两者是“请求”和“结果”的关系这也是 poll 相比 select 更优雅的地方——select 的位图在返回后会被改写你没法分清“原来我要监控它”和“它现在就绪了”而 poll 把输入和输出分开了。常用事件标志位事件含义出现在 events出现在 reventsPOLLIN有数据可读包含普通数据与紧急数据是是POLLOUT可写发送缓冲区有空闲是是POLLERR发生错误否是POLLHUP对端挂断否是POLLNVALfd 未打开无效 fd否是注意 POLLERR、POLLHUP 这些异常事件是不能通过 events 请求的内核一旦检测到会直接写进 revents。所以你的代码在检查 revents 时不能只看 POLLIN还要把 POLLERR、POLLHUP 一起判断否则某些异常连接会被漏掉连接 fd 越积越多。timeout 的单位是毫秒和 select 的 timeval 微秒单位又不一样timeout -1无限期阻塞。timeout 0非阻塞轮询。timeout 0最多等待 timeout 毫秒。poll 没有类似 FD_SETSIZE 的固定上限。数组你开多大就能监控多大只要不超过系统对进程可打开文件数的限制ulimit -n。但要注意它依然要把整个 pollfd 数组从用户态复制到内核态内核依然要线性扫描返回后你依然要遍历整个数组。数量上限放开了复杂度问题依然在。3.2 手写一个poll回显服务器下面这个示例同样可以直接编译运行端口换成 8081避免和上一个 select 示例冲突。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include sys/socket.h #include sys/poll.h #include netinet/in.h #include arpa/inet.h #define MAX_CLIENTS 128 #define BUFFER_SIZE 1024 int main() { int listen_fd; struct sockaddr_in server_addr, client_addr; socklen_t client_len; struct pollfd poll_fds[MAX_CLIENTS 1]; int client_count 1; // 下标 0 放 listen_fd int timeout 5000; // 5 秒 int ret, i; listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); exit(1); } int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(8081); server_addr.sin_addr.s_addr htonl(INADDR_ANY); if (bind(listen_fd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(bind); exit(1); } if (listen(listen_fd, 128) 0) { perror(listen); exit(1); } for (i 0; i MAX_CLIENTS; i) { poll_fds[i].fd -1; poll_fds[i].events 0; poll_fds[i].revents 0; } poll_fds[0].fd listen_fd; poll_fds[0].events POLLIN; // 只关心监听 socket 的可读事件 printf(poll echo server listening on 8081...\n); while (1) { ret poll(poll_fds, client_count, timeout); if (ret 0) { if (errno EINTR) { continue; } perror(poll); break; } if (ret 0) { printf(poll timeout, still waiting...\n); continue; } // 1. 处理新连接 if (poll_fds[0].revents POLLIN) { client_len sizeof(client_addr); int client_fd accept(listen_fd, (struct sockaddr *)client_addr, client_len); if (client_fd 0) { perror(accept); continue; } printf(new client connected: %s:%d, fd%d\n, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), client_fd); if (client_count MAX_CLIENTS) { poll_fds[client_count].fd client_fd; poll_fds[client_count].events POLLIN; poll_fds[client_count].revents 0; client_count; } else { printf(client table full, reject new connection\n); close(client_fd); } } // 2. 遍历所有客户端 fd for (i 1; i client_count; i) { int fd poll_fds[i].fd; if (fd 0) { continue; } // 有数据可读或对端断开/出错都走这个分支处理 if (poll_fds[i].revents (POLLIN | POLLERR | POLLHUP | POLLNVAL)) { char buffer[BUFFER_SIZE]; ssize_t n read(fd, buffer, sizeof(buffer) - 1); if (n 0) { if (n 0) { printf(client fd%d closed\n, fd); } else { perror(read); } close(fd); // 用数组最后一个有效项覆盖当前位置避免数组空洞 if (i client_count - 1) { poll_fds[i] poll_fds[client_count - 1]; } poll_fds[client_count - 1].fd -1; poll_fds[client_count - 1].events 0; poll_fds[client_count - 1].revents 0; client_count--; i--; // 迁移过来的 fd 需要再检查一轮 } else { buffer[n] \0; printf(recv %zd bytes from fd%d: %s, n, fd, buffer); write(fd, buffer, n); } } // 本轮 poll 结束后清理 revents避免影响下一轮判断 poll_fds[i].revents 0; } } close(listen_fd); return 0; }这段代码里我用了一个小技巧关闭连接后把数组最后一个有效元素搬过来覆盖当前位置避免数组空洞。为什么要搬因为 poll 处理的数组越大内核扫描和用户态遍历的开销就越大。连接的 fd 如果一会儿关一个数组里全是 -1 的空洞client_count 又不断增长最终会拒绝新连接——明明没几个活跃连接了却因为历史上用过很多 fd 导致满表这个现象在生产上很常见。搬移之后整个数组是紧凑的client_count 始终等于当前连接数加一。还要注意一个细节i--不能省。因为我把最后一个有效项搬到了 i 的位置这个新搬来的 fd 可能也有事件就绪如果直接 i 跳过它的数据就要等到下一轮 poll 才能处理对延迟敏感的业务来说这就是超时隐患。3.3 poll与select的正面交锋对比维度selectpoll底层结构fd_set位图struct pollfd 数组fd 数量上限受 FD_SETSIZE默认 1024限制没有固定上限受数组大小限制请求/结果分离不分离返回后会改写 fd_set分离events 保留revents 单独返回超时精度微秒struct timeval毫秒int 类型就绪判断FD_ISSET 逐个检查逐个检查 revents 位内核实现每次全量拷贝 fd_set线性扫描每次全量拷贝 pollfd 数组线性扫描移植性最广几乎所有平台都有Linux/Unix 系很常见Windows 支持较弱异常事件单独一个 exceptfds 参数通过 revents 里的 POLLERR/POLLHUP 上报从性能模型上看两者本质一样都是 O(n) 的“全量扫描全量拷贝”模型。poll 真正赢的地方是解除了 1024 这个硬限制并且用 revents 保留了请求信息代码写起来更顺一些。select 赢在兼容性和简单性它三个集合参数分开异常事件有专门通道很多老代码还在用。选型上我给的建议是写跨平台库选 select在 Linux 上做中小型服务选 poll真要奔着高并发去直接学 epoll别在 select/poll 上纠结性能优化——它们不是为海量连接设计的方向错了再怎么优化也到不了 epoll 的量级。4. 实战踩坑实录那些教科书不会告诉你的细节4.1 timeout被内核改写你必须重置select 的 timeout 参数在 Linux 上是“输入输出型”参数。内核在等待返回之后会把你传入的时间戳改成“剩余时间”。举个实际例子struct timeval timeout {5, 0}; // 想阻塞 5 秒 while (1) { ret select(max_fd 1, read_fds, NULL, NULL, timeout); // 第一次运行正常 // 第二次进到这里时timeout 可能已经被改成 {4, 123456}甚至 {0, 0} }如果哪次 select 返回前有 fd 就绪了内核就把剩余时间写回 timeout下一次循环你会看到一个被改得乱七八糟的 timeval。最恶心的情况是改成了全 0你的程序从“每次等 5 秒”变成“非阻塞轮询”CPU 占用直接拉满问题还很难定位。解决办法就一条timeout 必须在每次调用 select 之前重新初始化就是我把struct timeval timeout {5, 0};放在 while 里的原因。poll 的 timeout 不存在这个问题poll 不会改写它。这是 poll 在代码简洁度上一个小胜利。4.2 对端断开时别再满世界找“Connection reset”很多新手在服务端写网络程序遇到客户端直接拔网线或强杀进程时read 返回 -1 且 errno 是 ECONNRESETConnection reset by peer就开始怀疑是自己代码出了问题。这里我系统梳理一遍 read 的三种情况read 返回大于 0正常读到数据按业务处理。read 返回 0对端正常关闭连接发送了 FIN。这是优雅断开你应该 close 这个 fd并从 select/poll 监控集合里去除。read 返回 -1出现错误要分情况errno EINTR被信号打断不是真错误通常 continue 重试即可。errno EAGAIN 或 EWOULDBLOCK非阻塞模式下当前没有数据不是错误继续等下一轮事件。errno ECONNRESET对端在未完成正确关闭流程时强制断开比如进程 crash、网络闪断。此时这个连接已经废了该 close 就 close。所以你在服务端循环里看到 Connection reset 其实不用慌关键是要做到“任何一个 fd 出错了就清理它不要让它留在 select/poll 的监控集合里”。很多服务端的 fd 泄漏就是这么来的客户端断开后服务器没把 fd 从集合中移除时间一长内核里的 fd 被耗尽新客户端连不进来表现就是“服务器突然拒绝所有新连接”。4.3 poll数组空洞与fd-1的隐藏规则poll 有一个 POSIX 规定如果 pollfd 结构体的 fd 字段小于 0这个条目会被忽略events 字段被忽略revents 被清零。这个规则用得好代码会很简洁用得不好就是埋雷。我见过有人这么写// 客户端断开后只是把 fd 设为 -1没有压缩数组 poll_fds[i].fd -1;这样做 poll 确实会忽略这条程序能跑。但它的隐患在前面已经说过了client_count 不减数组里全是空洞。等到 client_count 涨到 MAX_CLIENTS新连接全部被拒你以为系统满了实际 active fd 只有十几个。如果服务器要长期运行这个“假满”问题迟早会咬你一口。正确姿势就是我示例里的做法断开时把数组最后一个活着的元素搬移过来让数组始终紧凑。当然如果你的业务连接数本来就不超过一二十个手动压缩代码也可以不做但你要清楚这个 trade-off。另外一个小坑POLLNVAL 经常被忽略。如果某个 fd 已经被 close 了但你忘记删除内核会给这个位置填上 POLLNVAL。你的检查条件如果只写了revents POLLINpoll 依然会返回这个条目但你的代码永远不进处理分支循环就卡在这里了。把 POLLNVAL 纳入处理范围至少可以打条日志帮你发现“哪个 fd 被重复关闭了”。4.4 常见问题速查表症状可能原因处理方式select 第二轮开始 CPU 飙高timeout 被内核改写为 0每次循环前重新初始化 timevalselect 永远等不到新连接nfds 没传最高 fd1维护 max_fdaccept 后更新客户端关闭后 fd 数一直涨没把 fd 从 fd_set/数组中移除对端断开后 close 并 FD_CLR/置 -1poll 返回但 ret 很小数组却很多活跃项revents 没清零导致误判每次 poll 后把本轮处理的 revents 清零poll 一直不返回新连接监听 socket 的 fd 被意外 close检查是否有代码误 close 了 listen_fdread 返回 -1errnoEAGAIN非阻塞模式下暂无数据不是错误continue 等待下一轮read 返回 -1errnoECONNRESET对端异常断开关闭 fd清理监控项select EINTR 导致服务挂掉信号打断 select判断 errnoEINTR 后 continueclient_count 到上限但实际连接很少poll 数组空洞没有压缩断开时搬移数组末尾元素覆盖当前位置5. 从select/poll到epoll下一段路怎么走5.1 选型建议别为了炫技选错工具看完前面的对比你应该能感觉到select 和 poll 都是“每次调用全量检查”的模型它们在连接数几十、几百时完全够用代码也足够直白。真实开发里很多服务根本到不了 C10K一个连接数一两百的嵌入式设备管理服务、一个内网工具用 select 反而比 epoll 更稳——至少代码简单、不容易出 bug、跨平台也方便。如果连接数到了几千或者你明确知道将来会往上增长我的建议是直接跳到 epoll不要试图优化 select/poll 的遍历逻辑。epoll 的核心改进是内核里维护一棵红黑树和就绪链表调用 epoll_wait 时只返回“真正就绪的 fd”不需要每次全量拷贝、不需要用户态再遍历一遍所有 fd。这是数量级上的差别也不是用 select/poll 做点小优化能追平的。我这篇只写了“多路转接 I/O一”epoll 我会在下一篇单独写包括它的三接口epoll_create、epoll_ctl、epoll_wait、水平触发和边缘触发的区别以及 LT/ET 模式下 read 必须处理 EAGAIN 的经典坑。到时候你会发现select/poll 这套“每次全量检查”的思路理解透了epoll 的“注册-回调-只返回就绪项”反而更好理解因为你能对比出它到底省在哪。5.2 面试和笔试喜欢挖的三个点最后整理三个我面试别人时几乎必问的问题也是这篇文章的知识点浓缩建议你自己过一遍第一select 和 poll 的时间复杂度是多少为什么答案是 O(n)n 是监控的 fd 数量。因为调用时要把整个集合拷贝到内核内核线性扫描返回后用户态还要线性扫描判断哪些 fd 就绪。两次 O(n)一出一进谁也没跑掉。第二select 的 fd_set 为什么有 1024 限制poll 为什么没有fd_set 是一个固定大小的位图FD_SETSIZE 决定了它最多能容纳的 fd 编号超过就无法用 FD_SET 标记。poll 用数组承载任意数量的 pollfd数组多大由你说了算所以没有硬编码上限。第三select/poll 是同步 IO 还是异步 IO它们是同步 IO。它们只是帮你“等待事件就绪”真正读写数据还是要自己调用 read/write 阻塞完成。异步 IO 是事件就绪后内核帮你把数据拷贝好再通知你这两者在 Linux 网络编程里是完全不同的东西。我自己写网络服务的体会是select/poll 这种“老家伙”虽然性能上早被 epoll 甩开但它们的价值在于把 I/O 多路转接的基本思想讲得非常朴素把所有 fd 交给内核、等待内核通知、再逐个处理。你只有真的把 select 和 poll 的代码跑起来、踩过 timeout 被改写和数组空洞的坑再看 epoll 的惊群、LT/ET、epoll_wait 返回列表这些概念才会有根基而不是背了一堆名词。所以别嫌弃它们“过时”它们是你理解 Linux 网络编程绕不开的起跑线。