☰
用epoll实现高并发TCP服务器:多路复用与IO模型解析
2026/10/8 14:57:46 网站建设 项目流程

如果你还在用“多线程 + 阻塞 IO”的方式写 TCP 服务,那么在并发过千的时候,大概率会遇到线程数爆炸、CPU 上下文切换吃满、内存被线程栈拖垮这类问题。今天是我 Linux 网络编程专项训练的第 27 天,正好把 Linux 系统 IO 模型、多路复用技术和 TCP 并发服务器串起来做了一次完整复盘。这次不是背面试题,而是真刀真枪落地了一个基于 epoll 的并发服务端。文章里我会先解释为什么多路复用能让单线程顶住上万连接,再对比 select/poll/epoll 在实际工程中的差异,最后给出完整可运行的 C 语言代码、压测方法以及我在调试中踩过的坑。不管你是刚啃 Linux 网络编程的新手,还是被 C10K 问题折磨的面试者,这篇 DAY27 总结应该都能给你一些可以直接拿去用的东西。

1. 从阻塞 IO 到多路复用:为什么要彻底重写并发模型

1.1 传统并发模型的瓶颈到底出在哪

很多初学者第一次写 TCP 服务器,代码长这样:main 里 socket、bind、listen,然后 while 循环里 accept,来了连接就pthread_create开一个线程去read和write。这个模型逻辑确实简单,每个客户端独占一个线程,代码几乎不用考虑线程同步。但它有两个非常致命的问题:第一,线程不是免费的。一个线程默认栈空间就要 8MB 左右,就算用线程池控制数量,上万并发时线程调度开销也是灾难。第二,阻塞 IO 会浪费 CPU。线程发出read后如果客户端没发数据,就会一直睡在内核里,大量线程同时睡眠,一旦有数据到达,内核要唤醒一大批线程,这就是“惊群”和“上下文切换风暴”的雏形。

更本质的问题是:TCP 连接本身是一种“大多数时间空闲”的资源。用户打开一个网页,TCP 连接建立后可能要几十毫秒才发送请求,服务端线程在这段时间里唯一能做的就是等待。如果一心一意地“一个连接一个线程”,那么连接数一涨,线程数就跟着涨,系统最终会耗尽资源。我记得第一次压测一个简单的阻塞 echo 服务,线程数开到 2000 的时候,CPU 已经 90% 消耗在调度上,业务吞吐量反而断崖式下跌。那一刻我才明白:问题的关键不是“能不能并发”,而是“能不能用少量线程管理大量空闲连接”。

1.2 五种 IO 模型速览:别再混淆同步与异步

搞 Linux IO 模型,绕不开教科书里那幅五模型图。为了好记,我用“点外卖”来打个比方:阻塞 IO 就像你在家干等外卖,没到之前你什么都干不了。非阻塞 IO 是你每隔几秒给骑手打个电话问“到哪了”,没到就接着干别的,但你要不停主动询问。IO 多路复用是你把小区门卫变成接货员,同时盯着美团、饿了么、京东达达,谁到了门卫就通知你。信号驱动 IO 是外卖快到时骑手先给你发个短信,但你收短信后还得自己下楼取。异步 IO 最省心,你直接告诉骑手“放门口就行”,连下楼取都省了。注意,前四种都需要你亲自取餐,也就是数据最终要从内核空间拷贝到用户空间,这个拷贝动作是同步的;只有真正的异步 IO 把数据也帮你拷贝好。

这五种模型的关键区别可以整理成一张表,面试时如果被问到,直接甩这张表就够:

IO 模型用户态等待方式是否阻塞数据拷贝典型实现
阻塞 IO阻塞在 read/write是同步read、write
非阻塞 IO轮询检查返回值否同步O_NONBLOCK + 循环
IO 多路复用阻塞在 select/poll/epoll_wait等待时阻塞,IO 时非阻塞同步select/poll/epoll
信号驱动 IO信号通知否同步sigio
异步 IO内核完成后通知否异步io_uring、AIO

这里容易踩的认知坑是:很多人以为 epoll 就是异步 IO,其实不是。epoll 只帮你“监控哪个 fd 可读、可写”,等到 epoll_wait 返回,你还是要自己去调用 read/write,把数据从内核 socket 缓冲区搬出来。所以它属于同步 IO 模型,只是“等待”的过程比阻塞 IO 高效了无数倍。真正的异步 IO 是 Linux 5.1 之后流行的 io_uring,它把 read 的发起和完成都交给内核,但这套东西在普通业务服务器里用得反而不多,我们这里先不展开。

1.3 多路复用凭什么能解决 C10K

C10K 是指单机同时处理一万个 TCP 连接。早期的 Apache 采用进程模型,一万个连接意味着接近一万个进程,根本不可能。后来的事件驱动方案,核心就是 IO 多路复用。它的思路是把“等待”这件事从用户态挪到内核态,服务器用一个线程把成千上万个 socket fd 注册到内核,内核帮你盯着谁有数据,一旦有事件,就返回一个就绪列表,你只需要处理有事件的 fd。这样线程数量不再和连接数绑定,而是和“真正活跃的连接数”绑定,而活跃连接在绝大多数业务里只占少数。

我在实现 TCP 并发服务器的时候,真正体会到多路复用的威力:用 epoll 监听 5000 个客户端连接,CPU 占用率只有个位数,因为大部分时间 epoll_wait 都在休眠,只有少数连接触发读写。这也是后来 Redis 能单线程扛住十万级 QPS 的底层原因之一:它把文件事件处理器建立在多路复用之上,命令处理逻辑再快,如果等待 IO 的模型不行,照样会被拖死。

2. 多路复用三剑客:select、poll、epoll 的选择逻辑

2.1 从 select 到 poll:fd 数量上限与线性扫描

select 是最早起步的多路复用接口,参数里有fd_set,内核会用位图表示 fd。问题在于位图大小由FD_SETSIZE决定,在 glibc 头文件里通常是 1024。也就是说,select 默认最多只能同时监听 1024 个 fd,你要真想看一万个连接,这玩意儿直接不可用。另一个坑是每次调用 select,都需要把整个fd_set从用户态拷贝到内核态,返回后又要遍历全部 fd 检查哪个有事件,复杂度是 O(n)。n 一旦上了几千,每次循环都要浪费大量 CPU。

poll 改进了 fd 数量限制,它用链表保存 pollfd 数组,理论上没有上限,但仍然没能解决两个问题:一是全量拷贝,每次 poll 都要把整个数组从用户态搬到内核态;二是全量遍历,内核要线性扫描所有 fd 判断是否有事件,poll 返回后用户态还得再扫一遍。所以 poll 只是把 select 的天花板抬高了,复杂度依然是 O(n)。在连接数不多、实时性要求不高的小工具里,poll 完全够用,但要做高并发服务器,它们都不是最优解。

2.2 epoll 的改进:事件回调替代全量扫描

epoll 由三部分组成:epoll_create创建实例,epoll_ctl管理注册事件,epoll_wait等待事件。它内部维护了两样核心结构:一棵红黑树用于存放所有注册的 fd,一个就绪链表用于存放有事件发生的 fd。当你调用epoll_ctl添加 fd 时,内核会为这个 fd 建立一个回调函数;当 fd 上有事件发生时,回调会把 fd 挂到就绪链表上。epoll_wait 只需要查看这个链表,然后把有事件的 fd 拷贝到用户态,复杂度降到 O(1) 或者 O(就绪数量)。这才是高并发场景性能差异的根本原因。

另外,epoll 还有一个关键设计:所有注册的 fd 和事件通过mmap在内核和用户间共享一块内存,减少了数据拷贝。你可以用一张表把三个接口的核心差异说清楚,写进简历和博客都很有说服力:

对比项selectpollepoll
fd 数量上限受 FD_SETSIZE 限制(通常 1024)无上限,取决于内存无上限,取决于内存
就绪检查方式线性扫描全部 fd线性扫描全部 fd内核回调 + 就绪链表
每次调用时数据结构全量拷贝 fd_set全量拷贝 pollfd 数组通过 mmap 共享,减少拷贝
触发模式水平触发水平触发水平触发 LT + 边缘触发 ET
编程复杂度低低中高,ET 模式尤其需要注意

2.3 真实场景怎么选:不是越新越好

虽然 epoll 看起来全面碾压,但工程选型不能只看性能。我的习惯是:连接数预期不超过几百,或者只是一个内部小脚本,用 select 或 poll 完全够了,因为代码更简单、可移植性更好。真正要扛住上万连接、或者连接数不稳定会瞬间飙升的服务,才值得上 epoll。如果你在 macOS 上开发,用 kqueue;Windows 上开发,用 IOCP;Linux 上开发,用 epoll。跨平台框架如 libevent、libuv 会自动封装这些差异,业务代码没必要直接和 epoll 死磕。

还要提醒一句:epoll 的水平触发(LT)和边缘触发(ET)差别很大。LT 是只要有数据没读完,每次 epoll_wait 都会上报;ET 是状态变化时只上报一次,你必须用非阻塞 IO 一次性把所有数据读完,否则剩余数据不会再触发事件。很多初学者一上来就追求 ET,反而把自己坑得怀疑人生。我的建议是先以 LT 为主实现功能,跑通压测后再改成 ET 优化性能,这样每一步都能定位问题。

3. 手写一个基于 epoll 的 TCP 并发服务器

3.1 设计思路与模块划分

我这次实现了一个非常小的“HTTP echo”服务器:客户端发送任意数据,服务端回一个固定的 HTTP 响应。选择 HTTP 而不是纯 TCP echo,主要是方便用 wrk 这类工具做压测。整体结构只有三块:监听 socket 初始化、epoll 事件注册、事件循环处理。事件循环里分两类事件:一是 listen fd 上触发 EPOLLIN,表示有新的 TCP 连接完成三次握手,进入 accept 队列;二是已连接 fd 上触发 EPOLLIN,表示客户端数据到达。

为了让服务器支持高并发,所有 socket 都必须设置为非阻塞。这里解释下为什么:如果 listen socket 是阻塞的,accept 在 ET 模式下需要循环调用,一旦连接队列清空,accept 就会阻塞在原地,整个进程就废了。同理,客户端连接 fd 如果阻塞,读数据时数据没到就会卡住,其他就绪 fd 也没机会处理。所以非阻塞是事件驱动的基本前提。另外,socket 初始化时一定要设置SO_REUSEADDR,否则服务器重启时如果端口还处于 TIME_WAIT 状态,bind 就直接失败,这个坑几乎每个新手都遇到过。

3.2 完整代码:初始化、epoll 循环、事件分发

下面是我在 DAY27 实测过的代码,精简掉了不必要的错误打印,保留了核心逻辑。编译方式很简单:gcc -O2 -o epoll_server epoll_server.c -lpthread,不过我们这个版本不用创建线程。

#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 PORT 8080 #define RESPONSE "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok" static 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); } static int create_listen_fd(void) { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); 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(PORT); if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); exit(1); } if (listen(listen_fd, 1024) < 0) { perror("listen"); exit(1); } set_nonblock(listen_fd); return listen_fd; } static void handle_client(int client_fd, int epoll_fd) { char buf[4096]; ssize_t n; // 边缘触发模式下,必须循环读,直到 EAGAIN while (1) { n = read(client_fd, buf, sizeof(buf)); if (n > 0) { // 这里只做 echo 业务,实际项目请替换为协议解析 write(client_fd, RESPONSE, strlen(RESPONSE)); } else if (n < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) { break; // 数据读完了,正常退出读循环 } // 真正的错误,关闭连接 close(client_fd); break; } else { // n == 0 表示对方关闭连接 close(client_fd); break; } } } int main(void) { int listen_fd = create_listen_fd(); int epoll_fd = epoll_create1(0); struct epoll_event ev, events[MAX_EVENTS]; ev.events = EPOLLIN | EPOLLET; // 边缘触发,监听读事件 ev.data.fd = listen_fd; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev); printf("epoll server listening on port %d\n", PORT); while (1) { int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1); for (int i = 0; i < n; i++) { int fd = events[i].data.fd; if (fd == listen_fd) { // 处理新连接,ET 模式下要 accept 到 EAGAIN while (1) { struct sockaddr_in cliaddr; socklen_t clilen = sizeof(cliaddr); int conn_fd = accept(listen_fd, (struct sockaddr *)&cliaddr, &clilen); if (conn_fd < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) { break; // 当前没有新连接了 } perror("accept"); break; } set_nonblock(conn_fd); ev.events = EPOLLIN | EPOLLET; ev.data.fd = conn_fd; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &ev); } } else { if (events[i].events & (EPOLLERR | EPOLLHUP)) { close(fd); continue; } handle_client(fd, epoll_fd); } } } close(epoll_fd); close(listen_fd); return 0; }

这段代码看起来不长,但已经把 epoll 并发模型的最小闭环搭起来了。create_listen_fd负责建立监听 socket,main里就是标准的事件循环:epoll_wait 返回后先判断是不是 listen fd,是就循环 accept 处理新连接;不是就交给handle_client读数据。注意我在读循环里用了while(1) + read,配合EAGAIN跳出循环,这就是边缘触发 ET 的标准写法。如果你把EPOLLET去掉,就是水平触发 LT,事件没处理完会反复上报,代码可以简化成“读一次”,但代价是每次事件都要重复唤醒,性能会下来。

3.3 ET 与 LT 的差异:为什么边缘触发必须读穷

边缘触发的名字来自电平信号:LT 是只要有数据就是“高电平”,每次扫描都会通知你;ET 是只有从“无数据”到“有数据”的这个上升沿通知你,如果你没把数据读完,哪怕下次又有新数据,只要缓冲区里还有旧数据,就不会再通知。ET 模式下,必须保证下次读之前用 while 循环把当前缓冲区里的所有数据消费完,否则就会出现“明明有数据,但 epoll_wait 一直不返回”的假死现象。这正是新手最容易踩的坑。

我特意把这段代码写成 ET 模式,是为了让你直观感受“读穷”和“accept 穷”的必须性。在测试时,你可以把EPOLLET去掉再跑一遍,用strace -p 进程号观察 read 系统调用次数,会发现 LT 模式下的 read 调用频次明显更高。ET 模式性能更好的原因就是减少了无意义的系统调用,但它把正确性责任推给了开发者。如果业务逻辑很复杂,一个 fd 的数据没读完就去做其他事情,后续数据再到达时可能丢失个别事件,所以我建议:第一版服务端先用 LT 调通逻辑,再小心切到 ET。

3.4 压测方法与实测指标

服务器跑起来之后,先用ss -tnp | grep :8080确认端口监听正常。压测我用的 wrk,模拟 1000 个并发连接持续压 30 秒:

wrk -t4 -c1000 -d30s http://127.0.0.1:8080/

在我这台 4 核虚拟机上,LT 版本 QPS 大约是 4 万左右;切到 ET 模式后 QPS 可以到 6 万上下。这不是严谨的基准测试,但足够说明多路复用 + 非阻塞 IO 的收益。如果用传统的“线程池 + 阻塞 accept”,同样的压测命令大概率直接把机器打崩,因为 1000 个连接就会撑起 1000 个线程,光线程栈就是 8GB 虚拟内存,调度损耗更是灾难。压测时还可以配合top -H观察 CPU,你会发现 CPU 主要消耗在用户态业务处理上,而不是系统调用切换,这正是事件驱动模型想要的效果。

3.5 Linux 调试三板斧:strace、ss、perf

这类并发服务器出问题时,不要瞎猜,直接用工具定位。第一个是strace -f -p PID,能看到 epoll_wait、accept、read 的调用时序,非常适合排查“事件有没有到”、“accept 为什么没触发”这类问题。第二个是ss -tanp,查看当前所有 TCP 连接状态,特别关注 ESTABLISHED 数量和 SYN 队列溢出。第三个是perf top,可以快速看出 CPU 到底消耗在哪个函数,比如是epoll_wait还是memcpy还是业务代码。这些命令本身不复杂,但组合使用能让你快速定位到问题是属于内核、协议栈还是应用层。

4. 实战中的典型坑与排查思路

4.1 accept 返回 EMFILE:连接队列越满,事件越频繁

服务器在高负载运行时,如果进程的文件描述符达到ulimit -n上限,accept 会返回EMFILE,也就是“文件描述符表已满”。问题在于,只要有新连接进入 accept 队列,listen fd 会一直可读,epoll_wait 就会反复唤醒你,而你又无法真正 accept,最终形成 100% CPU 的死循环。我踩过一次:压测时只开了一千连接,但程序忘记 close 掉关闭连接的 fd,积累到 1024 之后,CPU 瞬间打满,日志刷屏。

解决经验有两条路:最简单的方案是提前打开一个“占位 fd”(比如/dev/null),在程序启动时持有它;当 accept 返回 EMFILE 时,先关闭这个占位 fd,然后立刻 accept 拿下一个连接,再立即 close 掉这个连接,最后重新打开/dev/null补回占位。这样既避免了死循环,也把多余的连接丢弃掉。更彻底的做法是调大ulimit -n,但线上系统终究有限,占位方案更稳妥。另外,顺手写一个逻辑:每次 close 后都把 fd 计数减一,可以在开发期就避免泄漏。

4.2 ET 模式读不干净:数据滞留与响应卡死

最经典的 ET 事故是:服务器收到客户端的一行请求,代码只调用了一次 read,结果一次 read 只读到了部分数据。由于 ET 只在状态变化时通知一次,剩余数据就“藏”在 socket 缓冲区里,除非客户端再发新数据,否则服务器再也不会收到可读事件。表现就是客户端那边等响应等到超时,服务器这边连接一直挂着。解决方法是 ET 模式下必须while(read == EAGAIN)读到清空,或者干脆用 LT。但“读穷”也要有限度,如果客户端一次发来 100MB 数据,你不能真的在事件回调里一口气读完,否则其他 fd 全部饿死。实际工程中常用的方式是:单次读取限制在一个合理值(比如 16KB),读完这个值就暂停,把剩余数据放入业务缓冲;同时结合EPOLLONESHOT或者手动调整事件,确保后续还能继续触发。这个度需要根据业务数据包大小反复试。

4.3 多进程 epoll 惊群与 EPOLLEXCLUSIVE

如果你把 epoll 服务器改造成多进程模型,比如每个 CPU 核跑一个进程,所有进程都拿着同一个 listen fd 调用 epoll_wait,那么一个新连接到来时,内核会唤醒所有等待进程,让它们竞争 accept。最后只有一个进程能 accept 成功,其他进程白白被唤醒,这就是惊群。Linux 4.5 之后的 epoll 支持在epoll_ctl时加EPOLLEXCLUSIVE标志,内核只会唤醒等待队列里的其中一个进程;更常用的另一种方案是启用SO_REUSEPORT,让每个进程都创建一个独立的 listen socket 绑定同一端口,内核在协议栈层直接做负载均衡。Nginx 的做法就可以参考后者。这个坑一般在单进程模型下不存在,但一旦你想靠多进程扩展性能,就一定会遇到。

4.4 用 Redis 和 Java NIO 反推多路复用模型

理解 epoll 最好的办法不是反复看书,而是去看生产级开源项目的取舍。Redis 在 Linux 上就是典型的单线程事件循环 + epoll:所有客户端连接注册到 epoll,主线程循环处理就绪事件,命令执行完后再回到 epoll_wait。它能单线程支撑极高 QPS,不是因为命令有多快,而是等待 IO 的成本被 epoll 压缩到了极低。Java NIO 的 Selector 在 Linux 底层也是 epoll,但它做了跨平台封装,所以理解 epoll 之后,你再看 Java 的SelectionKey.OP_READ就会觉得非常亲切。面试中常问“epoll 为什么高效”,你可以从红黑树、回调、就绪链表、mmap 四个层面去答,这样比干巴巴背“非阻塞、事件驱动”要有说服力得多。

5. 进阶思考:从多路复用到异步 IO,下一步往哪走

5.1 多路复用不是终局:数据拷贝仍是瓶颈

即使 epoll 已经把“等待”的成本降到了极低,但每次读写仍然要经过一次从内核 socket 缓冲区到用户态内存的拷贝。如果业务是转发大文件,这种拷贝会非常明显,所以有了sendfile、splice之类的零拷贝接口。Linux 5.1 之后的 io_uring 走得更远,允许你提交一组异步读写请求,由内核在完成后再通过 completion queue 通知你,真正做到不需要进程/线程阻塞在等待上。普通业务里暂时用不上这么“硬核”的东西,但如果你要做高性能网关、代理服务器,方向一定要对。

5.2 从 echo 服务器到通用网络框架:你应该继续补哪些课

DAY27 的这个小服务器只是个起点。要把它变成能上生产的网络框架,至少要补上四块内容:一是协议解析层,把固定响应改成 HTTP、Redis 协议或者自定义二进制协议;二是业务线程池,因为多路复用只解决了 IO 等待,如果你的业务是复杂的计算任务,还是需要多线程或进程池处理,再通过队列和事件循环衔接;三是连接的优雅关闭,也就是EPOLLRDHUP、EPOLLHUP的语义区分,以及半关闭时数据的收尾处理;四是定时器,比如 Go 的 netpoller、Redis 的 ae 时间事件都是和 epoll_wait 的超时时间配合实现的。学习路线建议是:先自己写一个带协议解析的 echo,再模仿 Redis 的单线程事件驱动,最后去读一遍 Nginx 事件模块源码。

这一路 EPoll 并发实现和排障做下来,我个人最深的体会是:多路复用技术不是银弹,它只是把“资源占用”和“事件等待”解耦了。真正决定系统上限的,还是连接管理、缓冲区设计、业务拆分这些看起来不起眼的工程细节。DAY27 记录下来这些代码和坑,都是我实际调试过的,希望你在自己实现 TCP 并发服务器时,能少走几步弯路。接下来我打算接着写线程池和 Reactor 模式如何跟多路复用组合,欢迎持续关注这个系列。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询