1. 一个连接撑爆了的下午从阻塞IO说起如果你做过高并发服务端开发大概率遇到过类似的场景某天下午线上告警突然响起来某服务的连接数一路飙升CPU占用率跟着往上冲紧接着就是一连串的连接超时和请求堆积。排查的时候翻到监控面板发现线程数早就突破了配置的上限每个线程都被一个慢速连接卡在原地动也动不了。这事的根源往往不是你的业务代码有多差而是IO模型从一开始就没选对。说起阻塞IO很多人第一反应是accept一个连接就开一个线程去处理这套模型在教程里出现频率极高写起来也确实直观。但阻塞IO有个天然矛盾当一个线程卡在read()上等数据时它所有的CPU时间片都浪费了。CPU等待的是网络数据从网卡到内核缓冲区再到用户空间的这段链路而这段链路对CPU来说慢得像蜗牛。一千个连接就要一千个线程一万个连接呢光线程栈内存就能吃掉几个GB再加上线程切换的系统开销绝大多数机器根本撑不住。我当时负责的那个消息推送服务就是这么挂掉的。它采用的正是一连接一线程的经典模型初始设计时连接的并发量大概只有几百线程池五十个绰绰有余。后来业务量起来连接数冲到五六千线程池被打满新连接全部在accept队列里排队老连接又因为业务处理慢迟迟不释放线程死锁一样的恶性循环。那次的教训让我彻底明白了一件事在高并发场景下不能靠堆线程去解决IO等待的问题得换一套思路。换思路的方向其实很明确。仔细观察阻塞IO就会发现问题的核心在于线程等数据这个动作太被动了。如果一个线程能同时监听几千个连接的读事件哪个连接有数据就处理哪个没有事件的连接就晾在一边那这个线程的效率就完全不一样了。这种一个线程盯着所有连接的技术就是IO多路复用I/O Multiplexing。这套技术是今天几乎所有高并发网络服务的基础。从最古老的select()到它的小修小补poll()再到Linux下最成熟的epoll再到跨平台的kqueue、IOCP底层思路一脉相承只是实现方式一代比一代高效。这篇文章我不打算泛泛地讲概念而是从一次真实的性能事故切入把select、poll、epoll这三代IO多路复用技术拆开揉碎讲清楚它们的原理、差异和实际使用中的坑。不管你是刚接触网络编程的新人还是正在调优线上服务的老手这篇文章里的代码和踩坑经验应该都能直接用上。在开始之前先交代一个背景下面的代码和讨论都是基于Linux环境网络编程相关的系统调用全部是POSIX标准下的接口。理解了这一层后面的一切都好办了。2. 从select开始理解一个线程盯多个连接2.1 select的设计哲学把连接集中起来问一遍select是IO多路复用的开山之作它的核心思想非常朴素既然一个线程没法同时等好几个连接的数据那就让它先把所有感兴趣的连接集中登记在一个列表里然后统一去问内核这些连接哪些可读了、哪些可写了、哪些出错了。调用select时你需要告诉内核三件事你要监视哪些文件描述符的可读状态、哪些可写状态、哪些异常状态以及你打算等多久。内核拿到这份清单后会持续阻塞直到以下三种情况之一发生有描述符的状态变成了你关心的状态、有信号打断、或者超时时间到了。这里最关键的数据结构是fd_set一个用位图实现的描述符集合。什么叫位图你可以把它想象成一个超长的开关面板面板上每一个位对应一个文件描述符编号。如果你要监视fd为5的连接是否可读就把第5位打开置1。select返回后内核会把集合中不满足条件的位给清掉所以select的返回值告诉你还有哪些位是亮的这些位对应的连接就是你接下来要处理的。写一个简单的例子#include sys/select.h #include sys/time.h #include unistd.h #include stdio.h int main() { fd_set readfds; struct timeval timeout {5, 0}; // 5秒超时 FD_ZERO(readfds); // 先清空整个位图 FD_SET(4, readfds); // 监视fd4的可读事件 FD_SET(7, readfds); // 监视fd7的可读事件 int maxfd 7; // select需要知道最大描述符编号 int ready select(maxfd 1, readfds, NULL, NULL, timeout); if (ready 0) { // 返回后readfds里只剩下真正可读的fd if (FD_ISSET(4, readfds)) { // fd4上有数据可读 printf(fd 4 is readable\n); } if (FD_ISSET(7, readfds)) { // fd7上有数据可读 printf(fd 7 is readable\n); } } else if (ready 0) { printf(timeout, no fd ready\n); } else { perror(select error); } return 0; }看起来很简单对不对但这套设计存在两个从出生就带着的硬伤这两个硬伤在后来的实战中被无限放大。2.2 select的两个致命缺陷位图太小和全量扫描第一个缺陷是fd_set的大小限制。在Linux的默认头文件里FD_SETSIZE被定义为1024意味着你用select最多只能监视1024个文件描述符。注意你不是只能监视1024个连接而是文件描述符值本身不能大于等于1024因为位图只有1024位。也就是说服务器连接数一上千select直接就没法用了。你可能会想改一下FD_SETSIZE不就行了可以改Linux内核也会接受编译时修改后的值但1024这个限制从设计上就反映了一大类通用系统的假设一个进程同时持有的文件描述符数量不会太多。早期确实如此但高并发服务出现后这个假设立刻崩了。第二个缺陷更致命它会全量扫描所有监视的描述符并且每次调用时都要把整个位图从用户空间拷贝到内核空间返回后再从内核空间拷回来。我展开说一下这里发生了什么。每次调用select内核态需要通过遍历传给它的全部监视项来判断哪些fd的状态发生了变化。这个遍历的复杂度是O(n)n就是你要监视的描述符总数。当你有几千上万个连接时每次select调用就是一次彻头彻尾的大扫除——不管这些连接有没有数据内核都得把它们挨个看一遍。数据少的时候还好连接一多CPU时间几乎全部耗在了大扫除上真正处理数据的CPU所剩无几。另一个隐形开销是位图的反复拷贝。fd_set每次调用都要进一次内核再出来如果FD_SETSIZE改大了这个拷贝的代价也跟着线性上涨。本来网络IO的瓶颈就在用户态和内核态的切换上select的连接数一多切换频率没降低单次切换的开销反而变得更大了。所以实际项目中真正的连接数达到几百上千时select往往是指标最难看的那一环。健康的服务CPU使用率应该花在业务逻辑上如果用select的地方CPU使用率拉满而QPS又不高你基本可以断定是select的扫描和拷贝开销在作祟。2.3 为什么用select做服务器还是能跑起来的小型场景公平地说select并不一无是处。它的实现足够简单理解成本极低而且跨平台能力很强Windows的socket也有select。这意味着在一些规模很小的场景下——比如嵌入式设备上维护几条TCP连接、写个临时脚本去同时监视几个管道文件或者写个简单的网络测试工具——select依然是个不错的选择。我自己就经常在写一次性脚本的时候用select。比如某个某场景下同时开着好几个外部进程需要等它们任意一个先输出结果这时候select管几个pipe干净利落根本没必要上复杂的io多路复用框架。选型永远要看场景规模不要杀鸡用牛刀也不要牛刀杀鸡。但如果你做的是面向大量TCP连接的服务器程序比如我在第1节里提到的那个消息推送服务那select肯定是不够用的。它在设计时就没考虑过成千上万这个数量级。3. poll的改良与它的本质局限3.1 poll用一个数组解决了什么poll是select的直接继承者它解决的问题非常明确摆脱fd_set的位图限制。具体做法是把监视的描述符从位图换成了一个pollfd结构体数组。#include poll.h #include stdio.h #include unistd.h int main() { struct pollfd fds[2]; fds[0].fd 4; fds[0].events POLLIN; // 关心可读事件 fds[0].revents 0; // 内核返回时填写的实际事件 fds[1].fd 7; fds[1].events POLLIN; fds[1].revents 0; int ready poll(fds, 2, 5000); // 5秒超时 if (ready 0) { for (int i 0; i 2; i) { if (fds[i].revents POLLIN) { printf(fd %d is readable\n, fds[i].fd); } } } else if (ready 0) { printf(timeout, no fd ready\n); } else { perror(poll error); } return 0; }和select对比poll的变化主要有三处没有数量上限。pollfd数组的长度由你自己决定不再受1024位图限制能监视多少描述符完全取决于系统允许你有多少文件描述符。输入和输出分离。events字段是你关心的哪些事件revents字段由内核填写实际发生了什么事件。每次调用poll之前不需要重新构造整个数组内核也不会改动你的events字段。这个变化在工程上非常有用后面会展开讲。统一的事件类型。select把事件分成读、写、异常三组poll则给每种事件定义了独立的掩码比如POLLIN、POLLOUT、POLLERR。多了一些灵活性但本质还是一样的轮询。3.2 poll没能解决的核心问题仍然全量扫描poll的出现让监视几千个文件描述符在API层面变成了可能。但它依然保留了一个select的核心遗憾每次调用都会全量扫描整个pollfd数组。你放进poll数组里的所有描述符不管有没有事件发生内核都要遍历一遍去检查它们的等待队列状态。这个开销在数组长度达到几千几万时会变得异常明显。再有就是每次poll从用户态携带大数组进入内核返回时又带着结果出来这中间的传输开销和select一样存在只是从位图变成了结构体数组体积可能更大。所以poll更像是一个把select的容量补上了的版本而不是把select的效率提升了的版本。对于几百个连接poll用起来很顺手对于数千个连接poll也会开始吃力。我见过一些项目团队用poll支撑数千个长连接的处理一段时间后同样遇到CPU居高不下的问题。当时排查下来罪魁祸首就是poll的O(n)遍历。这不是代码写错了而是poll这个模型天然存在的上限。3.3 说说poll真正有价值的用法配合非阻塞IO的单线程服务虽然poll有扫描开销但它在以下场景下依然好使单线程配合非阻塞IO维护百量级到小千量级的连接。比如你要写一个简单的IM服务端原型不需要扩展到数十万连接也不需要跨平台那poll是个很舒服的中间方案。代码量比select没多多少容量却大得多。不过要注意无论是select还是poll用户态程序都需要在事件返回后自己遍历描述符集合或者数组去找到底是哪些fd就绪了。这个二次遍历在连接数变大时也是开销大头它跟内核态的扫描累加在一起让select和poll在大规模高并发场景下都显得力不从心。我建议你在理解select和poll的时候同时记住它们的三个共同特征这样后面看epoll会清晰很多每次调用的全量扫描所有监视的描述符都被重新检查一遍用户态到内核态的数据拷贝整个监视列表每次调用都要进出内核一次准备就绪后需要自己找返回值只是有多少个就绪具体哪些就绪还要靠遍历这三个特征本质上都指向同一个问题当监视规模很大时存在大量无谓的消耗。4. epoll从轮流过问到有事先叫我4.1 epoll的底层设计红黑树维护监视列表就绪链表记录事件epoll在Linux 2.6内核中正式出现它的设计真正改变了多路复用的工作方式。前面说select和poll是轮流过问也就是每次调用都把全部监视项从头到尾问一遍epoll则切换到有事先叫我的模型你先把所有要监视的描述符都放进内核维护的一个数据结构里然后当某个描述符真的发生事件时内核主动把这条事件往一个就绪队列里塞一笔。你每次去取只取有事件发生的那些没有事件就睡觉等唤醒。这个模式在工程上的差异是巨大的我展开讲两个核心数据结构。第一个是红黑树。epoll在你调用epoll_ctl向内核添加、修改、删除监视项时用红黑树来管理这些项。红黑树是一种自平衡二叉搜索树插入、删除、查找的复杂度都是O(log n)。这个结构非常关键——回想select和poll每次调用都要把整个列表搬进内核然后全量检查一遍epoll则把维护监视列表的工作分散到了每一次单独的epoll_ctl调用上等到真正要等待事件的时候内核不需要再去挨个检查只需要挂起等待就绪队列有东西入队即可。第二个是就绪链表。当某个文件描述符上有事件发生时内核会通过回调机制把这个描述符对应的epitem内核里管理监视项的结构体加入到这个链表中。调用epoll_wait时内核只需要把链表里的内容拷贝到用户空间拷贝多少取决于你给events数组开了多大。你可以这样简单类比select是班主任每隔几分钟挨个点名问全班学生有没有问题没有问题的学生也被打扰epoll是班主任事先收了一份有问题主动举手的名单然后安安静静等举手的人来汇报。区别不在于快了多少倍而在于计算复杂度从O(n)降到了O(k)k是真正发生事件的数量。4.2 epoll的三个API其实用法远比名字看着简单epoll使用起来非常直接只有三个函数#include sys/epoll.h // 创建一个epoll实例返回其文件描述符 int epoll_create(int size); // 或者使用带标志位的版本 int epoll_create1(int flags); // 控制某个epoll实例上的监视项添加、修改、删除 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 等待事件发生 int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);注意epoll_create的size参数在Linux 2.6.8之后其实已经不被使用了内核会动态分配需要的内存但你调用时必须传一个大于0的数传0会报EINVAL。epoll_create1则直接用flags位来设置额外选项最常用的是EPOLL_CLOEXEC保证在fork之后执行exec时自动关闭这个fd避免泄露到子进程中。epoll_ctl里的op取值有三个EPOLL_CTL_ADD添加、EPOLL_CTL_MOD修改、EPOLL_CTL_DEL删除。struct epoll_event的定义长这样struct epoll_event { uint32_t events; /* 关注的事件类型EPOLLIN、EPOLLOUT等 */ epoll_data_t data; /* 用户数据联合体 */ }; typedef union epoll_data { void *ptr; /* 指向任意用户数据 */ int fd; /* 文件描述符 */ uint32_t u32; /* 一个32位整数 */ uint64_t u64; /* 一个64位整数 */ } epoll_data_t;很多人第一次用epoll时习惯直接在epoll_event.data.fd里存文件描述符这完全没问题。但注意epoll_data_t是一个联合体ptr和fd在同一块内存上你存了fd就不能存ptr了。实际项目里我倾向于把ptr指向一个自定义的连接上下文结构体里面放fd、缓冲区、业务状态等这样事件返回时能一步拿到完整上下文省去查找的开销。一段经典的epoll服务器骨架#include sys/epoll.h #include fcntl.h #include unistd.h #include stdio.h #include stdlib.h #include string.h #include errno.h #define MAX_EVENTS 64 int main() { int epfd epoll_create1(EPOLL_CLOEXEC); if (epfd 0) { perror(epoll_create1); exit(1); } // 假设这里已经有一个监听socket int listen_fd socket(AF_INET, SOCK_STREAM, 0); struct epoll_event ev; ev.events EPOLLIN; ev.data.fd listen_fd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev) 0) { perror(epoll_ctl); exit(1); } struct epoll_event *events malloc(sizeof(struct epoll_event) * MAX_EVENTS); if (!events) { perror(malloc); exit(1); } for (;;) { int n epoll_wait(epfd, events, MAX_EVENTS, -1); // 永久等待 for (int i 0; i n; i) { if (events[i].data.fd listen_fd) { // 处理新连接 } else if (events[i].events EPOLLIN) { // 处理可读事件 } else if (events[i].events EPOLLOUT) { // 处理可写事件 } } } free(events); close(epfd); return 0; }4.3 为什么epoll在大规模连接下能扛住结合上面两个数据结构epoll相比select/poll的性能差异主要体现在三个维度添加监视项时epoll走红黑树O(log n)而select/poll每次都把集合全量带进内核O(n)。等待事件时epoll要做的是阻塞等待一个链表非空不需要遍历所有监视项select/poll则每次都要在等待之前把整个集合遍历一遍。事件返回时epoll只拷贝就绪链表里的k个事件select/poll返回的是哪些位还亮着你仍然要遍历整个集合才能判断具体是哪些。严格来说select/poll在下一次调用前的全量检查是隐藏在内核do_select里的而epoll则是借助了文件系统里那个特殊的poll回调机制。file_operations-poll这个函数指针在epoll的实现中承担了事件回调注册的职责某个fd一旦就绪内核就能通知epoll实例往就绪队列里塞事件。这就让epoll真正的等待开销和被监视的连接总数脱钩了只跟实际发生事件的连接数挂钩。这也是epoll能轻松支撑上万、十万级连接的根本原因。你的服务连接数很多但同一时刻真正有数据要读的可能只有几百个epoll自然高效而select/poll每次调用都要考虑所有连接连接越多亏得越多。5. 水平触发与边缘触发epoll独有的选择5.1 LT和ET的本质区别用一句话说清楚理解epoll到这里已经能解决90%的需求了。但有一个概念是绕不开的就是水平触发Level-TriggeredLT和边缘触发Edge-TriggeredET。很多新手在这里被绕晕我尽量用最直白的方式讲。水平触发只要一个fd上的缓冲区还没被读空每次epoll_wait都会返回这个fd的事件提醒你还有数据没处理完。简单说就是只要条件一直成立就不断通知你。边缘触发只有当fd上的状态发生变化时——比如缓冲区从没有数据变成有数据的那一瞬间——才会通知你一次。之后哪怕缓冲区里还有一大堆数据没读只要没有新的数据进来epoll_wait都不会再返回这个fd。打个比方LT像一个闹钟只要你还没起床它就每隔五分钟响一次ET像一个只响一次的门铃门铃响完你不开门它也不会再响第二次直到有人再次按下门铃。默认情况下epoll使用的是LT模式。你可以通过给events加上EPOLLET标志切换为ET模式struct epoll_event ev; ev.events EPOLLIN | EPOLLET; // 边缘触发 ev.data.fd fd; epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ev);5.2 ET模式下漏数据的真实案例ET模式看起来更高效因为内核只需要通知你一次状态代码的触发次数少了很多。但它有两个必须遵守的约定违反任何一个都会出事故。约定一必须用非阻塞IO。这个要求的关键在于ET模式下内核不会重复通知你还有数据所以程序员在收到通知后必须把缓冲区里的数据一口气读完。如果缓冲区一次读不完剩余数据将不会被再次通知就会一直积压在那里。但如果你用阻塞IO去读假设缓冲区里只剩100字节你read()时给了4096字节的缓冲区那么阻塞IO会一直卡在那里等这4096字节全部凑满如果没有新数据到来这个线程就永远卡死了。所以ET模式的读取必须配合非阻塞IO每次read()都尽可能多读直到返回EAGAINEWOULDBLOCK为止。EAGAIN就表示缓冲区暂时读空了这时候才能停下来等下一次事件。约定二必须自己做好数据完整性处理。因为一次read()循环可能触发多次读取你需要把每次读到的一部分数据拼接成完整的业务消息所以必须自己维护接收缓冲区把读数据和解析消息解耦。这一点LT模式下也一样要做但ET模式下更加关键因为你不知道这一次循环到底读到了哪条消息的哪一段。我举一个真实发生的故障。某项目组的推送网关用epoll的ET模式做数据接收刚开始上线一切正常但偶发性地出现消息缺失或卡死。排查了很久最后发现代码里的读取逻辑只是简单地read(fd, buf, 2048)读一次就收工了。数据量大时一个TCP包被拆成多个段到达或者多个段被合并成一个包到达单次read常常只能拿到一部分数据。由于ET模式不会再次通知剩下那半截消息就永远躺在内核缓冲区里没人管。用户看到的表象是消息发不完整或者连接莫名挂起实际上是一次疏忽的读取逻辑导致的半包滞留。后来改成循环读取直到EAGAIN再配合应用层的数据缓冲切包逻辑问题才彻底解决。5.3 LT和ET实际选型建议基于上面的分析我的实践经验是这样的能用LT就用LT。理由很简单LT模式下哪怕是一次读不完数据这种菜鸟级写法也不会丢数据只是会多几次事件通知而已。在事件通知本身开销已经非常小的epoll设计下LT多出的那几次通知对整体性能的影响微乎其微。它容错性高调试和维护成本低。ET更适用于处理速度要求极端、并且你完全清楚非阻塞读写怎么玩的场景。比如某些高性能网关单线程需要支撑极大的吞吐内核态事件触发的频率能省一点是一点这时ET的优势才值得你去承担那套更严格的数据读取约定。我在生产项目里见过的情况是绝大多数用epoll的业务系统用LT都完全够用真正需要ET的少之又少。如果你刚开始用epoll建议直接LT起步后续业务有明确的性能瓶颈再切ET。6. 实战用poll写一个简陋服务端再用epoll重写6.1 一次真实迁移中的代码对比理论说再多不如动手写代码。我之前在某内部模拟项目中做过一个连接采集工具第一版用poll实现跑起来带1500个左右的TCP连接就开始出现周期性的CPU尖峰。后来花了一个下午迁移到epoll同样的连接数下CPU占用率降了一半多。下面是迁移的核心对比。poll版本的等待循环核心static void poll_loop(int listen_fd) { struct pollfd *fds calloc(1024, sizeof(struct pollfd)); int nfds 0; fds[0].fd listen_fd; fds[0].events POLLIN; nfds 1; for (;;) { int ready poll(fds, nfds, -1); if (ready 0) { if (errno EINTR) continue; perror(poll); break; } // 这里必须遍历全部fds即使ready只有1 for (int i 0; i nfds; i) { if (fds[i].revents POLLIN) { if (fds[i].fd listen_fd) { int conn_fd accept(listen_fd, NULL, NULL); fds[nfds].fd conn_fd; fds[nfds].events POLLIN; nfds; } else { handle_read(fds[i].fd); } } } } free(fds); }epoll版本的等待循环核心static void epoll_loop(int epfd, int listen_fd) { struct epoll_event *ready_events calloc(1024, sizeof(struct epoll_event)); // 把监听fd加进去 struct epoll_event ev; ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev); for (;;) { int n epoll_wait(epfd, ready_events, 1024, -1); if (n 0) { if (errno EINTR) continue; perror(epoll_wait); break; } // 只需要处理n个就绪事件不需要遍历所有连接 for (int i 0; i n; i) { int fd ready_events[i].data.fd; if (fd listen_fd) { int conn_fd accept(listen_fd, NULL, NULL); ev.events EPOLLIN; ev.data.fd conn_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, ev); } else if (ready_events[i].events EPOLLIN) { handle_read(fd); } } } free(ready_events); }两段代码结构类似区别核心在于poll的revents对所有fds填充你是通过遍历所有fds来发现谁是就绪的epoll的ready_events只包含就绪的那n个你是通过遍历就绪集合来处理的。连接数越大这个差异越明显。6.2 迁移过程中的几个坑那次迁移过程并不算一帆风顺踩了几个值得记录的坑。坑一忘记处理新连接的可写状态。accept返回的新socket默认是阻塞模式而且TCP缓冲区一开始是空的也就是可写状态立即可满足。如果给新连接加上EPOLLOUT事件epoll会立刻疯狂地返回这个fd的可写事件导致CPU打满。我当时就在这个坑里绕了半小时现象是epoll_wait一返回就处理一堆可写事件什么业务都没做CPU却飙到100%。正确的做法是新连接刚建立时只关注EPOLLIN需要往外发数据时再临时注册或通过EPOLL_CTL_MOD改成EPOLLIN | EPOLLOUT。坑二EINTR导致epoll_wait提前返回。如果在等待期间进程收到了信号epoll_wait会返回-1并且errno是EINTR。如果不处理程序会自适应退出。正确做法是检测到EINTR后continue继续循环这个细节在线上服务里非常关键。坑三对端关闭时不只是EPOLLIN。客户端断开连接epoll通常会返回EPOLLIN因为EOF被视为可读也可能返回EPOLLRDHUP或EPOLLHUP。如果只按EPOLLIN处理你调用read返回0才知道对端关闭了。建议在代码里显式判断EPOLLHUP和EPOLLRDHUP并及时清理连接资源避免泄漏。6.3 线程安全与惊群单线程模型下的epoll注意事项用epoll的单线程事件循环好处是一个连接的所有操作都遵循严格的顺序不需要加锁。但再往上走单线程开始吃力时通常会用多个线程各自持有epoll实例或者一个epoll实例多线程消费就绪事件两种方式扩展。第一种方式简单粗暴但可能在某些内核版本上触发惊群问题多个线程各自阻塞在自己的epoll_wait上当同一个监听socket上有新连接到达时所有线程都可能被唤醒但只有一个能accept成功其余的都白跑一趟。好在现代内核通过EPOLLEXCLUSIVE标志可以解决一部分这个问题给监听socket加上这个标志内核只唤醒其中一个正在等待的线程。第二种方式是真正意义上的共享事件循环通常配合mutex或自旋锁来保证就绪连接的处理不冲突。我个人的经验是如果只是短连接类的高并发场景多线程共享epoll收益一般如果是长连接加耗时业务的场景单线程事件循环反而更容易优化因为每个连接的处理天然串行省去了复杂的并发控制。7. 从epoll到更高维度IO模型选型不能只看连接数7.1 连接数、消息大小与CPU密集度三个维度一起看每次有人问epoll是不是银弹我都会说连接数和性能不是简单的正相关关系。epoll的高效建立在一个前提上——每秒产生的就绪事件数远小于总连接数。如果每个连接每秒都高频传输大块数据那epoll等待的就绪链表也很长事件处理的CPU开销依然会被拉满。这种情况下瓶颈就不再是IO多路复用的效率而是你的事件处理循环本身的吞吐能力。所以选型要同时看三个维度连接数量级几十、几百可以select/poll几千、几万甚至更高必须epoll/kqueue。单连接的消息大小和频率每秒钟几十条小消息和每秒钟一个几十MB的大包处理逻辑完全不一样。大包场景需要额外的拆包组包小包场景则要注意事件循环里不能做重业务。业务计算量如果业务逻辑本身就重IO模型再快也会被计算卡住这时候考虑的不是换IO模型而是把计算从事件循环里挪出去丢给线程池处理。我见过最离谱的一个案例某团队把一个纯CPU密集的加解密服务塞进epoll事件循环里结果单个连接就把循环卡死其他所有连接全部超时。IO多路复用解决的是等数据的并行问题不是处理数据的并行问题。把耗时操作放进事件循环等同于开着多车道的高速公路上只允许一辆车走。7.2 既然是Linux为什么不提IOCP网络编程资料里常把epoll和Windows的IOCP放在一起比较。两者有很大差异epoll是事件通知机制告诉你有事件了接下来该干活了IOCP是异步IO机制你提交一个读请求后内核完全接管数据读好了直接丢到完成的队列里你不必自己再去read一遍。Linux下也有异步IO的尝试比如io_uring这件事这几年在系统编程领域讨论很多。io_uring和epoll不是同一层的东西io_uring可以服务于文件IO和网络IO通过在共享内存里维护一个提交队列和一个完成队列减少系统调用次数。但它的复杂度比epoll高一个数量级目前在高性能存储和网络框架中逐步推广普通业务服务暂时没有理由抛弃epoll。我的观点是新学网络编程的同学完全可以把epoll当作默认选择。它足够成熟、资料多、踩坑经验丰富是理解Linux高并发网络的最佳参照系。io_uring属于进阶玩法等你用epoll调优遇到明确的系统调用开销瓶颈时再去研究不迟。7.3 帮助理解的多路复用知识大纲整理一份方便自测的知识清单供参考select/poll/epoll各自的核心数据结构与复杂度LT和ET模式的区别以及ET为什么必须配非阻塞IOepoll_create/epoll_ctl/epoll_wait的各个参数含义什么是惊群问题EPOLLEXCLUSIVE解决了什么为什么“连接数很大但事件很少”的场景下epoll优势最明显阻塞IO、非阻塞IO、多路复用、异步IO之间的关系epoll在Redis、Nginx这类高并发服务中为什么能成为核心依赖能把这七条讲清楚网络编程里关于多路复用的知识骨架就算立住了。8. 写在最后一次性能事故教会我的选型课回到文章开头那次消息推送服务的故障。如果当时的代码用epoll而不是一连接一线程那几千个连接在一个线程里就能管理得游刃有余。后来我把服务重写成了单线程epoll事件循环加线程池处理业务逻辑的结构同样的并发量下CPU占用从满载降到了百分之二三十性能差距就是这么直观。从那之后我总结出一个选型心法除非你能确定业务规模永远停留在几十个连接以内否则直接从epoll开始设计方案。别用select/poll在那里够用就好互联网业务增长的速度通常比你重构的速度快。你花在select调优上的时间本来可以省下来做更有价值的事。有句话我一直很认同——网络编程的难点不在那些五光十色的框架上而是在你最底层那套IO模型是否扎实。select、poll、epoll这三个接口或许在AI时代听起来不太酷了但它们是所有高并发系统的地基。哪怕你用Spring Boot写接口用Netty做网关用Go的goroutine背后依然躲不开这套模型。地基稳了上面的东西才能站得住。最后分享一个小建议别只看这篇文章动手把里边的demo代码跑起来然后试着把select的版本改成poll把poll改成epoll再对比一下同样连接数量下CPU的差异。亲手摸一遍这三兄弟的脾气比读十篇文章都管用。