十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

深入理解I/O多路复用:从select、poll到epoll与Reactor模式

深入理解I/O多路复用:从select、poll到epoll与Reactor模式 CPU密集型任务光靠线程数就能顶上去但网络服务不一样——你想想看一个连接建立之后绝大多数时间都在等待数据到达等待期间线程完全闲置。如果每个连接占一个线程一万个连接就要一万个线程且不说内存吃不吃得消光是上下文切换就能把CPU干穿。单线程模型想要撑住海量连接靠的压根不是“算得快”而是“等得聪明”——把成千上万个连接交给内核去监视哪个有数据就通知哪个线程全程只在有活干的时候才动。这篇内容就是围绕“单线程 I/O多路复用”这条主线展开的。我会从阻塞I/O的困境一路讲到select、poll再到epoll的演进逻辑拆解Reactor事件驱动模型在Redis、Nginx这些明星项目里的落地方式最后给出一套基于epoll的可运行代码骨架和一些高并发场景下特别容易踩的坑。适合刚接触网络编程的读者建立全局认知也适合工作了两三年但对底层模型理解还不够系统的同学查漏补缺。1. 瓶颈到底在哪一个连接占用一个线程为什么走不通1.1 阻塞式调用的本质线程把时间全花在了等数据上先从最经典的阻塞socket开始。默认情况下你调用read去收数据如果对端一直不发这个线程就会卡死在系统调用里一步都动不了。写一个最简单的accept循环只能服务一个连接while (1) { int client_fd accept(listen_fd, ...); // 处理这个连接的数据读取完全阻塞 handle(client_fd); }这段代码平时写着玩没问题但稍微一推演就完了。第一个客户端连上来之后只要它不主动发数据程序就永远停在handle里面后面的连接根本accept不到。所以现实中稍微靠谱点的做法是每来一个连接就开一个线程去处理while (1) { int client_fd accept(listen_fd, ...); pthread_create(tid, NULL, thread_main, client_fd); }每个连接一个线程逻辑上没有问题但代价极其惊人。首先一个线程默认栈空间在Linux上是8MB就算你调低到1MB一万个线程也要10GB的内存。其次线程一多操作系统就要不断做上下文切换每次切换要保存和恢复寄存器、栈指针、内核态和用户态之间的边界这可不是免费午餐而是实打实的CPU开销。业内把这个问题叫C10K即一万个并发连接时代的一道坎——用多线程去扛几乎扛不动。1.2 非阻塞I/O出现之后为什么不直接暴力轮询那把socket设成非阻塞不就行了非阻塞模式下read调用立即返回没有数据就返回错误码EAGAIN。这样的话单线程可以把一万个fd全部轮询一遍看看谁有数据可读。思路是对的但问题出在“轮询”这两个字上。假设有一万个连接每次循环就要做一万次系统调用。系统调用是有固定成本的——从用户态切到内核态内核再切回来一次大约微秒级别。一万次系统调用就是十毫秒量级而数据到达事件本身是稀疏的可能这一轮只有两三个fd真正有数据你却有9997次调用是完全白做的。单线程忙起来之后CPU时间几乎全消耗在“有没有数据”这个无效探测上等真正处理数据的时候反倒没多少余量了。所以单纯的非阻塞 用户态轮询只适用于连接数很少的场景压根不是大并发问题的解药。1.3 破局思路把“监视”这件事交给内核既然用户态轮询效率太低那能不能让内核帮我盯着一堆fd谁有事件我就通知谁这个思路就是I/O多路复用的核心价值——一次调用内核完成对所有fd的状态检查用户态只对有事件的fd做处理。这里的关键转折在于监视事件是内核的职责内核本来就是为每个fd保存状态的地方它看这些fd几乎是零成本的不需要像用户态那样反复进行系统调用。用户只需要告诉内核两件事——帮我盯哪些fd、盯什么事件——然后阻塞在等待结果上。数据到达的那一刻内核把就绪的fd挑出来返回给用户整个过程只需要一次系统调用效率有了质的飞跃。这就是select、poll、epoll这套机制最初要解决的问题。2. I/O多路复用三件套select、poll与epoll的演进逻辑2.1 select能干活但上限锁死select是I/O多路复用最早的面孔。它的核心思想在Linux里是通过fd_set这个位图结构来维护fd集合的每次调用select之前你要把需要监听的fd加入到一个fd_set里内核帮你扫描一遍有事件就返回。听上去挺美但实际用起来有三个明显的坑。第一个坑是fd数量上限。fd_set是用固定大小的位图实现的FD_SETSIZE在Linux上默认是1024也就是说select最多只能监视1024个fd。百万连接想都不用想一万个连接就先把这层天花板给捅破了。第二个坑是每次调用都要把整个fd集合从用户态拷贝到内核态判断完之后内核再把这些数据拷回来。连接少的时候无所谓连接多了以后光拷贝就够喝一壶的了。第三个坑是就绪检测的复杂度是O(n)。select把fd_set拷进内核之后内核得挨个bit去检查有没有事件扫描完整张位图才知道哪些fd就绪了。一万个fd每次调用都要从头到尾扫一遍数据没到的fd占了绝大多数全部是无效劳动。2.2 poll解决数量限制但没解决扫描效率poll是对select的一次改良。它不再用位图而是引入了一个struct pollfd数组每个元素包含fd、关注的事件、返回的事件三个字段。这样一来fd的数量不再受1024限制你可以把几千几万个fd都塞进去理论上只要内存放得下就行。但poll在本质上没跳出select的框子。它依然是每次调用都把整个数组从用户态拷到内核态内核依然是线性扫描所有fd去检查状态就绪之后依然要整数组拷回用户态。所以poll解决的只是“数量限制”这一个单一问题复杂度还是O(n)连接数一多依然很吃力。2.3 epoll从“扫描”到“回调”的范式切换epoll是Linux下真正能把百万连接落到实处的基础设施。它和在select/poll上的思路完全不同核心不在于“扫描”而在于“回调”。先看epoll的注册方式。select每次调用时把全部fd交给内核临时检查而epoll通过epoll_ctl这个系统调用提前把fd和关注的事件挂到内核里。挂进去的那一下内核会做两件关键的事一是把这个fd对应的epitem节点插入一棵红黑树用来支撑增删查二是给这个fd的等待队列注册一个回调函数。数据到达的时候网络协议栈会触发这个回调把对应的epitem节点挂到epoll实例的“就绪链表”上。然后看epoll_wait。用户线程调用epoll_wait的时候内核做的事情远比线性扫描高效得多——它直接跑去就绪链表上取节点链表上有多少节点就返回多少就绪fd。这个操作的复杂度是O(k)k是就绪fd的数量和总连接数n完全解耦。就算你有100万个连接一次只有5个fd有数据epoll_wait也就只处理这5个真正的“按需返回”。我用一张表把三者之间的关系摆出来方便做个直观对比特性selectpollepollfd数量上限1024FD_SETSIZE无硬编码限制无硬编码限制每次调用数据拷贝整个fd_set拷入拷出整个pollfd数组拷入拷出epoll_ctl注册后无需重复拷贝就绪检测复杂度O(n)线性扫描O(n)线性扫描O(k)k为就绪fd数事件通知方式用户态检查revents用户态检查revents内核回调 就绪链表边缘触发支持不支持不支持支持ET模式性能特征连接少时够用适配更多fd但仍是扫描高并发下性能衰减极慢2.4 缓存区与边缘触发epoll的另一个杀手锏epoll相对select/poll还有一个容易被忽略但特别关键的优势——水平触发与边缘触发的区分。select和poll本质上是水平触发只要你没把数据读完内核每次都会提示你这个fd可读。这在逻辑上省心但也会带来“重复提醒”的问题某个fd只到了几十字节的数据你每次进事件循环都要把它处理一遍。epoll的水平触发模式LT行为类似但边缘触发模式ET完全是另一个玩法。ET模式下如果某个fd的数据没读完内核不会再次提醒你只会在“状态变化”的那一刻通知一次。这个设计大幅减少了内核与用户态之间的重复通知在高并发下能显著降低无效唤醒的开销。但代价是你在ET模式下必须自己保证“把数据读到读不动为止”否则漏掉的数据就没有下一次机会了。所以ET模式几乎必然搭配非阻塞fd来用循环调用read直到返回EAGAIN才算这次读取任务完成。很多新手在做ET模式的时候把数据读漏了一排查发现就是循环条件没写对。3. 事件驱动与Reactor模式单线程扛住百万连接的关键拼图3.1 为什么单线程反倒有优势我见过不少同学第一次接触“单线程撑百万连接”这个概念时第一反应都是“不太可能吧”。CPU就一个核单线程岂不是让它忙疯了这里要分清两个概念CPU密集型任务和I/O密集型任务。网络服务本质上是I/O密集型的大量的时间消耗在等待数据到达上。线程在等待过程中什么都不做纯粹占着资源。单线程模型下CPU时间只花在真正有事件需要处理的时刻其他时间都阻塞在epoll_wait里由内核来接管等待。拿Redis来举例它为什么大量场景下比多线程的数据库还快一个很重要的原因就是它避免了线程切换和锁竞争。多线程模型下你要么加锁保护共享数据要么用无锁结构处理并发复杂度直线上升。单线程事件循环里处理一个请求的时候绝不会有另一个线程动同一个数据天然不需要加锁自然也就没有死锁、竞争这些让人头皮发麻的问题。3.2 Reactor模式的三大核心部件要想实现“单线程 事件驱动”这套玩法Reactor模式是最经典的组织方式。它拆开看只有三个核心角色。第一个是事件源。在Linux的语境里事件源就是一组被监听的fd包括监听socket和所有已连接的socket。这些fd上可能发生可读事件、可写事件、异常事件等。第二个是事件多路分发器。也就是刚才讲的epoll_wait。它负责等待事件源上的事件发生一旦有事件就把对应的fd从内核中拿出来返回给调用方。第三个是事件处理器。每一个fd都关联着一个处理逻辑比如新连接到了要accept可读事件到了要recv数据可写事件到了要发送缓冲区的数据等等。循环分发的核心动作就是取出就绪fd找到对应的handler调用它处理事件处理完之后回到epoll_wait继续等待。这套骨架的伪代码几乎是所有事件驱动框架的原型后面讲代码实现的时候我会把每一行都拆开讲。3.3 从单Reactor到多Reactor现代框架的典型架构单线程Reactor虽然简洁但有一个明显的限制——事件处理这些逻辑不能太重否则后面的fd都会被堵住。所以实际工程中根据处理逻辑的轻重衍生出了几种不同的布局。第一种是单Reactor单线程最典型的就是Redis。Redis的处理逻辑本身非常快纯内存操作而且需要严格保证单线程语义所以直接就把accept、read、write、业务处理全部放在一个线程里跑完。得益于Redis指令执行时间普遍极短这个模型在绝大多数业务场景下都够用。第二种是单Reactor多线程比如早期的Netty实现。主线程只负责监听事件分发收到读事件之后把数据交给一个工作线程池去处理业务和回包主线程不阻塞在业务逻辑上。第三种是多Reactor多线程这也是Nginx和Netty的标准模型。主Reactor只负责accept新连接然后把新连接分发给多个子Reactor每个子Reactor各自有一个事件循环负责一部分连接的读写事件。这种架构的好处是子Reactor可以充分利用多核CPU而且每个子Reactor独立运行、互不干扰。选择哪种布局本质取决于你的业务是CPU密集、I/O密集还是纯内存操作。没有万能的架构只有当前场景下最合适的取舍。4. 核心落地一套基于epoll的单线程Reactor骨架4.1 从accept到epoll_wait的完整流程理论讲了一大堆最后还是得落到代码上才踏实。下面这个例子是一个简化的echo服务器只做一件事客户端发什么就回什么但整个流程完整覆盖了epoll创建、事件注册、事件分发、数据收发的全部环节。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include arpa/inet.h #include sys/epoll.h #include sys/socket.h #include fcntl.h #define MAX_EVENTS 1024 #define BUFFER_SIZE 4096 // 设置文件描述符为非阻塞模式 static int set_nonblocking(int fd) { int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); return fd; } int main() { 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(8080); bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)); listen(listen_fd, 1024); // 1. 创建epoll实例size参数在Linux 2.6.8之后只是给内核一个初始容量提示 int epoll_fd epoll_create(1); struct epoll_event ev, events[MAX_EVENTS]; // 2. 把监听socket注册进去关注可读事件有新连接到达 ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, ev); printf(echo server listening on 8080\n); while (1) { // 3. 阻塞等待事件超时设为-1即无限等待 int n epoll_wait(epoll_fd, events, MAX_EVENTS, -1); for (int i 0; i n; i) { if (events[i].data.fd listen_fd) { // 4. 新的连接到达接受它并注册到epoll实例 struct sockaddr_in client_addr; socklen_t client_len sizeof(client_addr); int conn_fd accept(listen_fd, (struct sockaddr *)client_addr, client_len); set_nonblocking(conn_fd); ev.events EPOLLIN | EPOLLET; // 边缘触发模式 ev.data.fd conn_fd; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, ev); } else { // 5. 已有连接上有数据可读 int fd events[i].data.fd; char buf[BUFFER_SIZE]; ssize_t nread; while (1) { nread read(fd, buf, sizeof(buf)); if (nread 0) { // 原样写回演示用实际业务不要这么干 write(fd, buf, nread); } else if (nread -1 errno EAGAIN) { // 数据读完了跳出循环下次有数据内核会再通知 break; } else if (nread 0) { // 对方关闭连接 close(fd); break; } } } } } }4.2 这段代码里最容易被忽略的细节先看listen_fd的处理。每个新连接到达的时候监听socket本身会触发EPOLLIN事件。这里有一个常见误区就是把accept的结果当成新fd直接注册进去就行了但实际工程中你还要考虑accept返回EAGAIN的情况——比如多个连接同时到达内核只触发了一次事件但accept队列里其实排了好几个连接。保险的方式是循环accept直到返回EAGAIN为止。再看边缘触发的循环读取。我把客户端fd注册成EPOLLIN | EPOLLET这是边缘触发模式有数据到达时内核只通知一次。所以每次收到可读事件后必须用while循环不停read直到读取函数返回EAGAIN才说明当前数据全部处理完了。如果只读一次就跳出剩余的数据可能会在下一次新数据到达前一直滞留在内核缓冲区里造成数据延迟严重的还会引发粘包问题。还有一点是write处理。这段代码直接调用write写回但如果客户端收得慢write也可能返回EAGAIN也就是发送缓冲区满了。真正生产级的实现要有一个写缓冲区外加EPOLLOUT事件配合缓冲区有数据时才注册写事件发完了就注销避免频繁触发可写事件造成忙轮询。Redis内部完整实现了这套逻辑代码非常值得去读一读。4.3 验证一下到底能不能撑住“百万连接”级别的场景每次讲到百万连接总有人质疑是不是吹牛。明确说一下百万连接在数据层面不难做到——连接建立之后如果客户端不发数据服务端这边基本只有内存占用一个fd加一个epitem节点大概几KB的内存100万个连接需要几个GB的内存现代服务器完全没压力。难的是百万连接的并发场景也就是同时有大量连接在收发数据。真正限制你的往往不是epoll本身而是系统参数。举个例子Linux每进程默认最多打开的fd数量通常是1024你不调ulimit就去跑百万连接第一步就卡死了。常见需要调整的参数包括# 放开单进程能打开的文件描述符数量 ulimit -n 1048576 # 调整内核关于TCP连接状态的参数 sysctl -w net.core.somaxconn65535 sysctl -w net.ipv4.tcp_max_syn_backlog65535 sysctl -w net.ipv4.ip_local_port_range1024 65535 # 如果追求更大规模还需要调整端口范围等另外提一个容易踩的坑服务器端accept的连接本身不消耗本地端口但压测客户端如果在一台机器上发起海量连接会面临端口耗尽的问题。local_port_range默认只提供几万个端口客户端机器上端口用完了后面的连接就会失败。所以真正做大规模压测时往往需要多台客户端机器同时发起连接或者压测机和服务端分布在不同的内网环境中用多个源IP来规避这个限制。5. 实战中的大坑与小技巧高并发网络编程经验实录5.1 回调里绝对不能干重活单线程事件循环最忌讳的事情就是在处理某个事件时执行耗时操作。你处理一个请求用了100毫秒这期间epoll_wait不会被调用意味着其他所有连接即使有数据到达也只能排队等着。单线程的容错能力就建立在“事件处理要极快”这个前提上。所以实际开发中凡是涉及磁盘I/O、远程调用、复杂计算的操作一定要扔到其他线程池去执行事件循环线程本身只做轻量的收发和协议解析。那种把SQL查询直接写在事件回调里的写法几乎等于把事件循环直接卡死。5.2 epoll惊群多线程时代的新问题虽然单线程模型本身不会碰到惊群但一旦扩展成多Reactor架构或者你用一个多线程模型去跑epoll就很容易遇到惊群问题。多个线程同时阻塞在epoll_wait上一个连接到达时内核会把所有的阻塞线程全部唤醒但最终只有一个线程能成功accept其他线程白白被唤醒一次CPU白忙活并带来额外的上下文切换开销。以前的处理方式是加一个全局锁让同一时刻只有一个线程进入epoll_wait。Linux 4.5之后引入了EPOLLEXCLUSIVE这个事件标志可以让你在向epoll注册事件时指定“唤醒时只唤醒其中一个线程”从根本上避免惊群。这也是Netty、Nginx这些框架在主从Reactor模型中会用到的一个标志。5.3 常见问题速查表现象根因解决思路数据明明到了事件循环没有反应边缘触发模式下数据没读完后续不会再触发循环读取直到EAGAIN或改用水平触发模式连接数一多CPU使用率飙升没有使用ET模式 大量无效唤醒或某些fd注册了EPOLLOUT按需注册EPOLLOUT避免发送缓冲区空闲时反复触发accept返回EAGAIN多个连接在短时间内同时到达只触发了一次事件在事件处理时循环accept直到EAGAIN压测到几万连接时报“Too many open files”进程fd数量达到ulimit限制执行ulimit -n调大上限并确认系统级fs.file-max设置多个线程同时被epoll_wait唤醒但只有一个能处理惊群问题高版本内核使用EPOLLEXCLUSIVE或对epoll_wait加锁设置了EPOLLET每次只收到一次通知但数据量特别大ET模式特性没有再通知的机会配合非阻塞fd做循环读取或者在业务允许时用LT模式降低复杂度客户端正常关闭服务端长时间感知不到没有处理read返回0的情况read返回0时主动调用close释放fdTCP半连接队列满了导致新连接建立缓慢客户端大量发起连接但服务端accept速度跟不上调大net.core.somaxconn和tcp_max_syn_backlog保证accept处理及时5.4 再补充两个低调但好用的调优技巧第一个技巧是给每个连接设置合理的TCP_NODELAY选项。在低延迟交互场景下Nagle算法会合并小包导致数据发送延迟很多刚做网络编程的同学会踩到这个坑表现为Ping延时不正常地忽高忽低一抓包发现数据都堵在发送缓冲区里。对于交互性强的应用关闭Nagle带来的低延迟收益远大于小包多的成本。第二个技巧是epoll_wait的超时时间不要设置成0也不建议长期设置一个比较大的数值。超时设为0意味着不断返回——即使没有事件也要执行循环体CPU直接被干满。生产环境通常是设成-1也就是让线程阻塞在等待上没有事件就彻底睡过去有事件再立刻回来处理。只有当你有定时任务需要周期执行的时候才给epoll_wait设置一个合理的超时值比如100ms用事件循环的空闲期顺带跑一遍定时任务。6. 从百万连接到真实业务这套模型能走多远Redis、Nginx、Netty、Node.js这些技术的底子全部是事件驱动加I/O多路复用这套内核。你把这套模型吃透了再看它们的源码或架构图很多原本觉得神秘的设计都是一下子就能对号入座。以Redis为例它的aeEventLoop本质上就是一个封装好的epoll事件循环文件事件和时间事件都挂在这一个循环上。你在网上看到的各种Redis性能调优文章动不动就说“保持单线程特性”但很少讲清楚核心原因——单线程模型配合epoll天然无锁又高效这是Redis能在纯内存场景下达到惊人QPS的根本基础之一。Nginx则是把事件驱动和多进程结合起来了。每个worker进程是独立的单线程事件循环master进程负责管理worker生命周期。这种多进程加事件循环的模型既解决了CPU多核利用的问题又规避了多线程共享内存的锁竞争还天然比多线程更稳定——一个worker挂了其他worker不受影响。到了Netty这里三件套已经变成标配主从Reactor线程组、事件循环、ChannelHandler流水线。不管是最简单的聊天室还是大型RPC框架底层传输层Netty帮你屏蔽了Java原生NIO里大量繁琐的细节但你如果不懂它底层是epoll在工作遇到线上诡异问题的时候还是会一头雾水。我自己这些年做高并发服务的一个体会是框架可以帮你做很多事情但底层那套“内核帮你监视、事件分发给用户、用户只处理就绪项”的逻辑你必须亲手写过、调过、压过才真正算是入行网络编程的门。纸上谈兵和手上有数完全是两个境界。建议找个周末把上面那个echo服务器抄下来扩展一下加一个写缓冲区试试ET和LT两种模式下的CPU表现差异再顺手用压测工具打个几百并发看看——跑过之后你对这套模型的理解会完全不一样。
返回列表