1. 为什么高并发场景绕不开IO多路复用1.1 从“阻塞等待”到“一个线程管所有连接”先从一个最朴素的场景说起。假设你要写一个聊天室服务端客户端A、B、C同时连上来每个连接都有消息要收发。最直觉的写法是每个连接开一个线程里面阻塞在read上等消息来了就处理。这个方案在小规模下没问题连接数到几百个就有点吃力了线程切换、内存占用、锁竞争一起上系统响应开始飘。等连接数到上万线程方案基本跑不动。问题出在哪里阻塞IO的模式是“一个连接配一个线程”线程在read上睡着不干活的这段时间CPU在忙着切换上下文。一个线程默认栈是8MB左右这是常见发行版的默认值一万个线程光是栈就要吃掉80GB内存服务器根本扛不住。更浪费的是绝大多数连接处于空闲状态等消息可能等好几秒这段时间线程完全无所事事但它占着的资源却没释放。IO多路复用的核心思路其实特别朴素与其让一万个线程各自等各自的连接不如让一个线程把这一万个连接的“等待”统一交给内核去盯谁有消息了谁就吱一声我再逐个处理。这样线程数从一万降到个位数内存占用、切换成本全部降下来。这个“统一盯着所有连接”的机制就是IO多路复用。打个比方。阻塞IO的模型就像一个接线员只负责一位客人客人不说话他也得陪着等一边等一边呼吸都费电。IO多路复用则是把一万位客人都接到同一个总机接线员谁拿起电话谁亮灯接线员只需要处理亮灯的那一路剩下的继续等着就行。这个总机就是内核那盏灯就是“文件描述符就绪事件”。1.2 C10K问题的本质与三个核心模型网络编程圈子里有个经典问题叫C10K就是单台服务器能不能同时处理一万个并发连接。C10K难在哪里难的不是CPU算力不够而是如何高效管理这一万个socket的IO等待。传统“一连接一线程”的思路线程数量本身就是瓶颈。IO多路复用的出现就是为了正面回答这个问题一个线程通过内核同时监控成百上千个文件描述符哪个可读、哪个可写内核负责通知应用层拿到通知再干活。在Linux系统上IO多路复用有三种经典实现select、poll、epoll。这是十几年前就定型的方案至今仍是网络服务端的主流底座。两个关键词贯穿始终文件描述符fd和事件就绪通知。fd是操作系统给每个打开的IO通道编的号socket就是fd的一种epoll监控的其实是“这个fd的什么事件发生了”。读事件代表有数据到达可以read了写事件代表缓冲区有空位可以write了。三种模型的设计思路完全不同。select和poll比较朴素的每次调用把全部fd列表从用户态拷进内核内核逐个检查状态再把结果拷回来用户态再遍历一遍找就绪的fd。epoll则是反过来的思路先把fd“注册”进内核的事件表内核维护一棵平衡树事件发生的时候内核把就绪fd挂到一个链表上用户态只需要取走就绪列表。一个O(n)全量扫描一个O(1)只取就绪这就是epoll高并发的底气。2. 选型全解select、poll、epoll到底差在哪2.1 select经典轮询模型的瓶颈在哪里先说select。它的API是int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);调用方把监听的fd分成三个集合可读、可写、异常然后丢给内核去“盯着”。返回之后用户态需要用FD_ISSET(fd, set)逐个判断哪些fd真的就绪了。select有几个硬伤实测下来都让人头疼第一个是fd数量上限。fd_set是一个bitmap在Linux上通常只有1024位也就是最多监控1024个fd。在现代高并发场景下1024根本不叫高并发第一个门槛就把你卡死了。虽然有改内核宏然后重新编译的玩法但生产环境很少有人这么折腾。第二个问题是全量拷贝和全量扫描。每次调用select都得把整个fd_set从用户态拷贝到内核态内核检查完还得拷回来。监控的fd越多拷贝越慢、遍历越慢复杂度是O(n)。哪怕你只关心两个fd也要把一千个bit全部过一遍。第三个问题是内核会修改传入的fd_set。select返回时未就绪的fd会被从集合里清掉。这意味着下一次调用前你必须重新把需要监控的fd全部加回去。我早期写代码就这样每次循环开头FD_ZERO、再逐个FD_SET代码又臭又长漏加一个fd就是线上事故。还有一个容易踩的细节timeout传NULL表示永久阻塞传一个全零的timeval表示完全不阻塞。很多人以为传NULL和传0是一回事实际上前者会在没有事件时一直挂起后者会立刻返回。如果写的是循环监听这个细微差别可能导致CPU飙到100%因为busy loop不断触发系统调用。2.2 poll去掉数量限制但本质没变poll的设计比select稍微好一点。int poll(struct pollfd *fds, nfds_t nfds, int timeout); struct pollfd { int fd; short events; // 关心的监听事件 short revents; // 返回时实际发生的事件 };poll把fd和事件放在一个结构体数组里events是调用方填的“我关心什么”revents是内核返回的“实际发生了什么”。这两者分离之后内核返回时不会修改events字段所以不需要像select那样每次重新构造监听集合这是个很大的改进。同时poll用数组存储不受1024的限制fd数量取决于系统能打开多少fd通常可以调到几万甚至更多。但poll仍然是“每次调用全量扫描”的思路复杂度依然是O(n)并且每次调用还是要把整个数组从用户态拷到内核态。连接数到几千的时候还好到几万的时候每次epoll_wait一两个微秒能拿到的就绪事件poll可能要几十微秒甚至上百微秒因为CPU得把几万个fd全部遍历一遍。这就是“能扛但扛得累”的状态。poll还有一个timeout0的特殊语义立即返回所有当前已就绪的事件。这个特性偶尔能派上用场比如想非阻塞地刷一遍事件。但平时用的时候注意区分它和timeout-1永久阻塞的差别。2.3 epoll高并发服务的地基epoll是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);epoll_create在内核里创建一个事件表返回一个epoll专用的fd。epoll_ctl用来往事件表里注册、修改、删除某个fd的监控op传EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL。epoll_wait就是阻塞等待返回多少个事件就处理多少个。epoll高效的核心在于两套数据结构内核里维护的红黑树存储所有注册的fd就绪链表只挂已经发生事件的fd。每次epoll_wait直接返回就绪链表里的内容不需要扫描全部fd。注册过的fd如果没有事件根本没机会占用你的处理时间。复杂度从O(n)降到O(k)k是实际就绪的连接数这在大量空闲连接的场景简直是质的飞跃。还需要注意一点epoll的fd本身也是一类正常的文件描述符用完必须close。同时它的数量受限一个高并发服务进程可以同时开多个epoll实例但也要控制fd总量。2.4 三张表看懂select、poll、epoll的取舍对比项selectpollepoll底层数据结构fd位图数组pollfd数组红黑树就绪链表支持的最大连接数1024通常受FD_SETSIZE限制受系统fd限制可调大主要受内存和系统fd限制远大于前两者每次调用的拷贝方式全量拷贝fd_set进内核返回再拷贝出来全量拷贝pollfd数组进内核只在epoll_ctl注册时少量拷贝epoll_wait只返回就绪列表查找就绪fd的复杂度O(n)O(n)O(k)k就绪数量内核是否会修改调用方参数会fd_set会被清空不会revents单独返回不会事件表独立维护水平触发LT支持支持支持边缘触发ET不支持不支持支持适用场景fd数量少代码简易fd数量较多但没有极高并发诉求万级甚至百万级连接的长连接场景这张表做选型就够了。连接少于几百、代码越简单越好select没毛病。连接几千想省点事poll可以。目标就是高并发、大量长连接直接上epoll别犹豫。3. 实操从select到epoll的一次完整改造3.1 起点用select写的简易echo服务端先看一份用select写的echo服务端代码量少适合做对比基线。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define MAX_FD 1024 int main(int argc, char *argv[]) { int listenfd socket(AF_INET, SOCK_STREAM, 0); if (listenfd 0) { perror(socket); exit(1); } 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_port htons(8080); addr.sin_addr.s_addr htonl(INADDR_ANY); if (bind(listenfd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); exit(1); } listen(listenfd, 16); fd_set rfds, allfds; int maxfd listenfd; FD_ZERO(allfds); FD_SET(listenfd, allfds); printf(select echo server listening on 8080\n); while (1) { rfds allfds; int nready select(maxfd 1, rfds, NULL, NULL, NULL); if (nready 0) { perror(select); break; } // 新连接到来 if (FD_ISSET(listenfd, rfds)) { struct sockaddr_in cli; socklen_t len sizeof(cli); int connfd accept(listenfd, (struct sockaddr *)cli, len); if (connfd 0) { perror(accept); continue; } printf(new connection: %s:%d\n, inet_ntoa(cli.sin_addr), ntohs(cli.sin_port)); FD_SET(connfd, allfds); if (connfd maxfd) maxfd connfd; } // 遍历所有连接处理可读事件 for (int i 0; i maxfd; i) { if (i listenfd) continue; if (FD_ISSET(i, rfds)) { char buf[1024]; int n read(i, buf, sizeof(buf)); if (n 0) { if (n 0) printf(client closed, fd%d\n, i); else perror(read); close(i); FD_CLR(i, allfds); } else { write(i, buf, n); // echo给客户端 } } } } close(listenfd); return 0; }这段代码有几个关键的“select式操作”每次循环rfds allfds重新拷一份因为select会改动集合遍历时从0扫到maxfd每个fd都要FD_ISSET判断一次。前几个连接跑起来非常顺畅但连接数一涨这个遍历就成了明显的热点。我实际测过把MAX_FD改大到4096、同时模拟大量半开连接连接建立但不发数据的测试select版本的CPU占用会明显偏高因为每次循环都要扫描四位数的fd位。这就是O(n)扫描的实际代价。3.2 改造核心替换成epoll版本把上面这套逻辑搬到epoll需要改动的地方比想象中少核心骨架不变变的只是“如何等待就绪事件”。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/socket.h #include sys/epoll.h #include netinet/in.h #include arpa/inet.h #define MAX_EVENTS 1024 int main(int argc, char *argv[]) { 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_port htons(8081); addr.sin_addr.s_addr htonl(INADDR_ANY); bind(listenfd, (struct sockaddr *)addr, sizeof(addr)); listen(listenfd, 16); 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[MAX_EVENTS]; printf(epoll echo server listening on 8081\n); while (1) { int nready epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i 0; i nready; 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) { perror(accept); continue; } printf(new connection fd%d\n, connfd); ev.events EPOLLIN | EPOLLRDHUP; // EPOLLRDHUP用于感知对端关闭 ev.data.fd connfd; epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, ev); } else { char buf[1024]; int n read(fd, buf, sizeof(buf)); if (n 0) { if (n 0) printf(client closed, fd%d\n, fd); else perror(read); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); } else { write(fd, buf, n); } } } } close(epfd); close(listenfd); return 0; }这段代码和select版本逻辑几乎一比一listenfd只负责acceptconnfd负责数据收发。最明显的区别是不再需要全量扫描——nready是多少就处理多少events[i]就是内核告诉你“这个fd就绪了”。连接数从100涨到10000这个循环的复杂度不会跟着涨只跟当前有消息的连接数有关。这里的EPOLLRDHUP是个容易被忽略的细节。单独监听的EPOLLIN在客户端主动close时Linux内核通常还是会给一个可读通知read会返回0所以单独用EPOLLIN也能感知关闭。但遇到对端半关闭shutdown写方向或者一些异常情况EPOLLRDHUP能更准确地反映连接状态的“另一端关闭”建议加上。实测中处理HTTPS类的协议时这个事件偶尔会把没读完的数据一起带出来处理逻辑要写成“先读到EAGAIN为止”不能读一次就停。3.3 从select迁到epoll真正要注意的四个坑改造本身很快真正容易出问题的是下面四个细节。第一个是listenfd也要加入epoll而不是像select方案里每次单独检查。很多人写epoll的第一版把新连接accept写到了主循环外结果客户端连上来没有任何响应排查半天发现listenfd压根没注册进事件表。第二个是epoll_ctl(EPOLL_CTL_DEL)之后要立刻close(fd)否则fd泄漏。fd泄漏在高并发进程里是最阴险的问题它不像内存泄漏会触发OOM而是慢慢积累到进程可以打开的fd上限然后突然“Too many open files”导致所有新连接握手直接失败。第三个是超时时间的语义。epoll_wait的timeout参数传-1是永久阻塞传0是立即返回当前就绪列表传正数N毫秒表示最多等N毫秒。如果写事件循环需要定期做一些非IO任务比如心跳检测、超时清理可以传一个20到50的timeout让循环每隔这段时间醒一次做杂务。第四个是返回的nready可能等于0。尤其是timeout传了正数之后超时返回0是正常情况不代表任何错误代码要处理这种for循环零次的情况不要误判为异常退出。3.4 性能对比同一个服务两种实现差多少为了量化差异我在一台配置普通的虚拟机上压过一轮模拟2万个连接其中只有100个连接持续互发消息。select版本因为每次调用要全量遍历CPU占用一直在高位大量时间花在系统调用和遍历上epoll版本的CPU占用低很多只有就绪连接在消耗资源。压测数据会受机器配置和内核版本影响没法给一个统一的绝对值但方向上几乎不会变连接数越往上涨epoll的优势越明显尤其是“连接很多但活跃的少”这种典型长连接场景。反过来如果连接数只有几十活跃度又很高select和epoll的差别基本感觉不出来这时候代码简洁性反而更重要。4. LT与ET边缘触发到底怎么用才不踩坑4.1 水平触发与边缘触发的机制差异epoll有两种触发模式这是比API本身更容易让新手栽跟头的地方。水平触发LT是默认模式语义跟select、poll一致只要fd上有未读完的数据每次epoll_wait都会上报。边缘触发ET则反直觉fd状态从“没有数据”变成“有数据”的那一刻只上报一次。如果这次没把数据读完剩下的数据并不会触发下一次上报直到新的数据再次抵达改变fd的状态。用一个生活例子理解你的门铃就是一个边缘触发器有人按一下门铃响一次。水平触发则是门铃响过之后只要你没开门查看它每隔几秒又响一次直到你开门检查完为止。ET模式就是“我告诉过你一次了你爱处理不处理反正我只说这一次”。那么ET模式的目的是什么就是为了减少无用的事件通知次数。LT模式下只要你没读完每次epoll_wait都会把这个fd重新塞到就绪列表里大量缓慢读取的客户端会产生大量重复通知。ET模式下内核只在状态变化时报一次应用层必须一口气把所有数据读完从而强制你实现“循环读直到EAGAIN”的高效读取逻辑。代价是要格外小心丢数据。4.2 ET模式下最基本的三个编码约束第一必须把socket设为非阻塞。因为ET模式要求“把所有数据读完”如果你阻塞在read上一旦缓冲区被读空了read会卡住等新数据整个线程就卡死了。只有非阻塞socket在缓冲区空时立即返回EAGAIN你才能判断“读完了可以处理业务”。int flags fcntl(connfd, F_GETFL, 0); fcntl(connfd, F_SETFL, flags | O_NONBLOCK);这是注册进epoll之前必须做的配置遗漏了就直接报EAGAIN处理异常或者卡死线程。第二必须循环read直到EAGAIN。正确姿势是这样的while (1) { char *buf ...; ssize_t n read(fd, buf offset, sizeof_buf - offset); if (n -1) { if (errno EAGAIN || errno EWOULDBLOCK) break; // 全部读完正常退出 else { perror(read); // 处理错误关闭fd break; } } else if (n 0) { // 对端关闭 break; } else { offset n; // 如果数据太长这里需要考虑缓冲扩容或者分次处理 } }第三一个fd的读写循环要一个线程内处理完。ET模式下你对某个fd只收到一次通知处理到一半挂起或者把fd交给别的线程很容易漏掉数据。实际项目中常用做法是ET模式配合一个用户态缓冲区read循环把数据捞进缓冲区之后再统一进入业务逻辑。这样可以避免“数据没读完但业务逻辑已经跑起来”的竞态。4.3 LT和ET的选型建议我个人的经验是业务代码不是性能关键路径时无脑用LT。很多生产级服务用的就是LT模式比如常见的HTTP库和RPC框架的默认实现通通是LT。LT代码好写、逻辑好理解、不容易丢数据遇到慢客户端也能稳定地逐次消费事件。只有当单个连接的数据吞吐极大、且你非常确定自己能把读循环写对时才考虑ET模式。ET省掉的重复通知对CPU的节省在真正的千万级长连接场景里才明显普通业务规模根本体现不出差异。5. 常见问题与线上排查经验5.1 epoll_wait返回-1并报EINTR高并发服务最常见的第一个坑epoll_wait被信号中断。当进程收到信号比如SIGCHLD、SIGTERMepoll_wait可能立即返回-1errno是EINTR。如果代码没处理这个分支可能直接break掉整个事件循环服务悄悄挂掉。正确处理是while (1) { int nready epoll_wait(epfd, events, MAX_EVENTS, -1); if (nready -1) { if (errno EINTR) continue; perror(epoll_wait); break; } // 正常处理nready个事件 }5.2 listenfd的accept惊群问题多线程或者多进程模型下多个工作线程同时调用epoll_wait等待listenfd可读新连接来临时内核会把这一个事件通知到多个等待线程可能多个线程同时调用accept。Linux 2.6后内核解决了accept本身的惊群只会唤醒一个线程但epoll层级仍然可能存在这个问题。实践中通常有两种方案一是只让一个线程专门负责accept接受完再把connfd通过队列分发给工作线程二是使用EPOLLEXCLUSIVE这是Linux 4.5之后提供的epoll事件专用标志位表示该事件只唤醒等待队列中的一个线程能有效避免惊群。多实例部署或者网关类服务强烈建议用后者。5.3 工作线程把epoll_wait的timeout设为0导致CPU飙高排查过一个很隐蔽的问题某服务的CPU长期维持在100%上下。翻代码发现线程循环里写的是epoll_wait(epfd, events, MAX, 0)本意是“非阻塞地检查一下有没有就绪事件”但业务逻辑里如果没有事件就立刻下一轮循环这就成了busy loop空转消耗CPU。正确的做法是用正数timeout比如1毫秒或者明确区分“无事件就阻塞等待”和“定期醒来干活”两种场景。这个坑排查起来很费时间因为错误值在测试环境几乎看不出异常流量一大CPU就失控做审查时一定要盯住这个参数。5.4 fd耗尽与Too many open files高并发服务跑一段时间后突然大量连接失败日志里出现“Too many open files”十有八九是fd泄漏。排查路径很简单用lsof -p 进程号 | wc -l看当前fd数量持续观察是否持续上涨再用ls /proc/进程号/fd查看具体哪个fd堆积起来了通常要么是某个accept后注册失败导致connfd没关要么是epoll_ctl DEL失败导致fd留在事件表里。此外还有系统硬限制ulimit -n的调整现代发行版默认可能是1024如果你明确要做高并发服务部署前就要调成比如65535甚至100万并且确认systemd或容器配置里的limit也改了不然进程起来后突然爆掉。6. 我自己实际用下来的几点体会做网络服务这几年IO多路复用是绕不开的地基但不同阶段对它的理解完全不一样。刚开始学的时候以为把select换成epoll就万事大吉实际跑起来才发现高并发不是单点技术而是fd管理、缓冲区设计、线程模型、信号处理、系统调优一起配合的结果。一个让我印象很深的教训某一次我把连接的读写逻辑写得特别复杂缓冲区要跨线程共享结果epoll ET模式下漏了“读完才能进入下一轮业务”的约束数据莫名其妙就丢了抓包抓了两个小时才定位到。折腾完之后我的体会是能用LT就用LT能简化就简化epoll的价值在于帮你把复杂度降下来而不是把复杂度变着花样堆上去。如果你刚接触这个领域我建议的路线是先手写一个基于select的echo服务再改成epoll版本跑通之后去读源码里围绕epoll封装的那一层的实现细节。这个过程中会自然碰到惊群、EINTR、fd泄漏这些问题踩过一次比读十遍文档都管用。等真正理解了select、poll、epoll的区别与适用边界再看任何高并发框架的IO线程模型都会觉得一目了然因为这层IO底座放之四海而皆准。