ARTICLE DETAIL

资讯详情

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

select/poll/epoll核心原理与Linux高并发服务端实战

select/poll/epoll核心原理与Linux高并发服务端实战 做Linux网络服务端开发的这几年“多路转接”这四个字我几乎每天都要和它打交道。从最初用阻塞socket写最简单的echo服务到后来在线上网关里用epoll扛住上万并发连接这条路走下来踩了不少坑也搞明白了很多文档里没写透的细节。这篇文章我想把这些积累完整梳理一遍为什么一定要多路转接、select/poll/epoll到底差在哪、epoll里的LT和ET该怎么选以及我在实际排查中总结出来的那些经典坑。给正在学Linux网络、准备网络方向面试、或者已经在写服务端代码但总觉得哪里没吃透的朋友提供一个可以直接参考的版本。1. 为什么非要多路转接阻塞IO模型的算账逻辑1.1 阻塞IO下一个连接一个线程的成本有多高先回到最原始的模型。你写一个最简单的TCP服务里面大概是这样的流程int lfd socket(AF_INET, SOCK_STREAM, 0); bind(lfd, ...); listen(lfd, 128); while (1) { int cfd accept(lfd, NULL, NULL); // 然后在这个cfd上阻塞读 recv(cfd, buf, sizeof(buf), 0); // 处理完后继续 }这段代码在本地测试时没有任何问题一个客户端连上来echo一下断开再连下一个一切正常。但是只要客户端稍微多一点哪怕同时就两三个连接这个服务就已经卡死了。原因很直观recv是阻塞的如果第一个客户端连上来之后不发数据你整个程序就停在recv那一步后面的连接根本没机会被accept。于是很多人会改成一个连接一个线程while (1) { int cfd accept(...); pthread_create(tid, NULL, handle_client, cfd); }这样确实能处理并发连接了但你要给这个模型算一笔账。假设并发连接数是一万你就需要一万个线程。每个线程默认栈空间是8MB光线程栈这一项就是80GB内存这还没算线程控制块、调度器需要维护的数据。线程一多CPU的时间大量消耗在线程上下文切换上因为每次切换都要保存寄存器、栈指针、程序计数器还要刷TLB缓存这个开销是实打实的。这就是C10K问题之所以成为经典的原因当并发连接数从百级跨到万级阻塞IO加多线程的模型在资源消耗上就已经扛不住了。它的问题不在于能不能连而在于连接进来之后系统大量资源被浪费在等待和调度上。1.2 多路转接到底转的是什么I/O多路复用这里的多路指的是多个独立的IO流也就是多个socket连接。传统模型里每个socket各自占一条执行路径谁有数据谁才能继续往下走。多路转接的思路是我不再让程序傻等在某一个socket上而是把这一堆socket全部交给内核让内核统一盯着一旦其中任何一个socket有了可读或可写的状态变化内核就告诉用户程序有情况了你自己看看是谁。这个转接的翻译来自电信领域的multiplexing概念你不需要纠缠字面意思只要抓住本质就行多路转接就是用一个监管者把几十万条IO通路的就绪状态汇集起来统一通知。程序只在一个地方等待这个通知接到通知后再决定对哪个socket做实际读写操作。Linux下实现这套机制的系统调用有三代产品select、poll、epoll。理解三者的演进关系就是在理解同一个问题的不同解题思路。2. select与poll能用但为什么大并发下跑不动2.1 select的位图机制和两个致命O(n)select的函数原型是这样int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);fd_set本质是一个位图每一位对应一个文件描述符你通过FD_SET(fd, readfds)把关心的fd对应的位置1然后传给内核。内核接下来要做什么它会在所有传入的fd上做轮询检查每个fd是否有数据到达。这里有第一个O(n)内核要遍历你传入的所有fd逐个判断状态。这个遍历成本是线性增长的fd数量越多单次select调用耗时越长。还有第二个O(n)在用户态select返回之后内核会把就绪的fd仍然保留在位图中把未就绪的fd全部清零。所以你怎么知道哪些fd就绪了你得自己再遍历一遍所有你关心的fd逐个用FD_ISSET去判断。如果有十万个连接即使这次只有两个连接有数据你也要把这十万个fd全部扫一遍才能找到那两个。select还有一个历史遗留问题FD_SETSIZE通常被定义为1024也就是说它默认最多只能监控1024个fd。虽然可以通过改内核头文件的方式把这个上限调大但治标不治本轮询的本质没有变化。更麻烦的一点是内核会修改你传入的fd_set因此你每次调用select之前都必须重新把所有关心的fd全部FD_SET一遍。这个反复重建的过程在高频调用下对CPU缓存非常不友好也是一笔不小的开销。2.2 poll的改进和没改进的部分poll针对select的上限问题做了修正struct pollfd { int fd; short events; // 用户关心的注册事件 short revents; // 内核返回的就绪事件 }; int poll(struct pollfd *fds, nfds_t nfds, int timeout);poll用一个pollfd数组替代了位图上限不再受1024限制理论上你可以监控任意数量的fd。同时它把用户注册的事件(events)和内核返回的事件(revents)分开了内核不会再修改你初始注册的事件位下次调用时数组可以继续复用这个设计比select体面很多。但核心矛盾没变。内核每次poll调用依然要线性遍历整个pollfd数组去检查每一个fd的状态是否变化用户拿到返回值后依然要遍历整个数组通过检查revents来判断哪些fd有事件。也就是说两个O(n)还在只是换了个形式。2.3 用数据感受一下为什么扛不住我当年做压测的时候测过两版服务同样的连接数一个用poll一个用epoll。连接数在几百的时候两者差距很小几乎感觉不出来。但是当我把连接数推到两三万poll版本的CPU使用率迅速飙升因为大部分时间都花在了反复遍历那个巨大的pollfd数组上而真正活跃的连接只有零星几个。这种为了找出少数就绪连接把所有连接检查一遍的模式在连接规模越大的时候越浪费。可以看一下三者的对比特性selectpollepollfd上限FD_SETSIZE1024无上限取决于内存无上限取决于内存内核就绪通知机制轮询遍历全部fd轮询遍历全部fd回调就绪链表用户态查找就绪fd遍历全部fd遍历全部fd只取就绪事件内核态可能修改用户传入数据会需要重建fd_set不会事件和返回分离不会注册表由内核独立维护支持LT/ET模式仅LT仅LTLTET注意最后一行select和poll都只能工作在水平触发模式下而且它们的高开销还不是最让人头疼的更麻烦的是每次调用都要把全部fd信息在用户态和内核态之间拷贝一遍。这套拷贝的数据量是O(全部连接数)级别的网络包来得多的时候哪怕内核什么都没返回拷贝开销就够你喝一壶了。3. epoll的核心设计红黑树、就绪链表与LT/ET的真实差异3.1 三个接口把监听和找就绪拆成了两件事epoll的思路和select/poll完全不同。它把整个过程拆成了三个步骤int epoll_create1(int flags); int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);epoll_create1负责创建一个epoll实例这个实例在内核空间维护一张事件注册表。epoll_ctl负责注册、修改、删除某个fd的关注事件把fd和它关心的事件类型EPOLLIN、EPOLLOUT等挂到这张注册表里。epoll_wait则负责等待就绪事件当有fd状态变化时内核会把对应的fd和事件信息填入一个就绪链表。这套设计最精髓的地方在于注册表和就绪链表是完全独立的两块结构。注册表在epoll_ctl时被维护不需要每次等待都把所有fd重新传一遍就绪链表只在fd真正有事件触发时才往里挂节点epoll_wait只需要从链表头开始取数据取多少取决于链表里有多少节点而不是全部连接数。这也是为什么很多文章说epoll复杂度是O(1)。严格讲不是绝对意义的O(1)更准确的说法是epoll的等待成本O(就绪连接数)。连接少、就绪也少时开销极小连接多、就绪才几个时它依然只需要拷贝那几条就绪记录不用把整张注册表搬到用户态。3.2 就绪事件是怎么被主动收集起来的epoll的另一个精髓在于事件驱动四个字。select/poll是用户每次来问谁好了没内核挨个检查一遍再回答epoll是用户在epoll_ctl注册的时候就告诉内核你帮我盯着这个fd它有动静了通知我。内核里的实现是当这个fd上有数据到来或者可以写入的时候内核会主动触发一个回调把这个fd对应的epoll_event结构体挂到epoll实例的就绪链表上。说得再直白一点select/poll像传统的人工巡检每个机器都要走过去看一眼状态epoll像给每台机器装了传感器出故障自动上报。连接规模上来之后这两种模式的效率差距是数量级的。epoll底层保存注册fd的数据结构是红黑树增删查的平均复杂度都是O(log n)这在处理大量连接注册、频繁删除的场景下表现很稳定。很多人面试时背epoll用了红黑树这没错但你要能解释清楚红黑树是给谁用的——它是给epoll_ctl服务的用来快速定位某个fd是否已经注册过而epoll_wait真正收数据时用的是就绪链表根本不是红黑树。3.3 LT和ET门铃响一次和响到你处理为止这是epoll最容易被忽略、也是面试最高频的区别点。默认的LT模式也就是水平触发只要fd上还有数据没被读出来epoll_wait就会反复通知你。比如对端发了一个10KB的包你一次只读了1KB那么下一次调用epoll_wait这个fd仍然会出现在就绪队列里直到你把剩余数据全部读完。ET模式也就是边缘触发内核只在fd状态从无数据变为有数据的那一刻通知你一次。如果这次你没把数据读完后续即使缓冲区里还躺着数据内核也不会再触发通知了除非对端再发新数据状态再次发生从无到有的变化。我习惯用门铃来类比LT模式的门铃带记忆你人没出去它就一直在响直到你处理完才停ET模式的门铃按下只响一声响完就不管了你没听见就是错过了。所以ET模式下你必须在收到这次通知时把缓冲区里的数据尽量读干净否则就会丢数据。ET模式为什么必须配合非阻塞IO这是很多新手栽跟头的地方。因为你要在一次性事件里把数据读完很自然的写法是循环调用recv直到返回0但如果对端暂时没发新数据读空的socket默认会阻塞在那里。这一阻塞整个事件循环就死掉了其他连接全部没响应。所以ET模式一定把socket设为O_NONBLOCK循环recv直到返回EAGAIN表示这次真的读完了。EAGAIN不是错误是正常状态是缓冲区暂时没数据时的标准返回值。void handle_read(int fd) { char buf[4096]; ssize_t n; while (1) { n recv(fd, buf, sizeof(buf), 0); if (n 0) { // 处理数据 send(fd, buf, n, MSG_NOSIGNAL); } else if (n 0 errno EAGAIN) { // 数据读空了正常退出 break; } else if (n 0) { // 对端关闭 close(fd); break; } else { // 真错误 close(fd); break; } } }LT和ET怎么选我的建议是如果你的业务代码还不熟练、对缓冲区的处理不放心先用LT它逻辑简单不易丢事件很多成熟框架默认就是LT。如果你追求更高的并发处理能力希望在每次事件到来时减少系统调用次数再考虑ET。两者对比维度LT水平触发ET边缘触发通知次数数据没读完会一直通知状态变化只通知一次缓冲区处理要求低漏读还会再通知高必须一次读干净socket模式阻塞/非阻塞都行必须非阻塞系统调用次数相对多相对少上手难度低高4. 手写一个epoll高并发echo服务并跑通压测4.1 完整代码与启动过程理论说了一堆直接上一份能跑的代码。这是一个基于epoll的LT模式echo服务单线程就能处理大量连接#include stdio.h #include stdlib.h #include string.h #include unistd.h #include fcntl.h #include errno.h #include sys/socket.h #include netinet/in.h #include sys/epoll.h #define MAX_EVENTS 1024 #define PORT 8888 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 void handle_read(int fd) { char buf[1024]; ssize_t n; while ((n recv(fd, buf, sizeof(buf), 0)) 0) { send(fd, buf, n, MSG_NOSIGNAL); } if (n 0) { close(fd); } else if (n 0 errno ! EAGAIN errno ! EWOULDBLOCK) { close(fd); } } int main() { int lfd socket(AF_INET, SOCK_STREAM, 0); int opt 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(PORT); bind(lfd, (struct sockaddr*)addr, sizeof(addr)); listen(lfd, 128); set_nonblock(lfd); int epfd epoll_create1(0); if (epfd 0) { perror(epoll_create1); return 1; } struct epoll_event ev; ev.events EPOLLIN; ev.data.fd lfd; epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, ev); struct epoll_event events[MAX_EVENTS]; for (;;) { int n epoll_wait(epfd, events, MAX_EVENTS, -1); if (n 0) { perror(epoll_wait); break; } for (int i 0; i n; i) { if (events[i].data.fd lfd) { while (1) { int cfd accept(lfd, NULL, NULL); if (cfd 0) break; set_nonblock(cfd); struct epoll_event cev; cev.events EPOLLIN; cev.data.fd cfd; epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, cev); } } else if (events[i].events EPOLLIN) { handle_read(events[i].data.fd); } else if (events[i].events (EPOLLERR | EPOLLHUP)) { close(events[i].data.fd); } } } return 0; }编译运行gcc echo_epoll.c -o echo_epoll ./echo_epoll然后另开一个终端测试nc 127.0.0.1 8888 hello hello你输入什么服务端就原样返回什么。这个服务同时能撑多少连接我拿简单压测工具试过单线程不加任何业务逻辑的情况下本地回环能稳定维持两三万并发连接不卡顿消息吞吐大概在每秒钟几万个小包的量级。这个数字已经远超阻塞socket多线程在同等资源下的表现。4.2 代码里每个关键步骤的理由代码看起来简单但每一处都不是随手写的。监听socket为什么要设非阻塞因为accept在没有新连接到达时默认会阻塞。在epoll_wait返回的那个循环里如果accept卡住了整个事件循环就停了。所以这里把它设成非阻塞然后循环accept拿到EAGAIN就说明当前没有待处理的连接了。监听socket为什么要注册EPOLLIN事件而不是EPOLLOUT因为新连接到达对监听socket而言是一次可读事件。EPOLLIN表达的是这个流上有数据/事件可以处理了对于监听socket这个数据就是一条新的连接请求。listen(lfd, 128)的128是什么它是内核为这个监听socket维护的已完成连接队列长度上限不是最大并发连接数。大量连接同时涌入时如果accept处理速度跟不上超过这个值的连接就会被内核直接丢弃。生产环境一般会把这个值调大一些比如1024或4096。为什么epoll_wait的超时时间传了-1-1表示永久阻塞直到有事件发生。在LT模式下这是安全的因为只要有数据没读完epoll一定会把你唤醒。但如果你在同一个事件循环里还有定时任务要跑就不能用-1了否则定时任务会被一直饿死这时候应该设置一个合理的超时毫秒数。对非监听fd的错误处理我在代码里单独判断了EPOLLERR和EPOLLHUP。很多人会忽略这两个事件结果就是连接异常断开后代码永远收不到任何通知连接泄漏到系统fd上限服务彻底没反应。异常事件不是可选项是必选项。4.3 常见压测方式和结果解读最简单的验证方式是用脚本发起多组连接for i in $(seq 1 1000); do nc -d 127.0.0.1 8888 test_$i done wait不过这种脚本方式拿到的数据比较粗糙更准确的方式是写一个多线程客户端每个线程建立一条长连接然后周期性发送数据并记录响应延迟。我实测下来有一个比较典型的规律连接总数从1000涨到10000时echo的延迟几乎没有变化因为epoll的处理能力只跟当前瞬时就绪的连接数相关跟总连接数关系不大。这一点如果面试被问到epoll为什么适合高并发可以作为论据。5. 从单线程到Reactor多路转接在高性能服务器里的真实用法5.1 事件循环到底在循环什么上面那个echo服务就是典型的Reactor模式骨架一个线程不断调用epoll_wait拿到就绪事件后分发给对应的处理逻辑。这个模式的核心不只是epoll接口而是你写代码时的思维模型——事件循环里永远不做阻塞的事情。事件循环的每一轮迭代大概做三件事等待事件、识别事件、处理事件。在echo服务里识别和处理都极其简单。但一旦业务变复杂比如要读文件、要查数据库、要调用远程服务这时候如果直接在事件循环里执行任何一次慢操作都会阻塞整个循环所有连接陪着你一起等。所以优秀的设计会把事件循环拆成IO读写和业务处理两个阶段。5.2 单reactor、多reactor与线程池的演进单线程Reactor的典型代表是Redis。Redis只有一个事件循环线程它靠多路转接管理成千上万的客户端连接然后所有命令在同一个线程里串行执行。它的高性能来自事件循环内几乎不做耗时操作和数据结构高效而不是靠并发执行。但大部分业务做不到Redis这么极致的快于是就有了演进方案单Reactor负责accept和分发把实际业务处理丢给工作线程池再进一步主Reactor只负责监听新连接acceptor把连接分配给多个子Reactor线程每个子Reactor各自维护自己的epoll实例各自管理一批连接。这就是多Reactor多线程模型Netty、Nginx的worker进程设计都有类似思路。一句话总结取舍Reactor线程越多并发处理能力越强但连接分配、锁竞争、线程通信的复杂度也上来了。小体量服务单线程Reactor够用高吞吐网关才值得上多Reactor。5.3 事件循环里最怕出现的阻塞点我排查过不少线上事故最后都定位到一个共性事件循环里出现了阻塞调用。最典型的有三种同步读本地大文件、同步执行耗时SQL、在事件处理里写了sleep或者死循环重试。这些操作只要出现一次整个服务的所有连接都会出现莫名卡顿。解决思路通常是要么把耗时操作异步化要么扔到独立线程池要么用非阻塞方式轮询结果。你一旦养成事件循环里不碰阻塞操作的习惯很多故障天然就消失了。6. 实战中踩过的坑与排查思路6.1 最容易踩的三个事件处理坑第一个坑是漏注册异常事件。上面代码里我特意写了EPOLLERR | EPOLLHUP的分支因为很多人只注册EPOLLIN和EPOLLOUT结果对端突然断电或者进程崩溃时内核要通知的其实是异常事件你没有监听就永远不会知道连接就越攒越多直到fd耗尽。排查这类问题可以用ss -lntp看连接堆积也可以用strace -p pid看系统调用卡在哪里。第二个坑是EPOLLOUT的误用。可写事件在大多数时候都是立刻满足的也就是说只要你注册了EPOLLOUTepoll就会频繁唤醒你。正确的做法是只在当前要发送的数据太大一次send没发完时才临时注册EPOLLOUT等缓冲区可写后写完再移除。我把这个原则叫按需监听否则你的epoll_wait会被写事件刷屏CPU空转得厉害。第三个坑是忘记处理对端半关闭。TCP对端调用shutdown只关闭写方向读方向仍然开着这时候你会收到EPOLLRDHUP事件而不仅仅是EPOLLIN。如果不监听它服务端会一直认为连接还在资源被白白占用。生产代码里建议在注册事件时同时关注EPOLLIN | EPOLLRDHUP | EPOLLERR。6.2 accept惊群与EPOLLEXCLUSIVE多线程模式下如果多个线程同时对同一个epoll实例调用epoll_wait新连接到来时内核会唤醒多个线程但最终只有一个线程能成功accept到连接其他线程被白白唤醒。这个现象就是惊群。Linux从内核4.5开始提供了EPOLLEXCLUSIVE标记它让内核在唤醒时只挑一个线程直接避免了惊群损耗。如果你的内核版本支持建议在注册监听fd时加上这个标记。另一种更彻底的做法是SO_REUSEPORT让多个socket分别listen同一个端口内核在accept阶段就做了负载均衡每个进程各自维护epoll实例互不干扰。Nginx就是这么做的。6.3 容器网络环境下的多路转接服务要检查什么现在很多服务跑在容器里多路转接本身在容器内外没有区别但环境问题会掩盖程序问题。我遇到过几次服务在宿主机上跑得好好的进容器就连接不上的情况排查下来无非三类一是容器用了bridge模式但没有正确做端口映射外部访问不了监听端口二是容器内ulimit -n被设成了默认的1024连接数一多就报Too many open files三是host网络模式下端口冲突两个容器抢同一个监听端口。排查思路很固定先在容器内用ss -lntp确认服务监听在哪个IP和端口然后在宿主机上用nc -vz ip port测试端口连通性再用strace跟一下系统调用确认epoll_wait是否正常返回。这套流程基本能定位所有网络层没问题但应用连接不上的问题。6.4 面试里关于epoll的高频题怎么答才不被问倒很多面试者喜欢背epoll是O(1)的这个说法并不严谨。严谨的回答是epoll把全部连接的管理和只查就绪事件这两件事拆开了注册和删除走红黑树等待和返回走就绪链表因此实际开销只和就绪连接数成正比而不是总连接数。这样回答面试官能确定你是真理解了原理不是背的结论。另一个高频题是为什么ET必须非阻塞。答案要落在数据读取的完整性上边缘触发只通知一次你必须一次读完而循环recv时不能让阻塞的recv卡住事件循环所以必须非阻塞。如果对方继续追问LT模式为什么可以用阻塞socket可以这样答因为LT模式下你没读完epoll会反复通知你即使你这次阻塞了一次下次它还会找你所以不会出现永久卡死但为了事件循环的稳定工程上通常还是统一设置非阻塞。最后说点个人体会。多路转接这套东西看文档三天能看完但真正变成自己的还得是自己在压测环境里把它跑起来故意制造一些异常场景对端突然断开、大批连接同时涌入、发送超大数据包看看程序会不会崩、会不会泄漏连接。我建议每个学到这里的人都亲手走一遍阻塞socket → select → poll → epoll的改造过程你会发现每次替换都是一次思维升级而这种升级比你背五十个面试题都有用。
返回列表