资讯动态

C++网络编程之epoll从入门到实践:事件驱动模型与高并发I/O

发布时间:2026/10/22 6:18:32 来源:尧图企业网站定制
说实话C网络编程走到“并发”这一步几乎所有人都会被同一个东西拦住——epoll。我之前在写系列前三篇的时候一直没敢碰这个话题因为 epoll 的门槛不在于 API 本身多难背而在于它背后那套“事件驱动”的思维方式和 Linux 内核的行为逻辑跟新手习惯的“阻塞 多线程”套路完全是两回事。但这东西确实是 Linux 下高并发网络服务的核心基础Nginx、Redis、各种游戏网关、IM 服务底层都有它的影子。你如果想把 C 网络编程真正用到生产级而不是只停留在课堂作业epoll 是绕不过去的一关。这一篇我用最直接的思路把 epoll 模型拆开讲清楚从“为什么不用多线程 阻塞 IO”讲起到三大 API 的参数细节再到 LT 和 ET 两种模式的本质区别和代码实现。整篇文章会带一个完整的 echo 服务器示例代码可以直接拷贝去跑跑完你再回头看原理会感觉通透很多。我尽量用做项目过程中真实会踩到的坑来讲不是照着 man page 念一遍适合已经会用 socket 写基础服务、但面对“高并发”“事件驱动”这些词还比较模糊的读者。1. 为什么说你写的阻塞模型撑不到高并发先认清问题在哪1.1 一连接一线程的代价到底有多高早期做网络服务的思路很直观每个客户端连接来了就单独开一个线程或者进程去伺候它读数据、处理、回数据这个线程就一直占着。我最初实习的时候接触过一个内部工具就是这个模型——当时连接数也就几十个跑得好好的但后来压测到一千多个连接的时候机器直接卡死在那问题就出在线程数量上。来算一笔账。Linux 下创建一个线程默认栈大小是 8MB注意这只是虚拟内存但也能说明问题——一千个线程就要约 8GB 虚拟地址空间加上线程调度器的开销上下文切换会把你 CPU 时间片全部吃干净。更重要的是绝大多数连接在某个时刻其实什么都没干就躺在那等着数据过来。阻塞模型里线程一阻塞就挂起操作系统为了维护这一堆挂起线程代价非常大。1.2 select 和 poll 的轮询扫描缺陷第一波改进是 select 和 poll。思路是把一堆 socket 文件描述符fd交给内核告诉它“你帮我盯着只要有任意一个可读或可写就通知我”。听着比阻塞模型聪明但实际效率并不理想原因有这么几个每次调用 select/poll 都得把全部 fd 集合从用户态拷贝到内核态调用完再拷回来内核拿到 fd 列表是线性扫描每个 fd 去查状态O(n) 复杂度fd 数量一多就完蛋内核返回的只是“有事件发生了”这个粗粒度结果你还要自己再遍历一遍所有 fd搞清楚到底是哪个 fd 可读、哪个可写又是 O(n)。加上内核还有 FD_SETSIZE 限制select 默认最多监听 1024 个 fd。改宏重新编译内核可以解决一部分问题但治标不治本。这就像你去奶茶店点单每次点完都要从第一家问到第一百家“你这个牌子东西备好了吗”全问一遍才知道该去哪取。连接少无所谓几万连接就是灾难。1.3 epoll 的三板斧是怎么砍掉上述开销的epoll 的改进恰好打在这几个痛点上核心是三个设计内核维护一棵红黑树来管理被监听的 fd增删 fd 只要树操作复杂度 O(log n)不需要每次全量复制每个就绪的 fd 会挂到内核的一个就绪链表上epoll_wait 直接从链表中取结果复杂度 O(1)这里说的 O(1) 是就绪事件的个数而不是全部连接数通过 epoll 文件描述符来引用整棵树和链表用户态和内核态只需维护这一个大对象而不是一次性拷贝海量数据。这三个特性让 epoll 非常适合“大量连接、少量活跃”的场景——一万个连接同时挂着某一时刻真正有数据要处理的可能就几十个epoll 只揪着这几十个处理就行。你要理解的是epoll 不是让你多线程写得变简单而是让单个线程能管住上万连接从而从根本上减少线程的数量。2. 三大核心 API 逐个拆开这些参数和返回值的坑我先说在前面2.1 epoll_create1只记住参数填几就行老版本里有 epoll_create我记得它需要传一个 size 参数看起来是能监听的数量但内核从 2.6.8 之后其实根本不拿它当真只是做一个预分配大小的提示。所以我现在都用 epoll_create1原因主要是它能传 EPOLL_CLOEXEC 标志。EPOLL_CLOEXEC 这个标志只有当你程序用到 fork exec 的时候才有意义。它保证 fd 不会在 exec 之后泄漏给子进程。不过呢它带来的问题也比较隐蔽——假如你的程序逻辑里确实有意让子进程继承 epoll fd比如某些特殊的多进程架构那 EPOLL_CLOEXEC 反而会成为坑。我见过有人 fork 之后在子进程里还能用父进程的 epoll id 做操作结果因为加了这个标志子进程直接拿不到排查了半天。所以要不要加先想清楚自己是不是真的会 exec。2.2 epoll_ctl增、删、改的边界条件epoll_ctl 的函数签名就三个操作EPOLL_CTL_ADD、EPOLL_CTL_DEL、EPOLL_CTL_MOD。我在最开始写的时候老犯一个迷糊到底什么时候用 MOD实际上ADD 是首次把某个 fd 交给 epoll如果要修改这个 fd 关心的事件类型比如之前只关心读现在也想关心写就用 MOD。用 ADD 去改已存在的事件会返回 EEXIST用 MOD 去操作一个从来没加过的 fd会返回 ENOENT。这两个 errno 其实都有用我经常在调试的时候靠它判断逻辑走到哪一步了。另一个边界条件是当你主动 close 了一个 fd它在 epoll 里的注册也会自动移除这个是内核帮做的不用手动 DEL。但是要注意如果你 fork 出了子进程父子进程共享了同一个 fd 指向同一个文件描述子进程 close 掉它父进程的 fd 还在这时候不会触发 epoll 里的移除。这个细节很容易导致你以为是“连接关闭了”其实只是某个进程关掉了自己的那份引用。2.3 epoll_wait不要把 timeout 当成“刚睡两秒”epoll_wait 里最容易被忽略的是 timeout 参数。它的单位是毫秒传 -1 表示永久阻塞直到有事件传 0 表示立即返回不阻塞传正整数表示最长等多久。这里的“最长等多久”意思是最多就等你这几毫秒如果期间一直没事件超时返回返回 0。但是注意了在时间精度上epoll_wait 并不保证你传 100 就精准 100 毫秒内核调度可能有几毫秒偏差。如果你拿它做定时器那是要走另一套方案的。更关键的是timeout 到期返回 0 和事件到达返回 0都要在逻辑上明确处理很多人只关心返回数量大于零的情况把返回 0 的时候逻辑漏掉了这在心跳检测和超时清理的场景里是很容易出问题的。第二个大坑是 events 数组的容量。你调用 epoll_wait 时传入的数组大小决定了内核最多把多少个就绪事件拷贝给你。如果就绪事件超过了数组容量剩下的会留在就绪链表里等下一次调用继续取。所以不要在大流量场景把数组设置得太小我一般给 128 或 256根据峰值流量调整太小会导致事件积压延迟增大。第三个坑在返回值上如果返回 N那只有 events 数组前 N 个是有效的你不用管剩下的是不是盐。很多新手一上来就遍历整个数组访问到无效成员数据出错之后找半天不知道是哪的问题。3. LT 和 ET你真正需要理解的是“什么时候会再通知你一次”3.1 水平触发就是“你不处理我就一直烦你”水平触发LT, Level Triggered是 epoll 的默认模式说人话就是只要这个 fd 对应的事件还有条件没被处理完epoll_wait 就会反复返回它。举个例子fd 上有 100 字节数据可读你只 read 了 50 字节那么下一次 epoll_wait 还会返回这个 fd因为你内核接收缓冲区的数据还没读完。这个模式的好处是逻辑简单用户代码写得比较随意也不容易卡死是新手学 epoll 最该从它开始的原因。select 和 poll 天生就是水平触发所以从老代码迁移到 epoll 保持 LT思维负担最小。3.2 边缘触发就是“只响一次响完自己想办法”边缘触发ET, Edge Triggered名字听着挺玄乎核心只有一句话只有当 fd 的状态从“无事件”变成“有事件”的那一瞬间epoll_wait 才会通知你并且只通知一次。接下来如果你没有把所有数据读完它也不会再管你直到下次有新数据到达产生新的事件。因此ET 模式强制要求两件事。第一连接的 fd 必须设置成非阻塞——因为你不知道事件通知这一次之后还有没有剩余数据只能循环读读到返回 EAGAIN 才知道读完要是阻塞模式下循环读很容易把一个连接的读线程堵死在那里。第二处理读事件的时候必须一次性把数据能读多少读多少也就是通常说的“循环 read 到 EAGAIN”。你要是还抱着 LT 那种“读一次就完事儿”的写法去写 ET就会遇到诡异的效果连接明明有数据程序却不响应了因为通知的次数已经用完了而你还没读干净。3.3 新手最容易产生的两个误解我见过不少人和我当初一样的想法既然 ET 事件通知次数少是不是性能一定比 LT 好实际上不一定。ET 匹配高性能的前提是你处理逻辑理得很清晰能保证每次事件来都把所有数据搬走如果你每次回调处理得拖泥带水或者系统调用频繁切换阻塞/非阻塞ET 反而可能因为代码复杂导致 bug 更多。第二个误解是“LT 会频繁唤醒肯定效率低下”。但 Linux 下 epoll 的 LT 实现其实做了优化并不是你数据读了一半就每次 epoll_wait 都来烦你它只在相关内核缓冲区状态不满足时继续触发实际性能并不差。同一套代码用 LT 和 ET 都压测过连接多、单次数据量小时两者差距可能不到 5%。对于快速入门先把 LT 写扎实比盲目追 ET 要好得多。3.4 EPOLLONESHOT多线程处理事件的一把锁这个标志位我单独拎出来说因为它和“多线程 epoll”的关系很密切。场景是这样的主线程 epoll_wait 拿到可读事件后把 fd 丢给工作线程去处理工作线程读数据、做业务、读写 socket。这中间如果这个 fd 又来了新事件主线程的 epoll_wait 可能又返回这个 fd又丢给另一个工作线程两个线程同时操作同一个 socket必然出乱子。EPOLLONESHOT 的作用是当某个 fd 被事件触发并返回后内核自动把它从监听集合里禁用掉直到你用 epoll_ctl EPOLL_CTL_MOD 重新注册。这就是“一次事件、一次线程”的安全保障。我建议任何涉及多线程处理 socket 事件的架构都给读事件加上 EPOLLONESHOT先保证线程安全再谈性能优化。4. 写一个能跑的 echo 服务器实现、运行与逐步解释4.1 核心代码从裸 socket 到完整事件循环下面这个例子是典型的 LT 模式 echo 服务器。客户端连上来发什么它原样回什么但有三个关键点值得注意。#include sys/epoll.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include unistd.h #include fcntl.h #include iostream #include cstring #include cerrno #define MAX_EVENTS 128 #define MAX_BUFFER 4096 int main() { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { std::cerr socket error: strerror(errno) std::endl; return 1; } int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); 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, (sockaddr*)addr, sizeof(addr)) 0) { std::cerr bind error: strerror(errno) std::endl; return 1; } if (listen(listen_fd, 128) 0) { std::cerr listen error: strerror(errno) std::endl; return 1; } int epoll_fd epoll_create1(0); if (epoll_fd 0) { std::cerr epoll_create1 error: strerror(errno) std::endl; return 1; } epoll_event ev; ev.events EPOLLIN; ev.data.fd listen_fd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, ev) 0) { std::cerr epoll_ctl listen_fd error: strerror(errno) std::endl; return 1; } epoll_event events[MAX_EVENTS]; std::cout server listening on 9000 std::endl; while (true) { int n epoll_wait(epoll_fd, events, MAX_EVENTS, -1); if (n 0) { if (errno EINTR) { continue; } std::cerr epoll_wait error: strerror(errno) std::endl; break; } for (int i 0; i n; i) { int fd events[i].data.fd; if (fd listen_fd) { // 处理新连接 sockaddr_in client_addr; socklen_t client_len sizeof(client_addr); int conn_fd accept(listen_fd, (sockaddr*)client_addr, client_len); if (conn_fd 0) { if (errno EAGAIN || errno EWOULDBLOCK) { continue; } std::cerr accept error: strerror(errno) std::endl; continue; } ev.events EPOLLIN; ev.data.fd conn_fd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, ev) 0) { std::cerr epoll_ctl conn_fd error: strerror(errno) std::endl; close(conn_fd); } } else { // 处理客户端数据 char buffer[MAX_BUFFER]; int len read(fd, buffer, sizeof(buffer)); if (len 0) { // 对端关闭 close(fd); epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, nullptr); } else if (len 0) { if (errno EAGAIN || errno EWOULDBLOCK) { continue; } close(fd); epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, nullptr); } else { std::cout recv: std::string(buffer, len) std::endl; send(fd, buffer, len, 0); } } } } close(listen_fd); close(epoll_fd); return 0; }4.2 为什么监听 fd 也要加进 epollaccept 也是事件很多初学者一开始的思维是监听 socket 先 accept 一下有连接了就接受没有就算了。但在 epoll 的模型里listen_fd 同样是一个需要被事件驱动的 fd。新连接到来时listen_fd 变为可读epoll_wait 会返回它的事件你先处理 accept再把这个新连接的可读事件注册进 epoll。要是不把 listen_fd 加进去就一直阻塞在 accept 上那整个服务器只能处理一个连接——后面的连接全部排队。因此你看到代码里处理事件时第一个就判断fd listen_fd是的话就 accept。那 accept 的时候连接满了或者 accept 返回 EAGAIN 怎么办这里有个细节要考虑服务器如果瞬间涌进来一堆连接而 accept 一次只能接受一个剩余连接留在队列里会再次触发 listen_fd 可读事件所以在 while 循环里多 accept 几次是比较稳的处理方式。简单写只 accept 一次也能跑但在高并发下会积累延迟。4.3 编译和运行用两个 nc 验证结果先把代码保存成epoll_echo.cpp然后编译g -stdc11 -O2 -o epoll_echo epoll_echo.cpp然后开两个终端。一个运行服务器./epoll_echo另一个用 nc 连上来测试nc 127.0.0.1 9000 hello每次输入一行服务器输出 recv: 那一行然后 nc 里会把同样的内容回显出来。你同时开多个 nc就能感受到“单线程服务器处理多连接”是怎么运作的——不用为每个连接开独立线程所有的连接都在同一个循环里被轮流处理。4.4 这段代码有什么问题提前说清楚三个不足说实话这只是一个入门 Demo有三处明显不符合生产要求第一我没把连接 fd 设成非阻塞。这在一个 max 才几千连接、处理逻辑很轻量的场景下问题不大但如果遇到客户端发送速度极慢、或者某一方的 TCP 窗口关闭导致发送阻塞的情况你可能会发现某个 send 卡住了其它所有连接的处理——因为一个事件循环被一个 fd 拖住整个服务器的“响应能力”就冻结在那。第二send函数可能没有发送完 buffer 里的全部数据。TCP 的 send 只是把数据拷到内核发送缓冲区内核缓冲区满了就会返回不足长度。对于 echo 这种小数据量场景问题不大但生产环境必须维护“待发送队列”没发完的数据要挂到 EPOLLOUT 事件上继续发。第三没有处理read后续数据的粘包、拆包问题。echo 服务器只是原样返回所以看不出来一旦你要做协议解析read 一次拿到的是一个不完整的协议消息就必须自己维护缓冲。5. 从 LT 到 ET 的完整切换代码怎么改、为何要这样改5.1 切换 ET 前先要对齐一个前提非阻塞必须提上日程ET 模式不是改个 flag 就完事。我们先看要改哪些地方。监听 fd 加非阻塞fcntl(listen_fd, F_SETFL, fcntl(listen_fd, F_GETFL, 0) | O_NONBLOCK);每个 accept 返回的连接 fd 同样要加非阻塞注册事件时将ev.events EPOLLIN改成ev.events EPOLLIN | EPOLLET。为什么必须非阻塞因为 ET 模式下你收到一次事件通知后并不知道缓冲区到底还有多少数据只能循环读直到内核告诉你“当前没有数据可读”返回 EAGAIN。如果 fd 是阻塞的最后一次 read 已经没有数据它会阻塞在 read 里整个事件循环就停摆了。这就是 ET 设计上对阻塞编程思维最大的挑战。设置非阻塞的代码一般这么写static void set_nonblocking(int fd) { int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); }5.2 ET 模式下的读逻辑循环 read 到 EAGAIN事件处理中读到len -1 errno EAGAIN说明缓冲区已经读空了可以正常离开如果返回EPIPE通常在发送时出现说明对端关闭应该把 fd 清理掉。while (true) { int len read(fd, buffer, sizeof(buffer)); if (len 0) { // 把数据交给业务处理或者写入应用层缓冲区 } else if (len 0) { close(fd); break; } else { if (errno EAGAIN || errno EWOULDBLOCK) { break; } close(fd); break; } }这段循环的意义我用另一种方式理解你像个打捞员收到“船靠岸”的信号得一直捞到捞不出来为止。主循环只负责告诉你“有货”至于捞多少、捞到没用完都是你的事。5.3 为什么我建议你现在可以用 ET前提是对性能有明确追求说实话对“快速入门”这个阶段我本来是建议 LT 先跑ET 了解即可。但如果你在实际项目里确实需要压榨单线程的吞吐量那么 ET 值得后来实践时去折腾。具体什么场景适合 ET单线程处理大量长连接每个连接都可能有海量数据涌入读写事件处理逻辑干净利落不会在回调里做重业务你能保证每次事件触发后处理逻辑一定会把当前缓冲区的数据尽量清空。反之如果你业务复杂、代码迭代快LT 的可调试性优势非常明显。别为了赶时髦硬上 ET性能提升如果不是数量级麻烦反而比收益大。6. 生产环境里更该注意的东西从 echo 走向真正的服务6.1 EPOLLERR 和 EPOLLHUP不在你的预期之内但一定会出现往往新手写代码只关心 EPOLLIN 和 EPOLLOUT但 epoll_wait 返回的事件里有可能出现 EPOLLERRfd 出错和 EPOLLHUP对端挂断。这两个事件如果不加处理会带来一个问题因为你注册的是 EPOLLIN出错时 epoll 也会帮你唤醒但它不区别事件类型如果你只按 EPOLLIN 去 read可能读到错误数据或者一直读不出来。我的习惯是每次从 epoll_wait 拿回事件后先检查events[i].events (EPOLLERR | EPOLLHUP)如果有直接清理这个 fd不要再做读写操作。这样能避免大量没必要的 read 尝试也避免在连接已经断掉的情况下误以为还有数据。6.2 多线程 epoll 架构一个厉害但容易混乱的方向严格意义上的“多线程 epoll”不是一个 epoll_fd 被多个线程同时 wait而是把连接分片到多个线程每个线程持有自己的 epoll_fd、自己管理的连接集合。主线程只负责 accept接受新连接后按负载均衡分发给某个工作线程工作线程在自己的事件循环里处理这一片连接的数据。主线程往工作线程的 epoll_fd 上注册新连接 fd需要解决跨线程操作 epoll 的安全问题。一般做法是给每个工作线程塞一个 eventfd主线程在分片时向对应 eventfd 写一个字节工作线程的 epoll 因为监听这个 eventfd会被唤醒再从自己的待分配队列里取新的 fd 注册进自己的 epoll。这块内容展开又是一整篇文章我只在这里留个引子epoll 本身是单线程能扛高并发的关键但服务更复杂的场景会变成多线程 epoll 的负载均衡。你先把单线程 epoll 吃透多线程就是怎么把多个单线程拼起来的问题。6.3 epoll 与定时器的结合不能只依赖 timeout有人会在 epoll_wait 外面套一层 timeout 做定时检查但就像前面说的timeout 不准而且定时任务多了会阻塞回路性能。Linux 上更标准的方式是用 timerfd_create 创建一个定时器 fd加入 epoll到时间点 epoll 会通知你不需要额外的线程或复杂的检查逻辑。事件驱动的定时器写出来非常优雅这里也留个学习线索给你。我在实盘经验里有个体会epoll 代码的坑大多不是 API 记错了而是对事件模型的把握不扎实。你把过一遍事件循环的所有分支——新连接、读事件、写事件、异常、超时——每个分支在什么条件下触发、触发后怎么收尾理清楚一遍后面基本不会再遇到奇怪的现象。这也是为什么我希望你把那一百多行 echo 服务器的每个分支都亲自跑一下故意制造对端关闭、故意长时间不发数据你会对这些分支有更直观的感知。最后再分享一个小技巧写 epoll 服务的时候我在代码里给每个 fd 维护一个上下文结构体里面存它的状态、待发送缓冲、最近活跃时间。这样就不至于在回调里只拿个裸 fd干瞪眼不知道这个连接什么情况。用 C 写可以 struct 包一层用 C 的话直接 class Connection 管理。刚开始训练的时候建议你也在日志里多打几个点看事件循环里各种返回码实际长什么样比背一百遍 man 手册要管用得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑