ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用epoll实现高并发TCP服务器:多路复用与IO模型解析

用epoll实现高并发TCP服务器:多路复用与IO模型解析 如果你还在用“多线程 阻塞 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 多路复用凭什么能解决 C10KC10K 是指单机同时处理一万个 TCP 连接。早期的 Apache 采用进程模型一万个连接意味着接近一万个进程根本不可能。后来的事件驱动方案核心就是 IO 多路复用。它的思路是把“等待”这件事从用户态挪到内核态服务器用一个线程把成千上万个 socket fd 注册到内核内核帮你盯着谁有数据一旦有事件就返回一个就绪列表你只需要处理有事件的 fd。这样线程数量不再和连接数绑定而是和“真正活跃的连接数”绑定而活跃连接在绝大多数业务里只占少数。我在实现 TCP 并发服务器的时候真正体会到多路复用的威力用 epoll 监听 5000 个客户端连接CPU 占用率只有个位数因为大部分时间 epoll_wait 都在休眠只有少数连接触发读写。这也是后来 Redis 能单线程扛住十万级 QPS 的底层原因之一它把文件事件处理器建立在多路复用之上命令处理逻辑再快如果等待 IO 的模型不行照样会被拖死。2. 多路复用三剑客select、poll、epoll 的选择逻辑2.1 从 select 到 pollfd 数量上限与线性扫描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在内核和用户间共享一块内存减少了数据拷贝。你可以用一张表把三个接口的核心差异说清楚写进简历和博客都很有说服力对比项selectpollepollfd 数量上限受 FD_SETSIZE 限制通常 1024无上限取决于内存无上限取决于内存就绪检查方式线性扫描全部 fd线性扫描全部 fd内核回调 就绪链表每次调用时数据结构全量拷贝 fd_set全量拷贝 pollfd 数组通过 mmap 共享减少拷贝触发模式水平触发水平触发水平触发 LT 边缘触发 ET编程复杂度低低中高ET 模式尤其需要注意2.3 真实场景怎么选不是越新越好虽然 epoll 看起来全面碾压但工程选型不能只看性能。我的习惯是连接数预期不超过几百或者只是一个内部小脚本用 select 或 poll 完全够了因为代码更简单、可移植性更好。真正要扛住上万连接、或者连接数不稳定会瞬间飙升的服务才值得上 epoll。如果你在 macOS 上开发用 kqueueWindows 上开发用 IOCPLinux 上开发用 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负责建立监听 socketmain里就是标准的事件循环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 模式如何跟多路复用组合欢迎持续关注这个系列。
返回列表