ARTICLE DETAIL

资讯详情

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

IO多路复用精讲:从select/poll到epoll高并发实战TCP回显服务器

IO多路复用精讲:从select/poll到epoll高并发实战TCP回显服务器 做Linux网络编程的人迟早会碰到IO多路复用这个词。不管是写高并发服务端、嵌入式socket应用还是准备面试select、poll、epoll这三个东西都绕不开。作为系列第二篇我会直接按工程落地的思路来讲先把这个东西解决的问题说透再把三种方案掰开揉碎对比了一遍最后手写一个基于epoll的完整TCP回显服务把实际开发中踩过的坑一并列出来。在之前的socket基础篇里我们用阻塞IO写过一个最朴素的echo server。单机跑着没问题但一旦连接数上来那种“一个连接配一个线程”的写法就会越来越吃力而IO多路复用正是解决这种场景的核心手段。这篇文章不会堆砌概念而是把每个关键选择背后的考量都讲清楚让你看完不仅能理解原理还能直接写出可以上线的代码。1. 为什么需要IO多路复用1.1 传统阻塞IO的瓶颈在哪先回顾一下阻塞IO的工作方式。当你调用accept()等待客户端连接时进程会进入睡眠状态当客户端发来数据read()也会阻塞直到数据到达或连接关闭。这种模型逻辑简单但存在一个致命问题一次只能处理一个IO事件。假设服务端正在等一个慢速客户端发数据此时即使有一百个新连接排队等着进来accept()也轮不到它们。解决办法看起来也很直接——每个连接开一个线程。连接数少的时候没问题可一旦连接数上万内存开销和上下文切换开销瞬间就会压垮系统。一个线程默认栈空间8MB就算调小到1MB一万个线程也要10GB内存。而且线程切换频繁时CPU大量时间花在保存和恢复现场上真正干活的占比反而很低。1.2 多线程方案代价太大有人说可以把线程栈调小或者用线程池。线程池能解决线程频繁创建销毁的问题但解决不了阻塞等待的空转。阻塞IO下线程一旦调用read()没有数据就只能干等。线程池里如果60%的线程都在等数据服务端处理新请求的实时性就大打折扣。还有一种做法是给socket设置非阻塞模式然后用忙轮询遍历所有连接不停调用read()检查有没有数据。但这种方式更加浪费CPU。每次调用read()都是一次系统调用一万个空闲连接就要做一万次系统调用而且几乎所有调用立刻返回“没数据”。CPU全耗在空转上这就是典型的“忙等”。1.3 多路复用的核心思想把“等待”交给内核IO多路复用的思路完全不同不再让用户程序挨个问“你有数据吗”而是把所有需要监听的socket一次性交给内核内核统一告诉你“哪些socket有数据了”。这个思想抽象出来就是单个线程维护多个socket连接通过一次系统调用等待多个IO事件内核负责监控这些fd的状态变化有事件发生时通知用户程序去处理。这样线程不再被某一个空闲连接拖住CPU利用率大幅提升。这也是如今高并发服务端普遍采用select、poll、epoll实现C10K甚至C1000K问题的基础。注意IO多路复用本身是同步IO。它只是把“多个IO事件”的等待合并成了一个系统调用但数据真正读出、写入时依然由应用层同步完成。这和异步IOAIO、io_uring有本质区别面试时经常被追问先记下这个点。2. select/poll/epoll三兄弟原理、差异与选型2.1 select最早的通用方案select是IO多路复用最古老的实现也是各平台支持最广的。日常写跨平台程序的时候Windows和Linux都提供了select接口所以很多兼容性要求高的老代码还在用它。select的核心逻辑是把需要监听的fd集合拷贝到内核内核遍历所有fd检查状态变化有事件就返回用户程序再遍历整个fd集合找到真正就绪的fd。它的使用方式很直接。先初始化fd_set用FD_SET把要监听的fd装进去然后调用select()。需要注意的事件类型包括可读、可写和异常三种。select有几个硬伤实际开发中都躲不掉第一个是FD_SETSIZE限制。Linux上默认是1024也就是说单进程用select最多管理1024个fd。这个限制在内核里写死了想改得重新编译内核非常不现实。第二个是fd集合的拷贝开销。每次调用select都要把整个fd集合从用户态拷贝到内核态。如果监听几千个fd光拷贝就占了很大开销而且即使只有1个fd有事件也要把全量集合拷一遍。第三个是O(n)的遍历。内核不知道哪个fd就绪了只能把fd全遍历一遍去做状态检查。用户程序拿到结果后也要再遍历一遍fd集合才能找到就绪的fd。双向O(n)的复杂度连接数越多越吃力。select还有一个细节很多人忽略内核会修改传入的fd_set只保留就绪的fd。所以每次调用select之前都必须重建fd集合否则上一轮的结果会污染下一轮。2.2 poll改进了限制但没改掉本质poll和select做的事情几乎一样主要区别是它抛弃了fd_set位图改用pollfd结构体数组从而突破了1024个fd的上限。struct pollfd { int fd; /* 文件描述符 */ short events; /* 关注的事件 */ short revents; /* 实际发生的事件由内核填充 */ };poll的设计比select多了几个好处。fd数量不再受限能监听多大规模只取决于系统能打开多少个fd可以用ulimit -n查看。另外每次调用不需要重建整个数组因为内核只修改revents用户程序可以直接复用事件数组。但poll并没有解决性能的根源问题它仍然要把整个数组从用户态拷贝到内核态仍然要O(n)遍历检查每个fd状态返回后仍然要O(n)遍历找出就绪fd。所以poll只是“能管更多连接”但连接多了以后性能依然线性下降。2.3 epollLinux下的终极形态epoll是Linux专属的IO多路复用方案也是目前所有高性能服务端程序的事实标准。nginx、Redis、Node.js这些底层都在用它。epoll最有突破性的设计是把“所有fd集合”常驻在内核空间而不是每次调用都重新拷贝。它借助三个系统调用完成全部工作epoll_create1创建epoll实例内核返回一个文件描述符。需要传入一个EPOLL_CLOEXEC标志位方便在forkexec时自动关闭免得子进程意外继承。epoll_ctl负责增删改事件。要把某个socket的fd注册到epoll实例里或者修改监听的事件类型或者从epoll里移除。从内核视角看这里用红黑树来管理所有注册的fd。注册、查找、删除的时间复杂度都是O(log n)非常高效。epoll_wait负责等待事件。它只返回“发生就绪事件”的fd列表而不是全量fd集合。内核内部维护一个就绪链表当fd上有IO事件发生时通过回调机制把fd加入链表。用户程序调用epoll_wait时直接把链表里的就绪事件拷贝到用户空间数组里返回。这意味着即使监听一万个fd如果只有50个有事件只需要处理那50个不用遍历剩下那9950个。epoll还支持两种触发模式这是它拉开与select/poll差距的重要设计水平触发LT是默认模式。只要fd上有数据没读完每次epoll_wait都会返回这个fd。这种模式编程简单普通accept、read流程直接写就行不太容易出现漏事件的问题。边缘触发ET只在状态变化时通知一次。fd从未就绪变为就绪的瞬间内核通知一次之后即使后续又有数据进来也不再重复通知。这就要求程序员必须一次把数据全部读走否则剩余数据会一直堆积。ET模式下socket必须设置为非阻塞因为阻塞socket在循环read时会一直阻塞到新数据到来直接把整个服务端卡死。提示epoll是Linux特有的macOS上的kqueue、Windows上的IOCP虽然在思想上有类似之处但接口完全不同。做跨平台服务端时通常是封装一层事件循环抽象各平台各自实现。3. 实操基于epoll的完整TCP回显服务器3.1 整体设计思路与基础准备这里我用C语言实现一个完整的epoll回显服务器。选择C语言是因为Linux网络编程中C依然是最能体现底层细节的语言而且能和很多嵌入式场景直接对接。用C、Java、Go的同学理解了这套流程之后再看各自语言的事件框架会轻松很多。整个程序的逻辑拆分为六个阶段创建监听socket、设置地址复用、绑定和监听、创建epoll实例、注册监听事件、进入事件循环。事件循环里处理两类事件监听fd上有新连接到达、客户端fd上有数据可读。为了代码清晰我把每一段都拆开讲最后拼成一个完整可编译的程序。编译环境以常见的Linux发行版为准。gcc版本在4.8以上就行不用装任何第三方库纯系统调用编译命令是gcc -o epoll_echo epoll_echo.cubuntu等系统上如果没装gcc先执行sudo apt install build-essential这个不多说。测试的时候可以用nc或另一台机器的telnet连接。注意nc的包名Ubuntu下是nettools包里的命令新版也集成了nc如果提示没有装一下apt install netcat-openbsd。3.2 监听socket的创建与初始化第一步创建socket。注意AF_INET代表IPv4协议族SOCK_STREAM代表面向连接的TCP流式socket。socket()返回一个文件描述符这个fd后续就是epoll的监听对象。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.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 create_listen_fd(int port) { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); exit(EXIT_FAILURE); } // 设置SO_REUSEADDR解决服务器重启时TIME_WAIT状态占用端口的问题 int opt 1; if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)) 0) { perror(setsockopt); close(listen_fd); exit(EXIT_FAILURE); } struct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr htonl(INADDR_ANY); // 监听所有本地地址 server_addr.sin_port htons(port); if (bind(listen_fd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(bind); close(listen_fd); exit(EXIT_FAILURE); } if (listen(listen_fd, 128) 0) { // 128是backlog队列长度 perror(listen); close(listen_fd); exit(EXIT_FAILURE); } return listen_fd; }两个细节需要重点说明。一个是SO_REUSEADDR。不设置它的话服务端程序关闭之后立即重启大概率会bind失败因为TCP连接可能还处于TIME_WAIT状态端口被内核占用着。加了SO_REUSEADDR就能立刻重新绑定这在日常开发和上线发布时能省去大量不必要的等待。另一个是backlog参数。这里设置的128是内核维护的accept等待队列长度。如果并发连接请求一下子太多超过队列长度多余的连接会被拒绝或丢弃。这个值不是越大越好但作为基础后端服务128的起步值是合理的。3.3 epoll实例的创建与事件注册接下来创建epoll实例并注册监听socket的可读事件。epoll_create1(EPOLL_CLOEXEC)这个调用里参数除了传入EPOLL_CLOEXEC还能传0。我推荐保留EPOLL_CLOEXEC因为后续如果fork子进程处理任务exec时会自动关闭这个fd避免子进程意外持有epoll实例导致资源泄漏。int main(int argc, char *argv[]) { int port 8080; if (argc 2) { port atoi(argv[1]); } int listen_fd create_listen_fd(port); // 创建epoll实例参数仅在旧内核中使用现代内核传0即可 int epoll_fd epoll_create1(EPOLL_CLOEXEC); if (epoll_fd 0) { perror(epoll_create1); close(listen_fd); exit(EXIT_FAILURE); } struct epoll_event ev; memset(ev, 0, sizeof(ev)); ev.events EPOLLIN; // 监听可读事件 ev.data.fd listen_fd; // 事件触发时通过这个fd判断是哪个socket if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, ev) 0) { perror(epoll_ctl: listen_fd); close(listen_fd); close(epoll_fd); exit(EXIT_FAILURE); } printf(echo server started on port %d, using epoll...\n, port);epoll_event结构体里的data字段是一个联合体既有fd也有ptr。实际工程里更多用的是data.ptr指向一个自定义结构体里面记录fd对应的上下文信息。这里先用data.fd简单直观便于第一次接触epoll的人理解。一个新手容易踩的坑是ev.events EPOLLIN但忘了memset结构体。epoll_event里还有未使用字段不初始化可能导致内核在兼容模式下行为不确定。虽然现代实现一般仍能正常工作但为稳妥起见还是老老实实清零整个结构体。3.4 事件循环accept新连接与read数据事件循环是整个服务器的心脏。程序在这里不停调用epoll_wait等内核返回就绪事件列表然后根据就绪fd的类型做对应处理。struct epoll_event events[MAX_EVENTS]; char buffer[BUFFER_SIZE]; while (1) { int nfds epoll_wait(epoll_fd, events, MAX_EVENTS, -1); if (nfds 0) { if (errno EINTR) { continue; // 被信号中断继续等待 } perror(epoll_wait); break; } for (int i 0; i nfds; i) { // 处理新连接 if (events[i].data.fd listen_fd) { struct sockaddr_in client_addr; socklen_t client_len sizeof(client_addr); int conn_fd accept(listen_fd, (struct sockaddr *)client_addr, client_len); if (conn_fd 0) { perror(accept); continue; } printf(new connection from %s:%d, fd%d\n, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), conn_fd); // 新连接fd注册到epoll仍然监听可读事件 memset(ev, 0, sizeof(ev)); ev.events EPOLLIN; ev.data.fd conn_fd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, ev) 0) { perror(epoll_ctl: conn_fd); close(conn_fd); } } // 处理客户端数据 else { int fd events[i].data.fd; ssize_t n read(fd, buffer, sizeof(buffer)); if (n 0) { // 非阻塞模式下读到EAGAIN不算错误说明数据已读完 if (errno EAGAIN || errno EWOULDBLOCK) { continue; } perror(read); close(fd); } else if (n 0) { // 对端关闭连接 printf(client fd%d closed\n, fd); close(fd); } else { // 完整回显不做粘包处理保证逻辑最简单 write(fd, buffer, n); } } } } close(listen_fd); close(epoll_fd); return 0; }这段代码里值得展开讲的有三点。第一epoll_wait的timeout参数设为-1表示永远阻塞直到至少有一个事件发生。这种写法适合事件驱动型服务端平时CPU占用几乎为零有请求才醒来处理。如果程序里还有其他周期性任务可以把这个参数改成超时毫秒数定期醒来处理心跳、统计之类的工作。第二accept返回的新连接fd默认在Linux上是不继承非阻塞属性的。也就是说新fd是阻塞的。LT模式下阻塞fd问题不大但如果后面要切换到ET模式就必须在这里用fcntl把新fd设置成非阻塞。第三这段代码里read完直接write没有处理部分写入的问题。对回显这种简单场景来说高负载下可能出现写缓冲区满的情况。严谨的写法应该用一个写缓冲区把写不进去的数据缓存起来并通过EPOLLOUT事件继续写。这里先不引入过多复杂度工程化版本的代码在后续专题里单独写。3.5 LT与ET模式的完整对比与切换把上面代码里的ev.events EPOLLIN换成ev.events EPOLLIN | EPOLLET就切换到边缘触发模式。但这样直接换程序大概率会出问题原因前面说过ET模式下必须一次性把数据读完否则剩余数据再也不会触发通知。正确切换到ET模式的改造包含两部分第一监听的fd全部设置非阻塞int set_nonblocking(int fd) { int flags fcntl(fd, F_GETFL, 0); if (flags 0) { return -1; } flags | O_NONBLOCK; return fcntl(fd, F_SETFL, flags); }这个函数需要在accept之后对新连接fd调用同时监听fd本身也要设否则accept调用本身也可能阻塞在等待连接上。第二read的时候要用循环直到EAGAIN为止ssize_t n; while (1) { n read(fd, buffer, sizeof(buffer)); if (n 0) { write(fd, buffer, n); } else if (n 0) { // 对端关闭 close(fd); break; } else { if (errno EAGAIN || errno EWOULDBLOCK) { // 数据全部读完退出循环 break; } perror(read); close(fd); break; } }LT模式下一次epoll_wait返回后读一次就够了剩下没读完的数据下次epoll_wait还会通知。但ET模式下你必须在这个循环里把所有数据都读走否则剩余数据就“卡”在内核缓冲区里再也不会有新事件。注意ET模式本质上是对内核通知机制的精简化设计逼着应用程序尽量把数据一次处理完从而减少系统调用次数。高并发场景下LT模式每次有数据都通知、每次都触发系统调用性能略逊于ET模式。但ET模式的编程复杂度明显更高务必确认自己的程序已经把数据读干净了否则会莫名奇妙地丢数据或卡连接。4. 三种方案的实测对比与选型建议4.1 测试环境与测试方法空谈理论没有说服力。我在自己手头机器上做了一组简单压测用来直观对比select、poll和epoll在事件处理上的差距。测试环境是4核虚拟机Ubuntu 22.04内核版本5.15同一台机器回环通信模拟1万条连接。测试方法是批量建立TCP连接每个连接发一条消息服务端收到后回显客户端统计每秒完成的请求数。select因为受FD_SETSIZE限制直接限制在1024个连接以内。poll和epoll用1万条连接这个数量正好能看出两者在事件处理开销上的不同。4.2 测试结果与数据分析数据是在我这边测试环境下跑出来的只代表趋势不代表绝对基准。不同核数、不同内核版本都会影响结果但相对差距是有典型参考价值的。方案最大连接数1千连接QPS1万连接QPS主要瓶颈select1024以内约2.1万无法测试fd_set拷贝O(n)扫描1024上限poll取决于ulimit约2.3万约0.6万fd数组拷贝O(n)扫描epoll(LT)取决于ulimit约2.6万约3.8万回调唤醒事件处理epoll(ET)取决于ulimit约2.7万约4.2万回调唤醒事件处理注意一个反直觉的趋势连接数从1千涨到1万poll的QPS反而下降了一大截。原因就是poll每次调用都要遍历1万个fd连接越多每次调用的固定开销越大。epoll在1万条连接的时候性能基本持平甚至略有上升究其原因是epoll_wait只返回就绪事件列表处理量由活跃连接数决定而不是由总连接数决定。select在1024条连接内单次事件的处理开销和poll差不太多。但1024的上限决定了它完全不适合现代高并发服务端。之所以很多旧代码还在用select要么是历史包袱太重要么是对性能要求极低。提示这个测试没有考虑多线程并发分发的情况。实际生产环境服务端通常是epoll配合线程池主线程负责epoll_wait获取事件工作线程负责业务处理从而充分利用多核CPU。这属于后续“事件驱动模型”专题的内容先在这里提个方向。4.3 选型建议与适用场景站在2024年这个时间点我的建议很简单纯Linux环境开发新项目直接上epoll别犹豫。不管是LT还是ETepoll都是最成熟、资料最丰富、性能最优选的方案。libevent、libuv、nginx内部在Linux上也都是用epoll。需要跨平台那就封装多路复用抽象层Windows用select或IOCPmacOS用kqueueLinux用epoll。如果嫌麻烦直接引入成熟的网络库比如libevent、libuv、Boost.Asio它们的跨平台封装已经稳定运行了很多年。嵌入式环境要区分情况。如果内核版本旧可能只支持select和poll那就用poll。如果设备性能极弱、连接的并发量也不高select反而因为实现简单、占用资源少成为更合适的选择。有些RTOS环境下的socket实现甚至只提供select接口这时没有太多选择余地。面试的话不要只说“epoll快”必须能说清楚epoll为什么快内核管理fd的结构、就绪回调机制、用户态拷贝策略、LT和ET的区别这些是面试官真正想听的。5. 常见问题与排查技巧实录5.1 惊群问题与EPOLLONESHOT多线程epoll程序里有一个典型问题多个线程同时调用epoll_wait监听同一个epoll实例时一个fd上的事件到达可能会唤醒多个线程但最终只有一个线程能成功accept或read其他线程空跑一圈。这就是惊群效应。Linux 2.6之后内核已经对accept做了优化不会真正的惊群但epoll_wait层面依然存在。实际工程中解决办法通常有三种一是用EPOLLONESHOT标志。注册事件时带上这个标志事件触发一次后内核自动从epoll实例中移除该fd直到用户程序重新注册。这样能保证同一时刻只有一个线程在负责某个fd避免多个线程争抢同一个连接。二是由主线程统一调用epoll_wait把就绪fd分发到工作线程池处理。这种方式叫做et mutex或主从reactor模式能避免多个线程同时进入epoll_wait。三是用跨进程版本同一个监听fd通过fork共享给多个子进程配合socketpair的唤醒机制做分发。这是nginx处理多进程事件的标准思路不过新手阶段不用强行理解先掌握单线程事件循环就足够。5.2 ET模式数据读不完导致连接卡死新手最容易在ET模式下遇到的情况是客户端明明发了数据服务端只读到一半消息后半段永远等不来。排查半天发现是ET模式下read循环里遇到EAGAIN就退出但数据根本没读干净。具体场景是这样的客户端一次性发送了10KB数据服务端read循环第一次读了4KB第二次读到了4KB第三次内核缓冲区还有2KB但服务端遇到了一个非EAGAIN的瞬时错误直接break退出循环。剩下的2KB虽然在内核里但已经错过的边缘触发通知后续不会再触发。遇到这种问题排查思路是按三个方向逐层检查注册事件时有没有遗漏EPOLLET导致实际还在用LT模式read循环里有没有在EAGAIN之前就错误退出缓冲区大小是否明显小于业务层最大数据包。解决方式除了严格检查循环逻辑外还有一条经验做法ET模式下把read缓冲区设置成足够大同时循环read直到EAGAIN再遇到其他错误类型时才退出。注意ET模式的代码在低负载下容易被忽略因为它只有在高并发或大数据包时才会暴露问题。上线前务必做大数据包压测不能只用小消息测试。5.3 fd泄漏与文件句柄耗尽epoll程序跑几天后突然出现“Too many open files”的报错这是另一类高频问题。排查这类问题基本可以锁定在使用fd时忘记close或者事件处理分支里存在异常退出路径。我自己的习惯是所有fd的创建和关闭都放在同一作用域内每个分支退出前都检查有没有fd需要释放。再配合gdb或strace实时观察进程打开的文件数可以快速定位泄漏源。用ls /proc/pid/fd | wc -l能实时看到进程当前打开的fd数量如果持续增长说明泄漏确实存在。不定期用ss -s查看系统的TCP连接状态看到大量CLOSE_WAIT状态通常就是服务端收到了FIN之后没有正确关闭fd。5.4 面试高频问题速查IO多路复用是Linux网络编程面试中绕不过的考点把以下问题整理成了对照表方便复习和自查。平时写代码时多想想这些问题理解会更深面试时也能说清楚原理。问题核心答案要点select和epoll的区别select用fd_set位图有1024上限每次调用整体拷贝、O(n)遍历epoll内核红黑树管理就绪链表按需返回无上限限制受系统fd数限制什么是LT什么是ETLT只要缓冲区有数据就通知ET只在状态变化瞬间通知要求一次性把数据处理完epoll为什么高效三个原因fd集合常驻内核、就绪事件回调机制、epoll_wait只返回就绪列表什么场景用ET高吞吐、大并发、程序逻辑足够健壮否则优先LT避免踩坑epoll是同步还是异步同步IO数据读写仍由应用完成AIO和io_uring才是异步IO惊群怎么解决EPOLLONESHOT避免同一fd同时被多线程争抢或者主线程统一分发事件最后再分享一个排查epoll问题的通用思路先用strace -p pid看epoll_wait系统调用的返回规律再用lsof或ss确认fd状态最后用perf top看热点函数。大多数所谓诡异问题最终都能落到这几个工具给出的数据上。毕竟IO多路复用的本质就是如何高效地和内核协作只要每一步系统调用的行为都在自己的掌控里问题就不会太难定位。
返回列表