
我之前在某个公司做后端服务的时候遇到过一个非常诡异的问题。服务在压测环境下表现正常一旦上到线上扛不住十分钟连接数就狂飙到上限紧接着整个进程CPU打满所有请求都卡住。当时我们用的还是最传统的“一来连接就开线程”的模式线程一多调度开销和上下文切换直接把机器拖垮。后来项目里一位老哥提了一句“换epoll吧”才彻底改变了我的技术路线。那次以后我把IO多路复用啃了个透——从select到poll再到epoll从底层的红黑树到事件回调再到真正把一套完整的高并发服务器做出来。今天这篇内容就是我那段时间踩坑、学习、实践的一条完整记录标题叫“Linux系统编程-高效并发服务器IO多路复用详解”直接把这个话题里我认为最核心的干货全部整理出来。如果你正在写网络服务、做嵌入式Linux、或者准备校招社招的Linux后端岗位又或者手里有个C/C项目想支撑数千甚至数万的并发连接那么这篇文章应该能帮你在思路上理顺很多东西。我会从“为什么并发量大就必须用IO多路复用”讲起然后对比select、poll、epoll各自的实现细节和适用场景再手把手拆解一个基于epoll的事件驱动服务器最后附上我自己压测、排查问题过程中记录下来的血泪经验。1. 为什么高并发服务器绕不开IO多路复用1.1 从同步阻塞到多线程传统方案的瓶颈很多人在学习网络编程时第一个写的服务器都是这样的流程创建一个监听socket然后调用accept等待客户端连接一旦有连接进来就fork一个子进程或者pthread创建一个线程在线程里处理收发数据。这套模型在最早期确实能用文件描述符数量少、连接数少、客户端间隔时间长的时候它甚至非常稳定。但你把它放到几千个客户端同时在线、每个客户端每秒发几条心跳包的场景里看问题就彻底暴露了。第一个问题是线程资源消耗。Linux下一个线程默认栈大小8MBulimit -s看到的值就算你用pthread_attr_setstacksize把栈缩小到256KB几千个线程的地址空间占用和内存换页压力也依然可观。更关键的是线程一多调度器需要频繁切换上下文每次切换都要保存和恢复寄存器、程序计数器、栈指针等一整套状态这个开销在CPU核数有限的情况下是致命的。第二个问题是阻塞带来的资源空转。当一个线程在recv上阻塞等待数据时它不占用CPU但它占用的内核资源、栈空间、文件描述符资源并没有释放。而真正需要消耗CPU的数据处理逻辑反而只占用了其中一小撮时间大部分时间这些线程都在内核里睡觉。也就是说资源总数是有限的却没有多少资源真正在干活。第三个问题是惊群效应。多线程accept模型里内核收到一个新连接时可能会唤醒多个阻塞在accept上的线程只有一个线程能accept成功其余线程重新睡回去。这个唤醒-竞争-再睡眠的过程白白消耗一轮调度连接数一高这个开销也会被放大。我并不是说多线程模型一无是处。在连接数少、每个连接的数据处理耗时较长、需要阻塞等待外部资源比如数据库的B端场景里多线程配合连接池反而很合适。但对一个以“大量空闲长连接”为核心的C端接入服务器来说线程模型从资源使用率上就是不经济的。1.2 IO多路复用的核心思想一个线程管几百路IO那IO多路复用的思路是什么一句话概括就是一个线程同时监视多个文件描述符当某个fd真正可读、可写或有异常事件发生时内核才通知应用层去处理没有事件的连接完全不需要占用业务逻辑的注意力。为什么叫“多路复用”你可以把它类比成一个大楼前台。以前每个访客配一个专属接待员访客不来时接待员就在那儿空等。IO多路复用相当于只有一个前台几百个访客都在大厅里坐着休息谁有事进了门、递了文件谁就站起来到前台办理没事的人不会打扰前台。这样前台一个人就能服务所有人。落到系统调用上Linux下老牌的select和poll做的就是这件事。它们的问题在于程序每次调用select/poll时都要把需要监视的fd集合从用户态拷贝到内核态然后内核线性扫描一遍扫描完了再把结果拷贝回用户态。fd数量少的时候无所谓fd数量到几千甚至上万时每次调用都是几十KB甚至几百KB的拷贝和一次O(N)扫描而且不管有没有事件发生这一套动作都必须做。epoll解决了核心的效率问题它的思路是把“要监视哪些fd”这个集合留在内核里通过epoll_ctl增删改而不是每次调用都全量传一遍通过事件回调和就绪链表把“哪些fd有事件”直接返回给应用而不是让应用自己遍历所有fd做判断。这就是Linux高并发服务器事实标准的由来。2. 三种实现select、poll、epoll的演进与取舍2.1 select老当益壮但局限明显select是POSIX标准提供的接口它的原型长这样int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);nfds是最大fd编号加1readfds、writefds、exceptfds分别是读、写、异常事件的fd集合timeout是超时时间。fd_set底层是一个位图在Linux上FD_SETSIZE默认是1024也就是说一个fd_set最多只能监视1024个fd。虽然你可以通过修改FD_SETSIZE再重新编译内核来扩大这个值但这本质上绕不过select的性能问题每轮调用都要把整个fd_set在用户态和内核态之间拷贝还得做一次全量扫描。select还有一个非常坑的副作用它会在返回前把fd_set修改成“只有就绪fd的位仍然为1”的状态所以每次调用前你必须用FD_ZERO、FD_SET把原来的集合重新构造一遍。这就意味着每轮都要反复构造、反复拷贝、反复扫描。我早期写过一段select服务器连接数到了八百多就开始感觉到明显的卡顿后来抓了一下系统调用耗时发现select本身每次要花费近1毫秒而业务处理才不到0.1毫秒瞬间就明白瓶颈在哪里了。select能解决的问题是用极少的代码量实现并发IO监视逻辑足够直观对平台的可移植性也好。如果你只是写个玩具服务器或者做跨平台小工具select仍然可用。但做高并发服务器它当不了主力。2.2 poll解决了FD数量限制但本质没变poll和select的核心逻辑几乎一样区别在于它的fd集合是用链表结构表示的int poll(struct pollfd *fds, nfds_t nfds, int timeout); struct pollfd { int fd; /* 文件描述符 */ short events; /* 监视的事件 */ short revents; /* 就绪的事件 */ };poll的一个明显进步是它不受FD_SETSIZE限制你能打开多少fdpoll就能监视多少fd。另外一个改进是events和revents是分开的字段所以每次调用不需要像select那样重新构造整个集合。但poll仍然需要把fds数组整体从用户态拷贝到内核态内核仍然要线性遍历所有fd去检查它们的状态然后应用层还要再次遍历fds数组去查找revents被置位的项。也就是说它的复杂度依然是O(N)N是所有被监视的fd数量而不是就绪fd数量。假设你的服务有1万个连接其中只有3个有数据到达poll依然要扫描1万个fd去发现那3个。所以poll更适合的场景是fd数量较大但没有大到让拷贝和扫描成为瓶颈且对可移植性有要求的程序。很多老牌开源软件比如某些Web服务器早期版本都用poll跑过很长时间但今天在高并发场景里它基本被epoll替代了。2.3 epollLinux高并发的事实标准epoll是Linux 2.6内核开始提供的IO事件通知机制它不是用来完全替代select/poll的而是给Linux场景下的高并发接入层提供更优解法。它有三个核心接口int epoll_create(int size); 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); struct epoll_event { __u32 events; __u64 data; /* 在较新内核里是union但可以按整型指针来用它 */ };epoll_create用来创建一个epoll实例内核会为这个实例分配一个eventpoll结构内部有两个关键的数据结构一棵红黑树和一个就绪链表。红黑树用来存储所有通过epoll_ctl注册进这个实例的fd及其关注的事件就绪链表用来存放真正有事件发生的fd。每次epoll_wait调用只需要从就绪链表中取出节点拷贝到用户态再清空链表即可。这个过程的时间复杂度是O(K)K是就绪事件的个数而不是所有被监视的fd数量。epoll_ctl负责增删改监视项支持三个操作EPOLL_CTL_ADD注册新fdEPOLL_CTL_MOD修改fd关注的事件EPOLL_CTL_DEL从树中移除fd。这个设计把“维护监视集合”和“等待事件”分成两个独立步骤而select/poll被迫把这两步搅在一次调用里完成这就是epoll在执行效率上拉开差距的核心原因。另外epoll还支持EPOLLET边缘触发模式这个我们后面单独展开。现在可以先记住一个大方向选epoll就是选Linux上性能上限最高的方案。3. epoll的底层原理与关键细节3.1 三个系统调用epoll_create、epoll_ctl、epoll_wait我第二次重写服务器时把epoll的完整流程按三阶段拆开来理解一下子就通透了。第一个阶段是创建epoll实例。epoll_create的参数size在早期内核中用于告诉内核这个实例大概要管理多少个fd内核分配内存时参考这个值但2.6.8以后这个size参数基本被忽略了只要传大于0即可。返回值是一个文件描述符底层对应一个eventpoll对象。要注意这个fd本身也占用文件描述符资源所以程序结束时要记得close否则会泄漏。第二个阶段是注册监视。epoll_ctl的第二个参数op有三个值EPOLL_CTL_ADD表示把这个fd加入红黑树EPOLL_CTL_MOD表示修改红黑树中已存在节点的事件类型EPOLL_CTL_DEL表示从红黑树中删除节点。第四个参数传入的epoll_event结构体里data是一个联合体通常用来存放fd本身或者一个指向自定义结构体的指针。我强烈建议在这里直接放业务上下文的指针而不是只放fd编号这样epoll_wait返回后可以直接拿到对应的连接对象省一次查找。第三个阶段是等待事件。epoll_wait调用后内核把就绪事件从内核链表拷贝到用户传入的events数组里。这里有个性能关键点events数组是用户态分配的内存内核通过copy_to_user把数据填进去如果你每次调用前都把一个较大的数组清零那清零本身也会消耗时间。所以我的习惯是设置一个足够大的maxevents但不清零数组直接用内核返回的nfds作为有效长度去遍历。3.2 水平触发与边缘触发的差异epoll默认是水平触发LT也可以设置EPOLLET切换为边缘触发ET。这两种模式对编程模型的影响非常大。水平触发的语义是只要fd上还有未处理的数据每次epoll_wait都会再次报告这个fd可读。这就比较“笨”但很安全。比如你一次只recv了100字节而内核缓冲区里还有900字节下次epoll_wait还会告诉你这个fd可读直到你把数据全部读完。这样即使你的处理逻辑不完整也不会丢数据最多就是重复唤醒。边缘触发的语义是只有当fd的状态发生变化时epoll_wait才报告一次。比如一个socket从没有数据变为有数据这是从不可读到可读的状态跃迁会触发通知但如果你只读了100字节缓冲区还剩900字节这个fd的状态仍然是可读的并没有发生新的“不可读到可读”的跃迁内核就不会再通知你。所以ET模式下你必须保证每次可读事件触发后用while循环连续调用recv把内核缓冲区里的数据一次性全部读完直到recv返回EAGAIN否则剩余数据就永远留在了内核缓冲区事件却再也等不来通知。ET模式的好处是大幅减少了epoll_wait的唤醒次数同一时刻有大量连接都保持可读状态时LT模式会反复唤醒ET模式只在状态变化时触发一次性能上限更高。但ET模式下任何漏读都会造成数据永久滞留在内核缓冲区非常考验编程细节。我在实践中对ET的体感是短连接服务用LT就够了长连接高吞吐服务值得用ET但必须配合非阻塞IO和循环读。3.3 使用非阻塞IO的必然性无论你选择哪种触发模式只要用了epoll几乎都必须把socket设置为非阻塞。原因很简单epoll的通知只能说明“那一刻”fd可读或可写当你真正调用recv/send时数据量可能已经不够了。以recv为例LT模式下如果数据量小于你设置的recv缓冲区recv可能只返回部分数据ET模式下你必须在循环里读直到返回EAGAIN表示缓冲区已空。如果socket是阻塞的在数据刚好被读完后再次调用recv线程就会永远睡在那里整个事件循环就卡死了。设置非阻塞IO的方法是在创建socket后用fcntlint flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK);对于监听socket来说非阻塞的意义在于accept时如果没有新连接会立即返回EAGAIN而不是阻塞等待这样事件循环就不会因为某个连接的操作被卡住。对于客户端socket来说非阻塞recv在缓冲区无数据时返回-1并设置errno为EAGAIN非阻塞send在发送缓冲区满时也返回-1并设置EAGAIN这两个信号恰好是我们循环读写的出口条件。有一类常见错误是只在客户端socket上设置了非阻塞但忘了把监听socket也设为非阻塞。在连接数暴涨时accept队列里的连接可能一瞬间被取空如果你的监听fd是阻塞的下一次accept就会卡住整个线程这是非常隐蔽的线上故障。我遇到过一次排查了很久才发现监听socket没有设置O_NONBLOCK。4. 一个高并发服务器的完整实现思路4.1 事件驱动架构设计当我们决定用epoll做一个高并发服务器时最重要的不是上来就写代码而是先把事件驱动模型想清楚。下面这张架构是我个人比较推荐的一种主线程只做三件事accept新连接、注册fd到epoll、在epoll_wait返回后分发事件。业务处理逻辑放到线程池里异步执行线程池内部维护一个任务队列。主线程不处理耗时任务所有数据收发也只做“把数据读完/写完”不做协议解析和业务计算。这样的话主线程永远保持轻量任何时刻都在处理新的网络事件而耗时任务被并发执行但不会阻塞网络事件循环。这套模型对应到代码结构上大概是这样一个全局的epoll fd所有活跃连接都在这里面注册一个线程池负责执行协议解析、业务逻辑、响应生成等耗时任务每个连接维护一个状态机记录当前处在“读请求—解析—处理—写响应”四个阶段的哪个位置连接对象里保存读缓冲区、写缓冲区、以及当前任务状态的上下文指针。主线程每次epoll_wait拿到一批就绪fd后对每个fd先判断它属于监听socket还是普通连接socket属于前者就accept出新的fd并注册到epoll属于后者就把事件对应的处理函数丢给线程池。这里要注意线程池执行完业务后还要把待发送的数据填回连接的写缓冲区并注册EPOLLOUT事件主线程在可写事件触发时再调用send把数据发出去。4.2 核心数据结构与接口封装我实现这套服务时习惯先把conn结构体和几个核心函数抽出来因为写下去你就会发现大部分代码其实都是在操作这个结构体。struct conn { int fd; unsigned events; /* 当前关注的epoll事件 */ bool is_listening; /* 是否是监听socket */ char *rbuf; /* 读缓冲区 */ size_t rlen; size_t rcap; char *wbuf; /* 写缓冲区 */ size_t wlen; size_t wcap; void (*handle_read)(struct conn *c); void (*handle_write)(struct conn *c); void *ctx; /* 业务上下文指向一个具体的协议状态机 */ };这里写缓冲区千万不能设计成“每次响应时临时去malloc一个新buffer”的模式。高并发场景下内存分配是非常昂贵的操作频繁分配、释放会造成严重的内存碎片而且malloc本身需要加锁多线程环境下的锁竞争更是灾难。我采用的是每个连接常驻一个读缓冲区和写缓冲区容量按需扩容但不会主动缩容。当一个连接关闭时这两个缓冲区跟conn结构体一起释放。虽然单个连接的内存占用看起来比临时分配大但总体上是稳定的不会被并发波动放大。线程池和主线程之间的任务传递我用了一个简单的无锁队列加互斥锁组合。因为任务量不大互斥锁的开销可以接受但要注意锁的粒度主线程只负责往队列里塞一个函数指针加一个conn指针然后立刻解锁线程池也只负责取出任务执行不持有锁去等待执行结果避免主线程被阻塞。更复杂一点可以设计成无锁队列但在压力达不到百万级连接时互斥锁完全够用而且出bug的概率低得多。4.3 与线程池结合榨干CPU性能线程池的参数设置有一个非常关键的经验线程数不能等于连接数也不能设得太大理想值是CPU核数的1到2倍左右且必须留一个核给主线程事件循环。如果你的机器是4核8线程那么线程池设8到12个就足够了。线程数过多时线程为了抢锁、抢任务队列而产生的竞争开销会抵消并发的收益线程数过少时CPU空闲量大吞吐量上不去。我压测过16核机器上开64个线程和开24个线程的差距后者吞吐量反而高出10%左右原因就是锁竞争减少了。线程池和epoll主线程的交互还有一个经典的坑业务线程处理完任务后需要向连接的写缓冲写入响应数据然后注册EPOLLOUT事件。这个时候如果直接在主线程的epoll实例上用epoll_ctl注册就会出现并发调用epoll_ctl的竞争问题。稳妥的做法是业务线程不直接调用epoll_ctl而是把“需要注册写事件”这个操作也作为一个任务塞回任务队列的head或者另一个高优先级队列等主线程下一次循环处理。我的做法是设计了一个event_queue主线程每次epoll_wait之前先处理这个队列里的事件包括注册写事件、关闭连接等操作。这样就把所有对epoll实例的操作收敛到主线程里彻底消除竞态。5. 压测对比与实际debug经验5.1 三个版本的压测数据对比我自己在开发这套服务的过程中先后写了select版本、poll版本和epoll版本专门在相同机器上做过一轮压测对照。压测工具用的是自己写的一个简单客户端压力器模拟5000个并发连接持续发送心跳包每次服务端收到后原样返回跑满60秒统计吞吐量。测试环境是一台8核16线程的虚拟机Linux 5.15内核服务端进程绑定在8个核心上客户端压测运行在同一台机器的另一个进程里。select版本跑到约900个连接时吞吐量就开始剧烈抖动到1200个连接基本卡死CPU全部消耗在select的内部扫描和用户态遍历上。poll版本能撑到约3000个连接稳定吞吐量约每秒2.2万次请求CPU占用在90%以上继续增加连接数时延迟明显上升。epoll版本在5000个连接下稳定吞吐量约每秒7.8万次请求CPU占用只有60%左右延迟也稳定在1毫秒以内。同样的业务逻辑只是换了IO监视机制差距就是那么大。这份对比也印证了之前的分析select/poll的性能瓶颈在“监控所有fd”的O(N)开销上而epoll的关注点只在“哪些fd发生了事件”这个差异在高连接数下是数量级的。5.2 常见问题排查速查表我在调试这套服务器的过程中整理了下面这张速查表很多问题只要对着表排查基本上十分钟内能确定原因。现象可能原因排查方法连接短时间暴涨后服务卡死backLog设置过小accept队列溢出检查listen第二个参数建议128以上并配合非阻塞accept偶发丢数据客户端表现为响应缺失ET模式下未循环读缓冲区残留数据检查recv是否循环读到EAGAINCPU高但IO事件很少业务线程频繁加锁或线程池线程数过多用perf查看热点函数确认锁竞争占比连接关闭后fd泄漏进程fd数持续上升未在EPOLL_CTL_DEL后close或close后未删除epoll注册项用lsof -p 查看进程fd状态检查清理逻辑epoll_wait返回后同一个fd反复触发可读LT模式下recv不及时数据一直积压设计上应尽快读完并处理或者改用ET模式多个线程同时调用epoll_ctl导致崩溃同一epoll实例被多线程操作所有epoll_ctl统一收敛到单线程事件循环5.3 线上踩坑实录最后分享一个我自己在线上环境里踩过的比较深的坑就是对端关闭连接的检测。当时我的服务在处理EPOLLIN事件时调用recv之后发现返回值是0说明对端正常关闭了连接于是我直接执行了close(fd)。这个逻辑本身没问题问题出在“对端关闭连接时epoll到底会回报哪些事件”。在实际运行中我发现如果只是调用close去关闭一个socket fd而没有先从epoll红黑树里移除之后再有新的连接fd复用了同一个fd编号那么旧的epoll节点仍然挂着新连接的事件会错误地触发布到旧连接上导致状态错乱。正确的做法是一旦检测到recv返回0或EAGAIN就应该标记连接为“待关闭”然后从epoll中DEL这个fd最后再close。这里顺序很重要如果先close再DELfd已经被回收了epoll_ctl DEL时传入的fd可能已经被复用成别的连接反而会误删新连接的事件注册。另外一个很容易被忽略的问题是在做健康检查和心跳超时管理时不要用epoll_wait的超时参数来检测空闲连接。epoll_wait的timeout是“等待新事件的时间”如果有连接一直没有任何事件你永远不知道它是不是还在线。真正的空闲连接检测要靠应用层定时器比如用一个最小堆或者时间轮维护每个连接最后一次活跃的时间每隔一段时间扫描超时连接并主动关闭。很多入门代码把timeout用来做心跳超时这是概念上的误区。还有一点关于accept的细节我想特别强调当监听socket上有EPOLLIN事件时不要只accept一次。因为在高并发场景下一个EPOLLIN事件可能意味着accept队列里有十几个连接排队如果你只accept一个剩余的连接要等下一个事件才能被处理可能出现连接延迟。所以正确做法是在while循环里不断调用accept直到返回EAGAIN为止。这个细节我在最初版本里没有注意当时压测时连接建立的速度比实际低很多一度以为是内核参数问题后来加了循环accept连接建立延迟明显下降。线程池任务队列的容量限制也值得一说。我的实现里任务队列最多容纳一定数量的任务当生产速度远大于消费速度时队列满了以后新任务怎么办。最简单的策略是阻塞主线程但这样会让网络事件循环停顿所以我的做法是任务队列满时将当前epoll_wait的事件数组剩余项直接丢弃等下一轮再处理。因为epoll事件本身是状态性的数据还在内核缓冲区里下轮还会再触发不会丢数据只是延迟处理。这个思路在业务已经过载的时候反而能自然地起到限流作用避免系统被瞬时流量冲垮。我个人在实际操作中的体会是学会epoll的接口只是入门真正的功力在于对事件驱动模型的理解以及写出能够稳定撑住高并发的工程代码。每次遇到线上问题多问一句“这个等待发生在我预期的地方吗”大多数坑就都能提前被看出来。如果你正在做一个Linux环境下的网络服务我建议你亲手把select、poll、epoll三个版本都写一遍再压测对比这种自己跑出来的数据比看一百篇博客都管用。后续我还会把自己在定时器、连接池、内存池上的实践经验整理出来到时候再跟各位详细聊。