
如果有人问我Linux服务端开发里最值得吃透的一个系统调用是什么我会毫不犹豫地说是epoll。原因很简单你在用的Nginx、Redis以及大部分高并发网关服务底层在Linux上几乎都离不开它。很多人写过网络程序能用select或者多线程应付几千连接但一旦并发量到了几万、几十万老办法就撑不住了——CPU飙升、文件描述符耗尽、上下文切换爆炸问题一大堆。这篇文章我会从epoll解决的问题开始讲深入它的内核机制再给出一份可直接运行的ET模式回显服务器代码最后把工程里最容易踩的五个大坑和调优经验一并拆开。epoll是Linux内核提供的一种I/O事件通知机制专门用来高效管理大量文件描述符上的读写事件。它的核心价值用一句话说就是在内核里帮你盯着成千上万个连接哪个连接有数据可读、哪个连接可以写都由内核主动通知你不需要你自己一遍遍去轮询。这篇文章适合正在学习高并发网络编程的开发者也适合那些已经能用select写并发服务、但还想进一步搞懂底层原理、想让自己的服务扛更高并发的人。我会尽量少讲空泛概念多给能直接落地的操作和判断依据。1. 为什么你的并发上不去阻塞I/O与select的三重困境1.1 一个连接一个线程的路子为什么走不通很多初学者写高并发服务第一反应是用多线程主循环accept新连接每来一个连接就开一个线程去read和write。这种方案在几百连接的规模下确实能跑但你一旦把并发拉到几千、上万问题就全冒出来了。首先是线程资源太贵。Linux线程默认栈大小是8MB这8MB是虚拟内存实际物理内存按需分配但上万条线程光栈空间就占掉80GB的虚拟地址空间内存压力和线程调度开销都不可忽视。其次是上下文切换一台8核机器上如果跑着5000个活跃线程内核光做线程切换就能把CPU吃干净。更别提多线程共享数据时的加锁问题锁竞争一多程序跑得比单线程还慢。那不用多线程用非阻塞I/O加轮询行不行这就是select的原始思路。1.2 select的轮询模型为什么扛不住大规模连接select的工作方式很直接用户程序把要监视的文件描述符放到三个位图读、写、异常里调用select时把这三个位图整个传给内核内核遍历所有fd检查状态变化然后再把结果拷贝回用户态。这套机制有三个硬伤。第一个硬伤是fd数量上限。fd_set的大小受FD_SETSIZE限制Linux默认是1024也就是说select最多只能同时监视1024个fd。虽然你可以修改这个宏重新编译内核或者用poll来绕过但本质上这反映了select的设计思路就是给少量fd用的。第二个硬伤是O(n)的轮询开销。内核每次都要把所有fd扫一遍调用方也不知道到底是哪个fd就绪了还得自己再遍历一遍。假设你有1万个连接每次调用select都要遍历1万次其中可能只有几个连接真正有数据。这种空转的比例一高CPU基本都在做无用功。第三个硬伤是用户态和内核态之间的数据拷贝。select每次调用都要把整个fd集合从用户态拷到内核态内核检查完再拷回来。fd数量越大拷贝的开销越明显。面对高频的accept、read、write操作这些拷贝成本会不断累积。所以select适合的场景是连接数量不多、每个连接不是一直有数据、对响应延迟要求不苛刻。一旦到了C10K这个量级——1万个并发连接每个连接可能隔几秒才收发一次数据——select的O(n)遍历就成了最大瓶颈。1.3 epoll的破局思路把每次全部扫描变成谁有事通知谁epoll从根本上换了一种思路你把要监视的fd注册到内核里内核用数据结构维护住这些fd当某个fd上有事件发生时内核通过回调机制把这个fd放到一个就绪链表里你调用epoll_wait时只需要从就绪链表里取出已经准备好的事件而不用去遍历所有注册过的fd。这才是epoll高效的本质——它把复杂度从每次都O(n)扫描全部连接降到了只处理有事件的那一小撮连接。事件少的时候epoll_wait几乎是瞬间返回事件多的时候返回的也都是有效事件没有空转。后文会详细拆解这套机制但你先记住一个结论epoll适合大量连接、但每个连接活跃度不高的场景。如果一个连接每秒要收发几千次数据那任何多路复用方案都救不了你你的瓶颈在业务逻辑和网络带宽上。2. epoll核心机制拆解红黑树、就绪链表与事件回调2.1 三个系统调用如何分工epoll的使用只涉及三个系统调用分工非常清晰epoll_create/epoll_create1创建一个epoll实例内核返回一个文件描述符。这个实例是后面所有操作的基础。epoll_ctl往这个实例里注册、修改、删除你想要监视的文件描述符。epoll_wait等待事件发生把就绪的事件拿出来处理。这个设计和select完全不同。select是把fd集合当作参数反复传给内核epoll则是先把fd寄存在内核里每次等待时只需要给内核传一个等待结果的缓冲区。fd寄存这件事很重要。这意味着如果某个fd的状态没有变化它不会在内核和用户态之间来回拷贝只有当一个fd真的有事件发生时它才会出现在epoll_wait返回的数组里。#include sys/epoll.h int epoll_create1(int flags); // flags 传 0 或 EPOLL_CLOEXEC int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // op: EPOLL_CTL_ADD / EPOLL_CTL_MOD / EPOLL_CTL_DEL int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); // timeout: -1 永久阻塞0 立即返回0 等待毫秒数epoll_event结构体的两个关键字段events表示关心的事件类型EPOLLIN、EPOLLOUT、EPOLLERR等data是一个联合体通常存入fd或者指向业务结构体的指针。struct epoll_event { uint32_t events; // 事件掩码 epoll_data_t data; // 用户数据 }; typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t;这里有个容易被忽略的设计data是联合体而不是结构体。如果你同时想存fd和对应的业务上下文不能写data.fd fd; data.ptr ctx;因为后者会覆盖前者。你需要定义一个结构体把fd和上下文都放进去然后用data.ptr指向这个结构体。2.2 就绪监听为什么是O(1)回调机制详解epoll实例在数据结构上主要靠两样东西干活一棵红黑树和一个就绪链表。红黑树负责管理所有注册到epoll实例上的fd。epoll_ctl执行ADD操作时内核会在红黑树里插入一个节点DEL操作时删掉节点MOD操作时查找节点并修改事件类型。红黑树的查找、插入、删除复杂度都是O(log n)对于几万几十万的fd规模来说这个开销几乎可以忽略。就绪链表则负责保存当前有事件发生的fd。当某个fd上有I/O事件发生时比如网卡收到了数据内核会调用这个fd对应的回调函数把该fd对应的epoll条目挂到就绪链表上。epoll_wait做的事情相当简单检查就绪链表是否为空不为空就把链表里的条目复制到用户传入的events数组里然后清空链表。这个过程最巧妙的地方在于它不需要主动扫描所有fd而是靠事件触发回调来填充就绪链表。选select时你得遍历1万个fd才知道哪几个有数据选epoll时内核直接告诉你这3个fd有数据。这个区别在活跃连接占比很低的场景下就是数量级上的差距。2.3 水平触发LT与边缘触发ET的本质区别epoll默认是水平触发Level-Triggered也可以设置成边缘触发Edge-Triggered。这两种模式的区别一句话说就是LT是只要缓冲区还有数据就会持续通知你ET是只在缓冲区状态发生变化的那一刻通知你一次。用生活场景类比你坐在工位上等快递快递到了前台。LT模式下前台每隔一会儿就打电话催你快递到啦快去拿快递到啦快去拿直到你去拿为止。ET模式下前台只在快递刚到的时候打一次电话你要是那天没听见电话快递就在前台放一天她也不会再提醒你。从编程角度看区别更实际。监听一个fd的EPOLLIN事件LT模式下只要fd的接收缓冲区里有数据每次epoll_wait都会返回这个fd的事件。哪怕上一次你只读了一个字节没读完的数据还会在下次epoll_wait时再次通知你。ET模式下只有缓冲区从空变为有数据或者有新数据到达时epoll_wait才会返回一次。这次通知之后如果缓冲区里还有数据没读完epoll_wait也不会再返回这个fd的事件直到又有新的数据到达。ET模式的编程要求非常严格你必须在收到事件通知后一次性把数据全部读完通常要循环read到返回EAGAIN。同理EPOLLOUT事件也是一样收到通知后要一直写直到返回EAGAIN。因为错过了事件通知不会补发读不干净或者写不干净后果就是数据滞留或者卡死。Nginx选择ET模式是因为它希望减少重复的事件通知次数。但在实际工程中很多团队也选择用LT因为LT模式下即使业务代码写得不严谨顶多是多几次事件回调不容易出现漏读数据导致连接卡死的严重问题。ET模式省掉了一部分系统调用和调度开销但需要更小心的代码。具体怎么选到第五节我会给一个比较落地的建议。3. 手写一个基于epoll的并发回显服务器3.1 服务器骨架设计光看原理容易飘真正把代码跑起来才知道各种细节有多重要。下面我用C语言写一个完整的基于epoll ET模式的回显服务器客户端发什么服务器原样返回什么。整体设计结构如下监听socket设为非阻塞注册到epoll实例关注EPOLLIN事件。每个接受的客户端socket也设为非阻塞注册到epoll实例关注EPOLLIN。服务端使用单线程事件循环epoll_wait拿到就绪事件区分是新连接到达还是已有连接的读写事件。读数据用ET模式循环read直到EAGAIN写数据直接write不额外注册EPOLLOUT回显服务器数据量小write不会阻塞简化逻辑。不引入多线程保证示例逻辑清晰。3.2 核心代码与关键注释包含必要的头文件和错误处理宏#include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include fcntl.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include sys/epoll.h #define MAX_EVENTS 1024 #define BUFFER_SIZE 4096 static void die(const char *msg) { perror(msg); exit(EXIT_FAILURE); } static int set_nonblocking(int fd) { int flags fcntl(fd, F_GETFL, 0); if (flags -1) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); }set_nonblocking这一步看起来不起眼但在ET模式下是生死攸关的。因为ET要求你循环read直到EAGAIN如果socket是阻塞的最后一次read会卡在那里等数据整个事件循环就停摆了。建立监听socket并注册到epoll实例int main() { int listen_fd socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0); if (listen_fd 0) die(socket); int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(9000); if (bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)) 0) die(bind); if (listen(listen_fd, 128) 0) die(listen); int epfd epoll_create1(0); if (epfd 0) die(epoll_create1); struct epoll_event ev; ev.events EPOLLIN | EPOLLET; ev.data.fd listen_fd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev) 0) die(epoll_ctl: listen_fd); struct epoll_event events[MAX_EVENTS]; ... }注意两点一是SOCK_NONBLOCK在socket创建时直接传入少调一次fcntl二是SO_REUSEADDR一定要设置否则服务端重启时因为TIME_WAIT状态导致bind失败你会莫名其妙排查半天。下面是事件循环for (;;) { int n epoll_wait(epfd, events, MAX_EVENTS, -1); if (n 0) { if (errno EINTR) continue; die(epoll_wait); } for (int i 0; i n; i) { int fd events[i].data.fd; if (fd listen_fd) { // 新连接到达ET模式下必须一直accept到EAGAIN for (;;) { struct sockaddr_in caddr; socklen_t clen sizeof(caddr); int cfd accept(listen_fd, (struct sockaddr *)caddr, clen); if (cfd 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; perror(accept); break; } set_nonblocking(cfd); ev.events EPOLLIN | EPOLLET | EPOLLRDHUP; ev.data.fd cfd; epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, ev); } } else { if (events[i].events EPOLLRDHUP) { // 对端关闭直接回收 close(fd); continue; } if (events[i].events EPOLLIN) { char buf[BUFFER_SIZE]; ssize_t r; // ET模式必须循环读直到EAGAIN for (;;) { r read(fd, buf, sizeof(buf)); if (r 0) { write(fd, buf, r); // 回显 } else if (r 0) { close(fd); // 对端正常关闭 break; } else { if (errno EAGAIN || errno EWOULDBLOCK) break; // 数据读完了正常退出循环 break; // 其他错误直接退出 } } } } } }这份代码能跑但它刻意简化了写事件。真实环境里如果回显的是大文件一次write可能只写了一半缓冲区就满了。这种情况下你需要把剩余数据缓存起来注册EPOLLOUT事件等socket可写时再继续写。这里不做展开但你在设计自己的服务时一定得考虑写不完怎么处理。3.3 编译与压测验证编译指令很简单gcc -O2 -o epoll_echo epoll_echo.c ./epoll_echo用一个简单的脚本模拟并发连接来验证效果。我习惯用Python写快速验证脚本import socket import threading def test_conn(idx): s socket.socket() s.connect((127.0.0.1, 9000)) payload bhello-%d % idx s.sendall(payload) data s.recv(1024) assert data payload s.close() threads [] for i in range(1000): t threading.Thread(targettest_conn, args(i,)) threads.append(t) t.start() for t in threads: t.join() print(all ok)如果你手头有压测工具也可以用wrk或者ab做更严格的验证。不过要注意回显服务是短连接还是长连接对压测结果影响很大压测时务必设置好keep-alive参数。3.4 代码里容易被忽略的边界条件上面这段代码看着简单但至少有三个边界条件容易被新手忽略。第一个是对EPOLLRDHUP的处理。客户端直接拔网线或者进程崩溃时内核不一定能及时触发EPOLLIN读返回0但EPOLLRDHUP能帮你感知到对端半关闭或异常断开。注册EPOLLRDHUP后再对端断开时及时关闭fd能避免fd泄漏。第二个是accept返回EAGAIN后的处理。ET模式下如果一次只accept一个连接然后直接跳出循环那么TCP连接队列里可能还排着好几个等待accept的连接而因为listen_fd的ET事件已经被消费了这些连接可能要等下一个新连接到达时才会被处理。这会造成连接处理延迟严重时大量连接堵塞在队列里。第三个是错误处理。read返回-1时除了EAGAIN/EWOULDBLOCK之外其他错误如ECONNRESET、EPIPE都应该走关闭连接的逻辑。很多初学者只处理EAGAIN结果连接被对端重置后fd一直不释放慢慢就把fd耗尽了。4. 实战避坑ET模式下最常见的五个问题4.1 accept不循环连接队列积压这个问题上面提了一句但值得单独拉出来再敲一次ET模式下listen fd的EPOLLIN通知来了你要用while循环一口气把连接队列里的连接全部accept掉直到返回EAGAIN。为什么这样设计因为ET模式只通知一次。假设某一瞬间有10个客户端同时发起连接内核把listen fd的可读事件投递给你你只accept了一次就break剩下9个连接还留在全连接队列里。而这个listen fd的EPOLLIN事件已经消费完了如果没有新的连接到达这9个连接就一直在队列里躺着客户端那边可能已经发数据等到超时了。这个问题在LT模式下不会出现因为LT模式只要队列里还有连接epoll_wait就会持续通知你。我见过不少人从LT切到ET后发现连接老是建不上排查到最后都是这个原因。4.2 read不读到EAGAIN数据卡在内核缓冲区和accept同理ET模式下EPOLLIN事件触发后你必须循环read直到read返回EAGAIN。如果你只read一次就退出会发生什么假设客户端一次性发来了2KB数据你的read buffer是4KB一次read能读完这没事。但假设客户端发来的是20KB数据一次read只能读走4KB剩下16KB还在内核缓冲区里。ET模式下内核不会再触发新的EPOLLIN通知这16KB就卡在缓冲区里了。如果客户端在等你的回应而你的程序在等客户端发新数据两边就互相等死了。这个问题的现象非常隐蔽程序跑起来后偶发性的响应超时而且复现很难通常在大流量场景才暴露。解决方式就是严格循环read到EAGAIN或者更保险地把读逻辑改成读到EAGAIN或者读满缓冲区为止。4.3 非阻塞I/O是ET的前提不是可选项ET模式下socket必须是非阻塞的。如果你的socket是阻塞的ET事件触发后你循环read最后一次read没有更多数据时会一直阻塞在那里等新数据整个事件循环就卡死了。其他所有连接的读写事件都不会再被处理一个连接的恶意行为就能打垮整个服务。所以务必要在你创建socket之后、注册到epoll之前确保fd被设置成了O_NONBLOCK。而且要注意accept出来的fd不会继承监听fd的非阻塞属性必须对新fd单独设置。4.4 EPOLLONESHOT与多线程处理的冲突如果你的服务用多线程处理业务逻辑epoll_wait拿到fd后交给一个工作线程去read和write。这种模式下同一个fd可能被两个epoll_wait线程同时拿到导致两个线程同时操作同一个socket数据错乱。解决方式是给fd注册EPOLLONESHOT事件这个事件触发一次之后就自动被禁用当前处理器线程处理完再通过epoll_ctl的EPOLL_CTL_MOD重新注册。这样保证同一时刻只有一个线程在操作这个fd。使用EPOLLONESHOT时还要注意重新注册的时机一定要在所有I/O操作完成后进行如果过早重新注册事件可能再次被触发处理线程又乱了。通常我建议在工作线程彻底处理完这个fd的数据后才重新MOD。4.5 惊群问题与EPOLLEXCLUSIVE多线程或多进程服务中多个线程同时调用epoll_wait等待同一个epoll实例当有一个新事件到来时所有等待线程都会被唤醒但最终只有一个线程能真正处理这个事件其他线程白醒一场。这个现象叫惊群。唤醒和睡眠是有开销的高并发下惊群会放大CPU浪费。Linux内核在较新版本中提供了EPOLLEXCLUSIVE事件标志注册事件时带上这个标志内核在唤醒时只会唤醒等待队列中的一个线程而不是全部。这个标志适合多个工作线程共同等待同一个epoll实例的场景能有效减少惊群。不过也要注意EPOLLEXCLUSIVE只能配合EPOLL_CTL_ADD使用不能用在EPOLL_CTL_MOD上而且内核版本需要4.5以上。如果你的生产环境内核版本较低就得结合业务场景考虑是让多个线程各自wait还是用一个线程专门wait、再把fd分发给工作线程。这两种架构各有优劣不能一概而论。5. epoll性能调优从能用到好用5.1 events数组大小与timeout的选择epoll_wait的第二个参数events是用来接收就绪事件的数组maxevents是数组大小。很多人图省事直接开个4096大小的数组然后就不管了。但这里有个性能细节值得注意如果同一个时刻就绪的事件数超过了maxevents内核只拷贝前maxevents个剩下的就绪事件会留在就绪链表里下一轮epoll_wait继续返回。这不会丢事件但如果事件积压得厉害会放大I/O延迟。maxevents设多大合适我通常以预估的峰值活跃连接数来定。如果你的服务有5万长连接但同一时刻大概只有几百个连接有数据往来maxevents设1024就够了如果服务类型是短连接高吞吐峰值请求数可能成千上万maxevents可以适当设大。太大会浪费内存太小会导致事件分批处理、增加延迟。这个值本质上是在内存和延迟之间取平衡。timeout参数也经常被误解。-1表示永久阻塞适合纯粹的事件驱动服务0表示立即返回适合你在事件循环里还要做其他事情比如定时任务的场景正数表示最多等待多少毫秒。如果你在事件循环里同时要处理心跳超时可以设一个合理的timeout值每次epoll_wait超时返回后就检查一遍心跳表这样不用额外开定时器线程。5.2 文件描述符上限与内存预算epoll能管理的fd数量理论上受两个限制进程级的RLIMIT_NOFILE和系统级的/proc/sys/fs/file-max。很多人调试时遇到Too many open files第一反应是改ulimit -n但很可能系统级的file-max也不够。两个都要看ulimit -n # 进程级通常默认1024或65535 cat /proc/sys/fs/file-max # 系统级所有进程合计上限调ulimit -n时注意要同时调硬限制和软限制。很多人在shell里直接ulimit -n 1000000结果因为硬限制不够而失败得先ulimit -Hn看看硬限制是多少配合普通用户权限做相应设置。内存方面每个注册到epoll的fd在内核里会占用一个epitem结构量级大概在几百字节具体看内核版本和编译选项。如果你有100万连接epoll本身占用的内核内存大约几百MB。这个数字听起来不小但和一个连接一个线程的方案比起来内存开销已经低了一个数量级。5.3 LT还是ET工程上的选型建议这个问题没有绝对答案但我可以给出比较务实的建议。如果你在写一个偏底层的网络库对性能有极致追求而且团队有足够的能力处理ET的各种边界条件选ET。Nginx就是典型的ET用户但Nginx的代码质量和review流程不是每个团队都有的。如果你在写业务服务用的是别人封装好的事件循环比如libevent、libuv、Netty那直接用框架的默认设置就好不要自己去切ET。框架在LT模式下已经做了大量优化你再手动切ET反而可能引入bug。如果你自己写事件循环而且团队的C/C水平参差不齐我建议LT起步。LT模式下的代码即便写得不规范也只是多几次事件通知不会因为漏读导致连接卡死。先把功能跑对压测后有明确的性能瓶颈了再针对性地切ET优化。笔者的经验是很多服务切了ET之后性能提升并没有想象中明显因为真正的瓶颈往往在业务逻辑、锁竞争或者磁盘I/O上而不是多路复用本身。Some final tuning tips from my own practice:不要把业务逻辑放在epoll_wait的事件处理循环里直接同步执行尤其是涉及数据库查询、RPC调用的逻辑。一个慢请求会拖慢所有连接的响应。事件循环只负责快速收发包业务逻辑交给独立线程池处理用队列缓冲。如果同一个fd上既需要读又需要写尽量在EPOLLIN事件里把数据读完缓存下来在EPOLLOUT事件里统一写入避免频繁修改事件类型。每次epoll_ctl的MOD操作都有系统调用开销。大规模长连接服务一定要开启TCP keepalive或者自己实现应用层心跳否则半开连接客户端崩溃但TCP连接未释放会慢慢耗尽你的fd和内存。epoll_wait返回事件时也要注意检查EPOLLERR、EPOLLHUP、EPOLLRDHUP这些非正常状态。压测时别只盯着QPS看多看两个维度CPU使用率是否均衡、有没有系统调用异常飙升strace可以帮你辅助追踪。如果CPU都耗在上下文切换上说明你的线程模型有问题跟epoll没关系。我维护网关服务时踩过最深的一个坑是fd被意外关闭后没有从epoll实例里删除后续又用同一个fd号注册了新连接。因为fd号会被内核回收复用这会导致新连接的事件莫名其妙触发旧连接的处理逻辑排查起来非常痛苦。解决方式很简单——每次close操作都记得调用epoll_ctl做EPOLL_CTL_DEL或者用一个独立的标志位管理fd的生命周期。服务端高并发这条路epoll只是起点但它是最关键的一块地基。把它的原理和坑吃透了后面再看Netty的Reactor模型、Nginx的worker进程间负载均衡、Redis的ae事件循环都会顺很多。这批代码和踩坑经验算是压箱底的东西希望也能帮你把服务调得更稳一些。