基于epoll与Reactor的百万并发服务器架构设计与实践
发布时间:2026/9/28 5:29:10来源:尧图网络
从需求层面讲“epollreactor实现百万并发”是很多后端工程师跨不过去的一道坎。它不是一个简单的demo而是对系统、内核、内存、调度和业务设计的综合大考。这篇文章会把我在设计这类高并发接入层时积累的完整思路和踩坑记录写出来从epoll的底层行为到reactor线程模型再到压测前的内核参数和内存规划全部按实操顺序展开适合想挑战百万长连接、高性能网关或IM服务器的同学参考。1. 百万并发到底意味着什么先从需求和误区说起1.1 百万连接和百万QPS是两个维度很多第一次接触这个目标的人会把“百万并发”理解成“每秒处理百万请求”这是典型的误区。百万并发连接指的是同时保持建立状态的连接数量也就是TCP连接数达到百万级别而每秒钟实际产生的请求量可能并不高。举个具体例子一个物联网平台有100万台设备在线每5秒上报一次心跳每秒实际吞吐只有20万心跳包但服务器必须时刻维持100万个socket的连接状态。你的epoll关注集合里会挂上100万个fd但这100万个fd并不都是活跃的。真正考验系统的是“少量活跃事件分散在巨量空闲连接中”时如何仍然保持低延迟和低资源占用。如果按QPS来设计你会在单核CPU上死磕事件处理效率但按百万连接来设计你首先要解决的是内存布局、fd管理、定时器扫描和锁竞争这些问题。这两个目标需要分开对待否则后面所有架构选型都会走偏。1.2 为什么传统IO模型扛不住传统多线程阻塞式模型里一个线程负责一个连接线程数跟着连接数走。到一万个连接就得开一万个线程光线程栈空间默认8MB虚拟内存和上下文切换开销就能把服务器压垮。线程多了CPU时间全耗在切换上业务逻辑反而得不到执行。这时的系统瓶颈不在网卡不在CPU而在调度器和操作系统对线程资源的管理上限。select和poll模型在fd数量上来之后也会失效。select有FD_SETSIZE的固定上限通常1024你得重新编译内核才能改大poll没有数量限制但每次调用都要把完整fd列表从用户态拷贝到内核态然后再从内核态拷贝回来。百万fd意味着每次poll要拷贝百万个结构体哪怕只有几个事件发生整体开销也是O(n)。这种O(n)扫描机制天然不适合海量连接场景。epoll把复杂度降到了O(1)级别因为它只关心“有事件发生的fd”不在每次调用时全量扫描。它靠的是内核维护的一个事件就绪链表用户态通过mmap和内核共享部分状态加上epoll_wait直接返回就绪事件集合。这套机制才是百万连接真正可行的地基。但地基只是第一步你的用户态程序怎么组织事件处理是决定你能否在百万fd下依然流畅的另一个关键。2. 前置基础epoll的核心机制与选型理由2.1 从select/poll到epoll差在哪里epoll的三个关键API是epoll_create、epoll_ctl和epoll_wait。epoll_create在内核里创建一个epoll实例这个实例内部维护两个重要结构一个红黑树用于存放你注册的所有fd及其事件类型一个就绪链表用于存放触发了事件的fd。epoll_ctl负责增删改红黑树中的节点epoll_wait只返回已就绪的事件不需要你逐个检查。这套设计带来的直接收益注册一百万fd红黑树查找和插入是O(log n)比poll的全量拷贝O(n)高了一个量级等待事件时内核直接遍历就绪链表返回没用的事件根本不会出现在用户态。更妙的是就绪链表里每个节点在fd触发事件时会被挂进去epoll_wait只是把这些节点摘出来返回事件拿到后你再通过回调去处理业务。select和poll都是“无状态”的每次调用都要重新向内核传递完整的监视列表而epoll利用内核中的红黑树“记住”了这批fd之后每次wait都只传一个超时时间。理解这个差异你就能明白为什么百万fd下只有epoll或类似的事件通知机制能活下来。2.2 水平触发与边缘触发选错会踩坑epoll支持两种触发模式这是新手最容易踩的第一个大坑。水平触发模式下只要fd的缓冲区里还有数据可读或者写缓冲区还有空间可写epoll_wait就会一直上报该fd哪怕你上次没处理完下次依然会再通知一次。边缘触发模式下内核只在状态发生“变化”的那一次通知你比如缓冲区从空变成有数据只会通知一次如果你没把数据读完那后续再也收不到通知数据就会被卡死在缓冲区里。我见过不少项目直接用默认的水平触发代码简单不容易丢数据但活跃fd频繁上报会造成大量重复系统调用。边缘触发要求你必须一次性把数据读完通常搭配非阻塞IO和循环read直到返回EAGAIN。优点是事件通知次数少处理效率高但编程复杂度明显上升。以我个人的实践建议没有十足把握不要一上来就上边缘触发。先用水平触发把业务逻辑跑通再通过压测对比两种模式下的CPU占用和事件唤醒频率。对于百万连接这类场景我最终选择了边缘触发加主动read到EAGAIN的方式前提是read循环里有严格的单次读取上限避免饿死其他连接的处理。2.3 epoll的常用API与工作流程写一个最简事件循环核心代码并不长int epfd epoll_create(1); struct epoll_event ev; ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev); struct epoll_event events[1024]; while (1) { int n epoll_wait(epfd, events, 1024, -1); for (int i 0; i n; i) { handle_event(events[i]); } }就这么几行背后已经涵盖了epoll的主要流程。这里有两个容易忽略的细节epoll_wait的maxevents参数也就是数组大小决定了单次最多返回多少个就绪事件压测时如果这个值太小内核里堆积的就绪事件要多次wait才能取完会放大延迟另一个是timeout为-1表示无限阻塞在纯事件驱动模型里很常见但如果你还需要处理定时任务最好把timeout设置成最近一个定时器的到期时间。有人问过我把fd注册进epoll之后是不是就不需要管这个fd了。答案不是你得负责fd的关闭和从epoll中摘除。如果直接close(fd)内核会自动把它从epoll实例中移除但更安全的做法是在连接关闭前先EPOLL_CTL_DEL再close避免因为fd被复用而出现脏事件。高并发的fd复用速度很快脏事件会导致连接串号这个问题在压测高连接数时特别容易爆出来。3. Reactor模式如何组织海量连接的事件循环3.1 单线程Reactor的经典骨架Reactor模式的核心思想是把“等待事件”和“处理事件”解耦。一个事件循环线程负责集中调用epoll_wait拿到就绪事件后根据事件类型分发dispatch给对应的处理器函数。业务处理逻辑不关心自己是哪个连接的fd只关心事件来了怎么处理。最经典的骨架是主循环调用epoll_wait阻塞等待就绪事件对每个就绪fd根据EPOLLIN、EPOLLOUT等不同事件调用独立的处理函数处理完当前事件后回到主循环继续等待下一批事件。单线程Reactor的精髓在于“所有连接的事件处理都在一个线程内顺序完成”这意味着天然没有锁竞争、没有上下文切换开销、不需要考虑并发安全。Redis就是单线程事件循环的典型代表它在几万连接时表现极其稳定。但单线程的局限也很明显如果你的业务处理包含磁盘IO、数据库访问或者复杂计算单线程处理这些耗时操作时epoll_wait就会停在那里其他连接全都得不到响应。所以在百万连接场景里单线程Reactor只适合做纯协议转发、简单的读写缓存和数据透传一旦涉及复杂业务就必须引入线程池或改成多Reactor模型。3.2 从单线程到多线程主从Reactor与工作线程池我推荐主从Reactor加工作线程池的组合这也是Netty、libuv等主流框架采用的结构。主Reactor只负责处理新连接的建立也就是监听listen_fd的EPOLLIN事件新连接accept之后把客户端fd注册到某一个从Reactor的epoll实例上。从Reactor可以有多个每个跑一个线程它们之间通过一定策略比如取模或轮询分配连接。这样做的好处是“连接建立”和“IO事件处理”互不干扰即使某个从Reactor的事件循环因为业务阻塞变慢新的连接依然能被主Reactor快速accept不会出现“连接建立不了了”的雪崩。业务处理如果比较重从Reactor读到完整请求后可以封装成任务丢给独立的工作线程池去执行执行完毕再通过一个线程安全的队列把结果送回原Reactor线程由Reactor继续write响应。这个模型完美解决了两个问题一是把阻塞操作从epoll线程里剥离出去保证事件循环的响应速度二是充分利用多核CPU让多个从Reactor并行处理不同fd上的IO事件。很多号称百万并发的网关卡本质就是这种主从Reactor的变体只不过加了更深度的连接管理。3.3 为什么Reactor适合epollReactor和epoll是天然的一对因为epoll本身就是事件通知机制而Reactor就是基于事件分发的事件处理模型。你的epoll_wait拿到的是一个事件列表Reactor告诉你怎么把这些事件对应到连接上下文、怎么调用用户注册的handler。两者结合的架构连接状态机非常清晰每个连接都有初始化态、读态、写态、关闭态每个状态对应不同的事件处理器。另外Reactor模式和epoll的EPOLLONESHOT配合起来也很好用。EPOLLONESHOT会保证一个fd上的事件在未重新注册前只触发一次这样可以防止多线程同时处理同一个fd导致数据竞态。在多Reactor模型里连接和线程的绑定关系比较稳定基本用不到EPOLLONESHOT但如果你采用“epoll_wait后把fd抛给线程池”的方式就必须用EPOLLONESHOT把事件处理权绑到某个工作线程上。4. 百万并发服务器架构设计关键模块拆解4.1 连接管理连接槽位、fd复用与哈希索引百万连接光是每个连接的状态管理就够写一本小书。你不能再为每个连接动态创建一套复杂数据结构必须预先规划好内存池和索引。一个标准的做法是创建固定大小的连接数组每个连接对象独占一个槽位槽位索引就是连接IDfd作为数组下标或哈希键来定位。fd和连接槽位要能互相快速查找。大多数服务器习惯把fd直接当作temporary key存入哈希表但fd在频繁开关连接时会被内核复用如果哈希表里残留脏项新连接可能拿到上一个连接的残留状态。我的做法是用数组下标直接对应fd但这个数组会非常大fd能到百万级别数组按最大fd预分配内存太浪费。实际用的是一种两级索引第一级是一个“fd到槽位”的哈希映射只存最近活跃的fd第二级是连接对象池本身连接对象用高并发内存池管理。当连接建立时从连接池取出一个空闲对象将fd和对象地址写入哈希表连接关闭后从哈希表删除并归还对象。关键点在于fd的复用周期很短哈希表里删旧建新的操作一定要保证原子性多线程环境要加锁或用无锁哈希表这一步是百万连接性能的分水岭。4.2 读写缓冲区与内存池避免频繁malloc每个连接都有收包缓冲区和发送缓冲区。如果每个连接每次收发时都直接malloc、free百万连接下内存分配器的锁竞争和碎片化会直接拖垮系统。Linux的malloc在分配和释放大量小内存时有一定的优化但面对每秒几万次申请释放依然会出现性能毛刺。我的做法是给每个连接预先分配一块固定大小的读缓冲区和可伸缩的写缓冲区。读缓冲区大小根据业务最大包长设置比如4KB或16KB不够时用链式缓冲区扩展。写缓冲区用内存池管理内存池按大小分级类似mcentralcache的设计小内存块从池里取用完退池不直接还给操作系统。这块内存来自预先mmap的大块区域通过自由链表切分和回收分配时间稳定在纳秒级。内存池的另一个优势是连接对象本身也能放在池中避免连接反复创建销毁带来的构造析构开销。在百万连接场景下连接对象可能达到几十MB甚至上百MB频繁分配和释放会带来严重的堆碎片。实际压测中发现使用内存池后内存碎片率从百分之十几降到了百分之二以内长时间的连接颠簸不再导致RSS内存持续增长。4.3 定时器与超时管理百万定时器怎么高效连接超时检测在百万连接场景里是最大的隐形杀手。如果每个连接一个定时事件而且用的是普通的最小堆或链表一秒内检测百万定时器会耗费大量CPU。有人说用时间轮确实时间轮是海量定时器的标准答案也是我最终采用的方案。简单解释时间轮它是一个环形数组每个槽代表某个粒度的时间片比如1毫秒。定时器根据到期时间哈希到对应的槽位插入操作是O(1)。指针每毫秒往前走一格检查该槽上的定时器链表是否有到期项。相比最小堆的O(log n)插入和O(log n)删除时间轮在定时器事件频繁增删的场景下优势明显。时间也可以分成多级比如第一级精确到毫秒第二级精确到秒能覆盖几分钟甚至几小时的超时需求同时减少内存占用。在连接建立和关闭都非常频繁的系统中连接超时定时器会大量创建和取消。用最小堆会频繁siftup和siftdownCPU消耗明显时间轮的添加和取消都是链表操作。另外不要试图把所有超时都精确到毫秒绝大多数业务场景的读超时、写超时、保活间隔都有容忍范围稍微粗糙一点的粒度能节省大量开销。4.4 线程模型与锁优化减少竞争多线程就离不开锁但锁的粒度决定了你的性能上限。一个常见的败笔是给全局连接管理表加个大锁每次事件处理都要抢锁线程一多反而比单线程还慢。好的线程模型应该让每个线程尽可能独立工作连接数据只属于某个Reactor线程不需要全局锁。在实际代码里我会这样划分每个从Reactor线程拥有自己的一组连接对象这些连接上的读写操作只由该线程处理所以读缓冲区和写缓冲区本身不需要加锁。需要跨线程通信的是工作线程池的任务队列用无锁队列比如基于数组的MPSC队列来传递任务和结果。全局唯一的带锁结构只有统计计数器和一些配置更新这些可以用原子操作或读写锁。锁竞争还来自fd哈希表。如果把fd到连接对象的映射放在全局每个连接事件都要读这张表竞争非常剧烈。更好的做法是每个Reactor线程维护一个独立的fd映射因为一个fd始终由同一个Reactor线程管理查表只在线程内部发生根本不需要锁。主线程accept后分配fd时决定好这个fd归属哪个Reactor后续所有操作都在那个线程上完成这种线程亲和消除了主要的锁冲突。5. 核心实现解析基于epollReactor的伪代码与实案5.1 事件循环主线程实现下面这段伪代码展示了单Reactor主循环如何和连接管理配合。虽然实际生产里是多Reactor但核心逻辑一致while (is_running) { int timeout_ms next_timeout_in_heap(); int n epoll_wait(epfd, events, max_events, timeout_ms); for (int i 0; i n; i) { int fd events[i].data.fd; uint32_t ev events[i].events; if (fd listen_fd) { handle_accept(); } else { if (ev EPOLLERR || ev EPOLLHUP) { close_conn(fd); } else { if (ev EPOLLIN) do_read(fd); if (ev EPOLLOUT is_writable(fd)) do_write(fd); if (need_close(fd)) close_conn(fd); } } } process_timers(); }这个循环最关键的一点是epoll_wait的超时设置不能总是-1。如果定时器堆里最近一个连接超时是100ms后就需要超时那epoll_wait最多等100ms就要返回让process_timers执行超时检测。这才能保证空闲连接能被及时回收避免死连接越积越多。另一个关键点是事件处理顺序。我习惯先处理可读事件再处理可写事件最后才判断连接是否应该关闭。因为一次epoll_wait可能同时返回EPOLLIN和EPOLLOUT如果先进行写操作而读事件还没处理可能把本该关闭的连接继续往出发数据造成资源浪费。5.2 连接建立与数据读取流程accept新连接时要做的事比想象中多int fd accept(listen_fd, nullptr, nullptr); if (fd 0) return; // 设置非阻塞和禁止Nagle算法 set_nonblock(fd); set_tcp_nodelay(fd); // 从连接池取对象 Conn *c conn_pool_alloc(fd); c-state ESTABLISHED; c-last_active now; // 注册读事件到当前线程的epoll epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ...);这里要注意accept之后需要立刻把连接状态初始化和epoll注册完成窗口期很短否则数据已经到了内核缓冲区而你还没有关注这个fd可能会丢失唤醒。不过只要使用的是水平触发模式后续epoll_wait仍会继续上报EPOLLIN边缘触发模式下这个问题就比较致命所以用边缘触发时accept后一定要立即注册。read流程就比较直接了void do_read(int fd) { Conn *c get_conn(fd); char *buf c-read_buffer; while (true) { ssize_t n read(fd, buf c-read_len, buf_free_space); if (n 0) { c-read_len n; c-last_active now; process_request(c); if (c-state CLOSED) return; } else if (n 0) { if (errno EAGAIN) break; close_conn(fd); return; } else { close_conn(fd); return; } } }如果业务处理很快直接在read循环里同步解析请求并写回响应性能最高。如果业务涉及数据库或远程调用就不能在这个循环里同步等待需要把完整的请求数据拷贝出来封装成任务丢给线程池让主循环继续读下一个连接。5.3 写事件管理与异步发送写入数据比读数据更容易出错。因为TCP发送缓冲区可能短暂满你没法保证一次write能把全部数据写完。这时候必须把剩余数据挂到连接的发缓冲区并向epoll注册EPOLLOUT事件。当EPOLLOUT触发且发缓冲区为空时要立刻删除EPOLLOUT注册避免fd一直可写导致忙轮询。这里面有个很容易忽略的问题每次调用write返回EAGAIN后再注册EPOLLOUT不如一开始就把数据追加到发缓冲区让事件循环统一处理。这样避免多次write系统调用还可以批量合并多个小数据包。很多开源框架采用“先尝试直接写写不完再排队”的优化策略其实对一半场景有效另一半场景因为TCP窗口拥塞导致频繁写一半这时候预排队更好。我的处理模式是void send_data(Conn *c, const char *data, size_t len) { if (c-write_len 0 c-writing) { // 尝试直接write ssize_t n write(c-fd, data, len); if (n 0) { data n; len - n; } if (len 0) return; if (errno ! EAGAIN) return close_conn(c); } append_to_send_buffer(c, data, len); epoll_ctl(c-fd, EPOLL_CTL_MOD, EPOLLIN | EPOLLOUT); }这里用完EPOLL_CTL_MOD而不是ADD因为fd已经注册过。频繁调用epoll_ctl修改事件也会消耗系统调用所以一定要避免无意义的修改比如已经注册了EPOLLOUT再次追加数据时没必要重复调用。可以在Conn结构里加一个字段记录当前注册的事件掩码只有确实需要变化时才调用EPOLL_CTL_MOD。6. 压测与实践验证百万并发需要做什么6.1 压测工具对比wrk、ab、自研客户端做百万连接压测你首先要明白wrk和ab这类HTTP压测工具是设计用来打吞吐量的它们创建的连接数通常不会太高几十万已经极限了。想要压到百万连接要么用很多压测机器要么自己写一个轻量级压测客户端利用异步IO同时建立大量连接。我建议分两步验证第一步用wrk打QPS看服务器在维持多少并发连接时吞吐能达标第二步用自研压测工具专注于连接数压测工具内部用epoll管理所有客户端socket分批注册、步骤化建连避免一次性accept太多导致系统抖动。自研工具也比较简单每个客户端连接建立后随机发送心跳包服务器只需回正确响应。关键是测试机和被测机之间要断开网络限制比如防火墙、端口范围限制等等。压测机本身的资源也很关键。一个进程最多打开的fd数默认是1024必须调大ulimit同时压测机的端口数只有六万多一台机器最多只能建立六万多个连接目标地址相同的情况下。所以百万连接压测通常需要十几台压测机联合进行或者启用多IP、多端口来扩展四元组数量。6.2 系统参数调优ulimit、tcp栈、内核参数这是最磨人的部分很多人代码写得没问题却倒在操作系统默认配置上。我整理了必改的内核参数# 最大文件描述符数 ulimit -n 1048576 sysctl -w fs.file-max1048576 # 端口范围 sysctl -w net.ipv4.ip_local_port_range1024 65535 # TIME_WAIT快速回收和重用小范围环境慎重 sysctl -w net.ipv4.tcp_tw_reuse1 sysctl -w net.ipv4.tcp_tw_recycle0 # 内核4.12以后移除了新版不用管 # TCP连接跟踪大小 sysctl -w net.netfilter.nf_conntrack_max1048576 # 听队列长度 sysctl -w net.core.somaxconn32768特别提醒tcp_tw_reuse是给客户端的不是给服务端的。服务端主动关闭连接时会产生TIME_WAIT大量TIME_WAIT会占用本地端口和内存这在高并发的短连接场景中是个大问题。如果服务协议允许尽量让客户端先主动关闭连接服务端用tcp_tw_reuse和tcp_timestamps配合来做优化。此外TCP内核缓冲区默认值可能不够。百万连接下即使每个连接只分配几KB的内核缓冲区合计也要好几个GB内存所以需要限制每个socket的收发缓冲区大小用sysctl或setsockopt设置明确上限防止内存被内核缓冲吃光。6.3 实测中常遇到的问题与排查技巧问题一连接数到达20万左右后accept速度急剧下降甚至不再接受新连接排查下来通常是somaxconn不够或者accept的backlog参数设置太小。内核中全连接队列满后syn包直接丢弃客户端表现为连接超时。调整net.core.somaxconn和listen时的backlog参数会有立竿见影的效果。另外如果你的accept循环里做了一些重操作连接建立速度会被放大要保证accept函数尽可能轻量。问题二内存涨到一定程度后出现抖动触发OOM百万连接下每个连接如果分配1MB用户态缓冲区那瞬间就是100GB所以连接对象和缓冲区必须严格按需。我遇到过因为连接池回收不彻底导致空闲连接一直占着大块写缓冲区不释放的情况。解决方法是在连接变成空闲一段时间后通过定时器把写缓冲区降为初始大小然后归还剩余内存给内存池。连接池的收缩策略比扩张策略更重要。问题三CPU占用莫名高但业务处理量很低首先要怀疑busy-loop也就是事件循环一直在空转。常见原因是在EPOLLOUT事件触发时没有正确删掉写事件或者错误设置了0超时循环。另一个可能性是epoll_wait返回很多EPOLLEXCLUSIVE误唤醒只在多线程不配合使用时有。可以先用perf top看一下热点函数如果是tcp_ack或者tcp_recvmsg高那可能是内核参数问题如果是自己的epoll_wait处理逻辑高那要检查是否存在大量无效的事件循环。排查问题我的经验是先用perf和strace缩小范围。strace看系统调用频率是否异常perf看热点在用户态还是内核态然后再针对性地通过开启详细日志观察连接生命周期。压测时记得每一层预热避免把冷启动效果当成稳定指标。7. 经验总结与避坑指南7.1 关于百万并发必须接受的现实百万并发是“运维内核参数、架构设计、代码实现、压测验证”四者的综合产物任何一环有短板整个系统都会露馅。如果你没有提前为百万连接准备好内存预算代码优化得再漂亮也会被操作系统OOM杀死如果你的业务处理逻辑里有一个阻塞的数据库查询在Reactor线程上跑那再好的epoll也无法让你的延迟稳定。另外很多方案在几十万连接时还风平浪静到百万时出现“conn串号”“事件丢失”“定时器失效”“内存膨胀”等疑难杂症这些几乎都与连接的创建和销毁管理有关。你必须严格执行“连接对象池化、fd归属线程、事件注册状态机、超时时间轮”这些基础设施千万不要在百万并发的路上写一堆临时补丁。架构上该上主从Reactor就上别用单线程Reactor硬撑也别用一个全局锁接盘所有连接状态这些曾经让我吃过大亏。7.2 个人实操体会后来把架构稳定在“主线程accept 四核从Reactor线程 独立工作线程池 时间轮管理超时”的模型后我在单机16G内存、8核的裸金属服务器上成功保持过105万左右的长连接数同时稳定处理每秒3~5万的心跳消息。坦白讲这个跳动的心跳消息量不算高但连接数确实到百万级别了整个过程中CPU占用在60%上下没有出现锁竞争剧烈或者内存无限增长的问题。那次压测让我最意外的是真正的问题不在用户态代码而在内核的TCP连接表占用和内存回收策略。所以我想特别强调开始写代码前先在目标机器上用一个小demo跑通“建连、保活、断连、重连”的完整循环通过/proc/sys/net/ipv4/tcp_mem和free的数据估算每连接内存开销。只有心中有数后面的百万并发之路才不会被突发的系统级问题打断节奏。如果你也正在尝试同样的目标愿上面这些经验能让你少走几段我为期几个月的弯路。
网站建设高端定制企业官网