
做Linux网络编程的同学几乎绕不开“多路转接”这四个字。它也叫I/O多路复用是select、poll、epoll这套机制的总称。我在接手公司网关项目的头一个月就深有体会不懂多路转接你写的服务器根本撑不住上千个连接。这篇东西不是教科书复述而是我把这套机制从原理到代码完整走一遍的实战笔记适合刚看完《Unix网络编程》但还没写过真正高并发服务的同学也适合那些线上服务偶尔卡顿、想搞明白事件驱动底层逻辑的运维和后台工程师。1. 多路转接到底解决了什么问题1.1 阻塞I/O的天然缺陷先看一个最基础的场景。你用 socket 写服务器accept 阻塞在那里等客户端连接一连接上read 又阻塞在那里等对方发数据。这套代码在实验环境里跑得好好的教学楼的课程设计里永远没问题可一放到真实场景就翻车。翻车的核心原因只有一个阻塞调用把线程绑死在一个文件描述符上。accept 阻塞的时候其他客户端只能排队等read 阻塞的时候这个连接哪怕一天不发数据这一个线程就一天干不了别的。我见过不少实习生写的聊天室开两个终端互相发消息没问题一旦五六个客户端同时进来服务端就开始丢连接因为线程全都卡在 read 上了。用生活里的话说这就是一个人守着一个水龙头水不出来他就干等着。你雇十个人来看十个水龙头那成本就上去了可如果一个人能同时看着一百个水龙头谁来了水就去接谁那问题就解决了。多路转接解决的就是这个“一个人同时盯很多个水龙头”的问题。1.2 进程/线程模型的代价有人说那简单来一个客户端就 fork 一个进程或者开一个线程去处理总行了吧。这是很多人走的第二条路也是另一道坎。进程模型的问题在于代价太贵。一个进程有独立的地址空间、页表、文件描述符表fork 一次的开销不小线程虽然轻一些但线程栈默认就有 8MB 的虚拟内存空间开一千个线程光栈空间就压掉 8GB 虚拟内存再加上频繁的上下文切换CPU 时间一大半都花在切进切出上。更别提线程之间还要处理共享数据加锁锁竞争一上来性能雪崩。所以真实的 C10K 问题一万个连接靠进程/线程一对一扛是扛不动的。大家开始意识到绝大多数连接是空闲的真正有数据要读的只是少数。让内核告诉我“哪些 fd 有数据了”而不是我一个个去问“你好了没”这才是正解。多路转接就是把这个“告诉”的机制做成了系统调用。1.3 多路转接的核心思路把等待交给内核多路转接的思想一句话就能概括把一大批文件描述符交给内核内核帮忙盯着一旦其中有任何一个就绪了就告诉我“有哪些”我再逐个去处理。这里关键在于“就绪通知”而不是“逐个问询”。服务端代码不再为每个连接单独阻塞而是统一阻塞在一个多路转接调用上。连接从几千涨到几万循环处理逻辑不变变的只是内核帮你筛选出来的就绪集合。用个更贴合开发的比喻这就像你在工位上同时对接十几个群每个群里都在聊天你不可能开十几个窗口轮流盯刷新。你想要的是一次性把所有群的新消息列出来看到哪个有你才点进去。多路转接就是这个“把新消息汇总”的机制select、poll、epoll 是三代不同的实现效率天差地别。2. select、poll、epoll三兄弟选型拆解2.1 select位图驱动的老前辈select 是最早出现、也是跨平台性最好的一套 API。Windows、Linux、macOS 都支持你要是写简单的跨平台网络代码select 几乎是唯一通用选择。它的用法不复杂准备一个 fd_set 位图把要监听的 fd 对应位置 1然后调用 select。内核会把就绪的事件更新在位图上返回就绪数量你再用 FD_ISSET 去逐个检查哪些 fd 就绪了。fd_set read_fds; FD_ZERO(read_fds); FD_SET(listen_fd, read_fds); FD_SET(client_fd, read_fds); int max_fd client_fd listen_fd ? client_fd : listen_fd; int ret select(max_fd 1, read_fds, NULL, NULL, NULL); if (ret 0) { if (FD_ISSET(listen_fd, read_fds)) { // 有新的客户端连接 } if (FD_ISSET(client_fd, read_fds)) { // 有数据可读 } }select 有三个硬伤面试里也最爱问。第一fd 数量上限fd_set 是位图大小由 FD_SETSIZE 决定Linux 上默认 1024也就是说你一个 select 最多盯 1024 个描述符改成内核参数也撑死就是 4096。第二线性扫描内核每次都要遍历你传入的所有 fd判断每个有没有就绪复杂度 O(n)fd 越多越慢。第三位图会被内核改写每次 select 调用后fd_set 被内核原地修改下次调用前你必须重新 FD_SET 一遍导致用户态和内核态的两次数据拷贝省不掉。我见过一些老项目里用 select 扛了上千个连接然后 CPU 占用率奇高把 FD_SETSIZE 改大编译后确实能跑但 O(n) 的扫描开销摆在那里治标不治本。2.2 poll去掉1024上限的中间派poll 是 select 的直接改良版把位图换成了 pollfd 数组于是没有了 1024 的上限。每个 pollfd 是一个三元组fd、events要监听的事件、revents内核返回的就绪事件。事件用 bitmask 表示比如 POLLIN 表示可读POLLOUT 表示可写。struct pollfd fds[MAX_CONN]; fds[0].fd listen_fd; fds[0].events POLLIN; int ret poll(fds, nfds, -1); if (ret 0) { for (int i 0; i nfds; i) { if (fds[i].revents POLLIN) { // 处理 fds[i].fd } } }poll 比 select 进步的地方在于events 和 revents 是分开的字段内核只改 revents不会把你要监听的事件类型搞丢所以不用每次重新初始化数组的上限看内存不再有 1024 的硬限制。但 poll 的性能模型和 select 一样还是 O(n) 的线性扫描。每次 poll 调用内核都要遍历你传进去的每个 pollfd检查对应 socket 的等待队列上有没有事件发生。连接数一多这个遍历成本依然很扎眼。tcpdump 抓包看不出问题但你用 perf 去看内核热点一下就能看到 poll 的扫描逻辑在烧 CPU。所以 poll 在实战里通常是“能用但不好用”的定位——它适合连接总数不大、单次事件处理很快的场景真要到几千上万的连接更多人会直接上 epoll。2.3 epoll事件驱动才配叫高并发epoll 是 Linux 独有的机制不再扫描全部 fd而是由内核主动回调把就绪的 fd 放进一个就绪链表里。用户调用 epoll_wait 时内核把这个链表里就绪的事件批量复制出来有多少给多少。就绪事件是“推”出来的不是“查”出来的所以复杂度是 O(1)严格说是 O(就绪事件数)跟总连接数无关。我拿一个真实的对比数据说话。之前给公司内部写的压测工具模拟 2 万并发连接用 poll 实现的服务端每轮 poll_wait 之后要遍历 2 万个 fd 检查 reventsCPU 单核直接跑到 90% 以上换成 epoll 之后同样连接数下 CPU 占用不到 30%因为 epoll_wait 返回的通常只有几十个真正活跃的事件。三个 API 是铁三角epoll_create 创建实例epoll_ctl 增删改事件epoll_wait 等待就绪。这段代码值得你背下来。int epfd epoll_create1(0); struct epoll_event ev; ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev); struct epoll_event events[1024]; int n epoll_wait(epfd, events, 1024, -1); for (int i 0; i n; i) { // 处理 events[i].data.fd }三者的对比我用一张表总结面试、写方案、做选型都能直接拿来用维度selectpollepoll数据结构fd_set 位图pollfd 数组红黑树 就绪链表最大连接数FD_SETSIZE 限制默认1024无硬限制无硬限制就绪检测方式线性扫描 O(n)线性扫描 O(n)回调通知O(1) 级别事件是否被内核改写会需重新设置不会revents 独立不会事件由 ev.data 管理是否支持边缘触发否否是EPOLLET跨平台Windows/Linux/macOS多数 Unix 系统仅 Linux3. epoll的核心细节与底层原理3.1 三个系统调用搞定一切epoll_create1(flags)负责创建 epoll 实例返回一个匿名的文件描述符。flags 传 0 就可以如果你想系统帮你清理继承行为可以传 EPOLL_CLOEXEC这样 exec 新程序时 epoll fd 会自动关闭避免 fd 泄漏到子进程。epoll_ctl(epfd, op, fd, event)是管理接口op 有三个取值EPOLL_CTL_ADD 注册新的 fdEPOLL_CTL_MOD 修改已注册 fd 的关注事件EPOLL_CTL_DEL 把 fd 从 epoll 实例中移除。第三个参数 event 指向 struct epoll_event里面有两个字段events 是事件掩码data 是个联合体最常用的是 data.fd存你这个 fd 是谁也可以用 data.ptr 指向自己定义的连接对象省得后面再用 fd 去查结构体。epoll_wait(epfd, events, maxevents, timeout)是阻塞等待接口。events 是内核向外吐就绪事件的数组maxevents 是你一次性最多拿多少个timeout 是超时毫秒数-1 表示永久阻塞。返回值 n 告诉你这一次有几个就绪事件然后你只遍历 events[0] 到 events[n-1] 就够了。这里有个使用细节经常被忽略同一个 fd 上的事件不会重复返回。也就是说如果某个 fd 同时可读又可写epoll_wait 只会把它放进就绪链表一次你需要自己通过 events[i].events 里的 EPOLLIN 和 EPOLLOUT 位来判断具体发生了什么。这也意味着一次循环处理一个就绪 fd 时最好把读和写都照顾到避免一边被反复触发、另一边一直被饿着。3.2 回调机制与红黑树epoll 背后的数据结构就是面试八股里最常说的“红黑树 就绪链表”。红黑树用来管理所有注册进来的 fdkey 是 fd 编号查找、插入、删除的复杂度都是 O(log n)就绪链表存放已经触发事件的 fd链表节点用指针互连避免拷贝大块数据。真正让 epoll 高效的关键是回调函数。每个被注册的 socket 都有自己的等待队列当 socket 上发生事件比如数据包到达触发可读时内核会调用一个 epoll 预先挂上去的回调函数 ep_poll_callback这个回调做的事很简单把对应的 epitem 从红黑树的状态里摘出来挂到就绪链表尾部唤醒在 epoll_wait 上睡眠的进程。所以 epoll_wait 本质上是到就绪链表里取事件取不到就睡被回调唤醒后再取。整个过程不遍历、不轮询、不全员检查考察的 fd 再多活跃的就那几个开销自然可控。这跟 select/poll “每次把所有 fd 扫一遍”的思路有本质区别。3.3 LT与ET水平触发和边缘触发的岔路口这是 epoll 特有的问题也是面试区分普通选手和深度选手的分水岭。LTLevel Triggered水平触发是默认模式只要 fd 还有数据没读完每次 epoll_wait 都会通知你哪怕你上次没处理。ETEdge Triggered边缘触发从“无数据”变为“有数据”这个瞬间才通知一次如果这次你没把数据读完后续不再通知直到下一次新数据进来。用电梯比喻LT 是电梯到了这层你人还没进去门就一直开着反复提醒你“快进”ET 是电梯只在到达瞬间响一声你没赶上就得等下一趟。LT 模式下你 read 的时候想读多少读多少读一半也没关系下次 epoll_wait 还会再通知你。所以 LT 写起来容错率高业务逻辑不容易出错。ET 模式则要求你在触发的那一次把数据读到 EAGAIN也就是非阻塞 read 返回“暂时没数据了”为止否则数据就残留在内核缓冲区里要等下一波数据才能触发轻则延迟重则丢数据。ET 模式配合非阻塞 socket 是标配而且这是我在代码评审里反复给新人纠正的一点。下面这个读循环是 ET 下必须的写法while (1) { ssize_t rn read(fd, buf, sizeof(buf)); if (rn 0) { // 处理数据 } else if (rn 0) { // 对端关闭连接清理 fd close(fd); break; } else { if (errno EAGAIN || errno EWOULDBLOCK) { break; // 数据读完了 } // 真正的读错误 close(fd); break; } }3.4 百万连接是怎么撑起来的C10K 之后还有 C10M 的问题网上经常提到“百万连接”。百万连接意味着什么进程其实不需要一百万条线程但只要有一百万个 fd 注册在 epoll 里每个 fd 就是一个活跃程度很低的连接。单机百万连接对内存的占用是能算的每个 socket 的内核收发缓冲区、文件结构体、套接字结构体加起来大约 3KB一百万个就是 3GB 左右的内核内存这个量级在今天的服务器上不是问题。真正考验的是用户态能不能高效处理就绪事件。epoll 的事件通知模型决定了哪怕连接再多处理开销也只跟就绪数量相关。这就是它成为 Linux 高并发服务标配的根本原因。不过一个进程开一百万个 fd 本身有 ulimit 限制得改 nofile。我记得调优时用命令ulimit -n 1048576还要在/etc/security/limits.conf里把 hard nofile 也放开否则进程起来还是会碰到“Too many open files”。这类底层限制越到高并发场景越要注意。4. 实操从零写一个epoll回声服务器4.1 先看完整代码纸上谈兵没意思我把一个能跑的 epoll 回声服务器完整贴出来。逻辑很简单客户端连上来之后你发什么它回什么但代码里包含了 listen fd 的接入、普通 fd 的数据读取、连接关闭处理这三个核心环节可以直接拿去做更复杂协议的基础骨架。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include fcntl.h #include sys/epoll.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define MAX_EVENTS 1024 #define BUFFER_SIZE 4096 int set_nonblock(int fd) { int flags fcntl(fd, F_GETFL, 0); if (flags 0) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main() { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); return -1; } int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); set_nonblock(listen_fd); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(9000); addr.sin_addr.s_addr htonl(INADDR_ANY); if (bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); return -1; } if (listen(listen_fd, 128) 0) { perror(listen); return -1; } int epfd epoll_create1(0); if (epfd 0) { perror(epoll_create1); return -1; } struct epoll_event ev; ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev); struct epoll_event events[MAX_EVENTS]; while (1) { int n epoll_wait(epfd, events, MAX_EVENTS, -1); if (n 0) { if (errno EINTR) continue; perror(epoll_wait); break; } for (int i 0; i n; i) { int fd events[i].data.fd; if (fd listen_fd) { // 处理新连接用循环把积压的连接全部 accept 掉 while (1) { struct sockaddr_in peer; socklen_t len sizeof(peer); int conn_fd accept(listen_fd, (struct sockaddr *)peer, len); if (conn_fd 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; if (errno EINTR) continue; perror(accept); break; } set_nonblock(conn_fd); ev.events EPOLLIN; ev.data.fd conn_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, ev); printf(new connection: %s:%d\n, inet_ntoa(peer.sin_addr), ntohs(peer.sin_port)); } } else { // 处理普通连接的可读事件 char buf[BUFFER_SIZE]; while (1) { ssize_t rn read(fd, buf, sizeof(buf)); if (rn 0) { write(fd, buf, rn); } else if (rn 0) { epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); printf(connection closed\n); break; } else { if (errno EAGAIN || errno EWOULDBLOCK) break; epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); break; } } } } } close(epfd); close(listen_fd); return 0; }编译直接一条命令gcc -o epoll_echo epoll_echo.c然后./epoll_echo启动另外开一个终端用nc 127.0.0.1 9000就能测。4.2 逐段拆解关键逻辑我挑几个容易踩坑的细节展开说。第一listen fd 也要设成非阻塞。如果不设accept 循环里最后一个 accept 会因为没有新连接而阻塞住整个事件循环就卡死了。这里 accept 返回 EAGAIN 就退出内层循环是一个标准写法LT 和 ET 都适用。第二注册事件前先设置好 data.fd。ev.events EPOLLIN; ev.data.fd conn_fd;这两行的顺序不能反因为 epoll_ctl 会把整个 ev 结构的内容拷贝进内核如果你先 epoll_ctl 再改 data.fd内核里存的还是旧值后面 epoll_wait 返回的事件就会拿错 fd。第三读到 rn 0 时必须主动摘除再 close。很多人只 close 忘了 epoll_ctl DEL结果 fd 虽然关了但 epoll 实例里还残留着这个条目后面内核在整理等待队列时会碰到一个已经关闭的 socket轻则内存泄漏重则触发奇怪的行为。规范做法是先 epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL)再 close(fd)。第四写操作没做非阻塞处理。我这个示例是回声write 的数据量跟 read 相同正常不会阻塞。但真实业务里如果对端收得慢写缓冲区塞满write 会占用事件循环。上线前一定要把写也改造成 epoll 管理第一次 write 返回 EAGAIN 时给 fd 注册 EPOLLOUT等可写事件触发后再把剩余数据发出去。4.3 从LT切到ET要注意什么如果你想把上面的服务器切到 ET 模式只改一处是错的。很多人以为把ev.events EPOLLIN改成ev.events EPOLLIN | EPOLLET就够了但配套的读循环必须已经是“读到 EAGAIN 为止”的写法。好在我上面代码里的读循环就是这么写的所以直接切过去就能跑。真正要在 ET 下留意的是处理不完整的风险。假设一个客户端一次发来 8KB 数据但 4KB 到了、4KB 还在路上。ET 模式下第一次触发时你只能读到 4KB读到 EAGAIN 就退出。没过多久剩下 4KB 到达又会触发一次可读事件你再读 4KB。只要循环写得对数据不丢。怕就怕有人偷懒读一次就退出那剩下的 4KB 就要等到下个数据包才能被捞出来协议对不上线上就会出“消息延迟”的诡异 bug。另外 ET 模式下有个经典问题大数据块可能在一次触发里读不完导致业务层需要缓存半包。比如你要解析 1MB 的完整消息但内核缓冲区一次只给了 64KB你要把每次 read 到的数据拼起来直到拼满一个完整消息再处理。这涉及用户态缓冲区管理代码复杂度一下就上来了。所以我给新人的建议一直是默认用 LT只有明确知道数据处理逻辑完全可控时才上 ET。ET 的性能收益主要在减少 epoll_wait 的重复通知对多数业务场景来说LT 的那点重复开销根本不算瓶颈。5. 常见问题与排错实录5.1 五个高频坑我全踩过第一个坑是ET 模式下数据残留导致连接假死。现象是客户端发了数据服务端一次都没处理日志里没有任何 read 记录。排查方向先看是不是注册事件时漏了 EPOLLET 之外的条件再看读逻辑是不是没有循环到 EAGAIN。我之前复查过一个同事的代码他把 ET 和 LT 的读逻辑混着写read 只调用一次就返回本地测试偶尔过压力一上来就丢消息。第二个坑是epoll_wait 返回 0 当成错误。timeout 传 0 时epoll_wait 不会阻塞没有就绪事件就直接返回 0。很多人一看到返回 0 就以为出错了马上 break 退出循环。实际上返回 0 是合法的超时结果只有返回 -1 才是出错。类似的还有被信号打断返回 -1 且 errno 为 EINTR 的情况正确的处理是 continue 而不是退出。第三个坑是惊群效应。多线程/多进程模型下如果每个 worker 都在同一个 epoll fd 上调用 epoll_wait一个连接到达时所有 worker 都可能被唤醒但只有一个能抢到连接其余白白空转。解决思路常见的有几种单独用一个线程做 accept再把 conn_fd 分发给 worker或者内核版本够新的话注册时加上 EPOLLEXCLUSIVE 事件标志它会限制同时只唤醒一个等待者。第四个坑是file descriptor 泄漏。表现是连接数只涨不跌用ss -tan能看到大量 CLOSE_WAIT 状态的连接。原因九成是服务端没调用 close或者 close 之前忘了摘除 epoll 事件。定位时可以lsof -p pid | wc -l看 fd 数量也可以每隔几秒采样一次观察是否持续上升。第五个坑是可读可写的处理顺序。一次事件里你拿到一个 fd 的 EPOLLIN | EPOLLOUT先写后读还是先读后写有讲究。如果先处理写写到一半遇到 EAGAIN你还得重新注册 EPOLLOUT一折腾读数据的时机就被延后了。我的习惯是先读后写先把对方发来的数据收进来再处理要发出的数据这样既不容易丢数据也让逻辑更清晰。5.2 问题速查表把高频问题整理成一张表排查时直接对号入座现象大概率原因处理动作客户端发数据服务端无反应fd 未注册进 epoll或注册事件被覆盖检查 epoll_ctl 调用路径确认 data.fd 赋值正确偶发延迟消息要重发才能收到ET 模式读不完整循环 read 直到 EAGAIN连接数只增不减出现大量 CLOSE_WAIT服务端漏 close 或漏 EPOLL_CTL_DELlsof 查 fd代码评审清理路径多 worker 下负载集中在单个 worker惊群导致竞争单线程 accept或用 EPOLLEXCLUSIVE压测时 epoll_wait 频繁返回 0timeout 设置不合理或事件注册过多检查 timeout 语义评估是否注册了不活跃的 fd进程打开文件报 Too many open filesulimit nofile 限制ulimit -n 调大改 limits.conf写完数据对方收不到写阻塞在日志写缓冲满未用 EPOLLOUT 管理改造成事件驱动写5.3 性能调优还能做什么代码逻辑没问题之后想再压榨性能有几个方向值得试。先从内核参数下手。TCP 层面的 backlog决定 listen 队列长度我用listen(listen_fd, 128)是演示用真实服务可以把它调大配合net.core.somaxconn和net.ipv4.tcp_max_syn_backlog一起看。fd 数量上限前面提过不再重复。再说多线程模型。最稳妥的架构是“主线程 accept 多个 worker 线程各自持有自己的 epoll fd”。主线程 accept 到新连接后按某种哈希规则比如 fd 对 worker 数取模把 conn_fd 丢给对应 worker 的 epoll 实例。这样每个 worker 处理的就绪事件都是自己那部分互不干扰还能利用多核。注意跨线程传递 fd 时worker 注册事件前要保证 fd 是非阻塞的并且各 worker 的事件处理逻辑要独立避免共享状态加锁。最后聊聊EPOLLONESHOT。注册事件时加上它意思是这个 fd 上的事件只会触发一次触发完后内核自动把这个事件从 epoll 实例里摘除。这样做的好处是如果一个 fd 上的数据需要耗时处理比如解密、解压事件不会在处理期间被反复触发避免多个线程同时操作同一个 fd。处理完成后业务代码再手动重新注册。在逻辑复杂的网关类服务里这个标志能省掉不少同步成本。我个人的体会是多路转接这套东西最难的不是调用 API而是建立“事件驱动”的思维方式——你的代码不是从上往下执行完就结束而是“哪个 fd 有事就处理哪个处理完继续等下一个”。从 select 到 poll 再到 epoll本质是在思考同一个问题怎么让内核用最小的开销告诉我哪条连接该被处理了。把 select 的原理吃透再去理解 epoll 的红黑树和回调机制会发现一切水到渠成。最后再分享一个小技巧写这类服务调试阶段一定要开着详细日志把“注册 fd、触发事件、处理完成、摘除 fd”这四个节点的关键信息都打出来。很多线上问题看着像并发 bug打开日志一看其实是事件注册顺序错了或者漏了一步清理。日志是排查网络问题最朴素也最可靠的工具比什么高级调试器都好使。多路转接的代码框架就那么大真正决定服务稳定性的恰恰是这些不起眼的细节。