接手过一个消息推送系统的性能优化,压测跑到二十万并发连接时,整个进程CPU直接打满,新连接几乎无法建立,后来把网络层从阻塞式多线程模型彻底重构为Reactor网络模型,同样一台机器干到了近百万连接,CPU占用反而降了40%。这篇文章就把我在Linux网络编程里对Reactor模型的完整实践和理解整理出来:它到底怎么设计、代码怎么落、系统参数怎么调、百万级并发背后藏着哪些坑。
适合三类人看:准备做高并发网关或IM服务的新手、正在用select/poll/多线程模型但遇到性能瓶颈的开发者,以及想系统理解epoll事件驱动本质的人。我会把原理、代码、调优、排查一条线讲完,尽量说人话。
1. 并发模型演进:为什么最终都会走到Reactor
1.1 一连接一线程的代价
早期做Linux网络服务,最直观的写法就是监听socket接受连接,然后为每个连接创建一个线程,在线程里阻塞式read/write。代码逻辑清晰,开发效率高,但它有两个致命短板。
第一个是线程开销。一个线程默认栈大小8MB,当然实际不会全用,但创建线程、上下文切换、cache miss都在烧CPU。2000个连接就是2000个线程,调度器光切换上下文就忙不过来了。第二个是阻塞IO造成的资源浪费。线程等待客户端数据时是挂起的,CPU却无法把这个空闲线程的算力让给其他连接,纯粹的白白耗着。用“店小二模式”来类比:每来一个客人就雇一个服务员,服务员守在桌边等客人点菜,客人不吭声服务员就干站着,餐馆能雇得起几千个服务员吗?显然不现实。
所以要解决高并发,只有两条路:要么让一个线程服务大量连接,要么让一个连接很少占用线程。Reactor走的是前一条路,把IO等待变成事件通知。
1.2 select和poll的先天不足
在epoll出现之前,Linux程序员用select或poll实现事件分发。select有个经典的FD_SETSIZE限制,默认是1024,虽然可以改内核宏重新编译,但本质上它的效率是O(n)的:内核每次都要遍历全部fd,找出就绪的fd返回给用户态,用户态还要再遍历一遍找到具体可读写的fd。
poll解决了FD数量限制,但仍然避免不了全量遍历。更麻烦的是,每次调用都要把fd数组从用户态拷贝到内核态,连接数多了之后,拷贝开销和遍历开销会吃掉大量CPU。这就导致select/poll在几千连接时还能凑合,一旦到几万连接,性能断崖式下降。
epoll的出现改变了游戏规则:它通过红黑树管理fd,通过就绪链表返回活跃fd,复杂度从O(n)降到O(就绪数量)。在高并发场景下,大部分连接是空闲的,活跃连接可能只有几百个,epoll只需要处理这几百个,效率自然高得多。
1.3 从事件通知到Reactor思想的落地
epoll解决了“内核怎么通知你哪些fd有事”的问题,但并没有规定你拿到事件后怎么处理。Reactor模型做的事情,就是把“事件检测”和“事件处理”拆开,用一个循环统一接收事件,再按事件类型分发到对应的回调函数。
可以说Reactors就是epoll的“使用范式”。你不用Reactors的话,直接在项目中写epoll_wait加一堆switch-case也能跑,但业务代码会和网络逻辑缠在一起,连接管理、超时、断开重连这些细碎逻辑能把代码搅成一锅粥。Reactors把网络层骨架搭好,业务方只需要注册“可读时干什么”“可写时干什么”这几个回调,开发效率和可维护性会明显好很多。
2. Reactor模型核心组件与整体架构
2.1 五个角色搞定所有网络逻辑
一个标准的Reactor模型里有五个核心角色,理解他们之间的关系,整个模型就通了一半。
第一个是事件源,也就是fd,连接socket、监听socket、定时器fd、信号fd都是事件源。第二个是事件多路分发器,Linux上就是epoll实例,它负责等待事件发生,内核把就绪事件放到链表里。第三个是Reactor本身,它持有一个事件循环,调用epoll_wait拿到活跃事件列表后,把每个事件交给对应的处理器。第四个是事件处理器Handler,它内部保存着fd和业务回调,真正执行read、write、业务处理。第五个是Acceptor,专门负责处理监听socket的可读事件,每次触发就调用accept把新连接拿出来,再封装成新的Handler注册到Reactor上。
这里最容易搞混的是Reactor和epoll的关系。Reactor是一个设计模型,epoll是底层的系统调用,两者不是一回事。你在Reactor里可以用epoll,也可以用select、poll、kqueue,模型本身不绑定具体OS API。只是Linux下epoll表现最好,所以大家提到Reactor基本默认搭配epoll。
2.2 单线程、多线程、主从Reactor怎么选
单线程Reactor很好理解:一个线程跑event loop,也就一个Reactor,所有连接都在这个线程里处理。优点是简单,没有并发问题,不需要加锁。坏处是:如果某个Handler的回调里做了耗时操作,比如查数据库、解析大包,整个事件循环都会被卡住,其他连接全部饿死。所以单线程Reactor只适合CPU密集型不重、回调很快的场景,比如静态代理转发。
多线程Reactor是对单线程版的改进:IO事件检测和分发仍然在一个线程,但业务处理丢到线程池里异步执行。这样即使某个业务处理慢,也不会阻塞事件循环。要注意的是,业务线程池和Reactor线程之间需要一个线程安全的任务队列,这里是有锁竞争的,设计时要控制队列粒度。
主从Reactor是目前最主流的方案,Netty、Redis的IO线程模型基本都属于这个思路。它分成两组:mainReactor只管监听socket的accept事件,接受到新连接后把连接注册到某个subReactor上;subReactor可以有多个,每个跑一个独立的事件循环,专门负责各自管理的连接的读写事件。不同subReactor可以绑定到不同CPU核心上,充分利用多核。
| 模型 | 线程数量 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 单线程Reactor | 1个 | 无锁、极简 | 单点,业务耗时阻塞所有连接 | 轻量转发、短连接 |
| 多线程Reactor | 1个IO线程 + 线程池 | IO与业务分离 | 任务队列有竞争 | 业务有耗时操作 |
| 主从Reactor | 1个main + N个sub | 多核利用、连接隔离 | 实现复杂、需要绑核 | 百万级长连接网关 |
2.3 选型背后的关键考量
我自己的判断标准是:连接数超过5万,或者单连接流量不大但数量很大,直接上主从Reactor。如果连接数在1万以内,业务逻辑又很轻,单线程Reactor就够用,没必要为了技术炫技增加复杂度。
另外一个重要因素是业务是否异步化。Reactor模型本身只解决IO并发,如果你的业务处理是同步阻塞的,比如在回调里直接访问MySQL,那无论哪个Reactor模型都会出问题。这时候必须把业务丢到业务线程池,或者干脆把业务也做成异步。简单来说,Reactor负责“连接级并发”,业务线程池负责“任务级并发”,两者组合才能扛住大规模连接。
3. 手写一个轻量级Reactor:核心代码与细节
3.1 事件封装:EventContext设计
写Reactor的第一件事是设计事件上下文。我们需要把一个fd、关注的事件类型、回调函数、参数绑在一起。
struct EventContext { int fd; uint32_t events; // EPOLLIN / EPOLLOUT / EPOLLET std::function<void()> readCallback; std::function<void()> writeCallback; std::function<void()> closeCallback; void* userData; // 业务自定义数据 bool active; };这里有个很小的坑:回调函数不建议直接用裸函数指针加void*参数,虽然C语言经典写法是这样,但C++场景下用std::function更安全。lambda捕获业务对象的this指针后,要注意对象生命周期,连接断开时一定要把fd从epoll里摘除,并且把EventContext标记为inactive,防止悬空回调。
3.2 事件循环:epoll_wait的封装
Reactor的核心就是event loop,代码不长,但每个细节都影响性能。
void Reactor::loop() { const int maxEvents = 1024; epoll_event events[maxEvents]; while (!stop_) { int n = epoll_wait(epollFd_, events, maxEvents, timeoutMs_); for (int i = 0; i < n; ++i) { EventContext* ctx = static_cast<EventContext*>(events[i].data.ptr); if (!ctx || !ctx->active) continue; if (events[i].events & (EPOLLERR | EPOLLHUP)) { ctx->closeCallback(); continue; } if (events[i].events & EPOLLIN) { ctx->readCallback(); } if (events[i].events & EPOLLOUT) { ctx->writeCallback(); } } doPendingTasks(); // 处理跨线程来的任务 } }每次epoll_wait都复用同一个events数组,不要把数组放在循环里反复构造。timeout的选择也有讲究:设为-1表示永久阻塞,但如果其他线程需要往Reactor里注册新连接,就必须用eventfd主动唤醒,否则新连接要等到有IO事件才会被处理。我习惯把timeout设为100ms,同时配合eventfd唤醒,兼顾实时性和CPU占用。
3.3 连接接入:Acceptor与Handler创建
accept流程很明确:监听fd变成可读,说明有pending连接,循环accept直到返回EAGAIN。
void Acceptor::handleRead() { while (true) { struct sockaddr_in addr; socklen_t len = sizeof(addr); int connfd = accept(listenFd_, (sockaddr*)&addr, &len); if (connfd < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) break; else continue; } setNonBlocking(connfd); // 配置TCP_NODELAY减少小包延迟 int flag = 1; setsockopt(connfd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag)); // 注册到subReactor EventContext* ctx = new EventContext(); ctx->fd = connfd; ctx->events = EPOLLIN | EPOLLET; ctx->readCallback = [this, connfd]() { handleConnectionRead(connfd); }; subReactor_->addEvent(ctx); } }注意accept必须用while循环,因为同一时刻可能有多个连接就绪,只accept一次的话剩下连接会留在内核队列里,直到下次可读事件触发。如果事件是ET模式,只accept一次就会丢事件,用LT模式虽然不会丢但会造成额外唤醒。
3.4 ET模式与读写分发要点
epoll有LT和ET两种模式。LT是电平触发,只要缓冲区还有数据就一直通知;ET是边沿触发,只有状态发生变化时才通知一次。做高并发网络框架,我建议统一用ET模式,配合应用层缓冲区和一次性读完/写完的策略。
读数据时,要用循环read,把socket缓冲区读到EAGAIN为止。这里有一个非常重要的细节:即使读到EAGAIN,也可能是因为socket收缓冲区里已经空了,但TCP数据还在路上,所以等下一轮EPOLLIN事件触发时再读。写数据也一样,一直写到EAGAIN才停下来,并且只有写缓冲区没写完时才关注EPOLLOUT事件。如果数据一次性写完,就不要注册EPOLLOUT,否则会频繁触发浪费CPU。
void handleConnectionRead(int fd) { char buf[65536]; while (true) { ssize_t n = read(fd, buf, sizeof(buf)); if (n > 0) { // 处理数据,解析协议,投递业务线程池 } else if (n < 0) { if (errno == EAGAIN) break; else { closeConnection(fd); break; } } else { // 对端关闭,这里要特别小心:read返回0不代表连接真的关闭了, // 如果有半包数据还在shutdown流里,可能需要先处理再close。 closeConnection(fd); break; } } }ET模式下最经典的坑是:读事件到来后没有循环读到EAGAIN,导致明明内核里还有数据,却因为不再触发新事件而漏读。另一个坑是每次read都固定读64KB,但应用层协议可能是几十字节的小包,一次读操作可能把多个包都读进缓冲区,拆包逻辑必须健壮。
3.5 定时器与跨线程唤醒
高并发网络程序免不了要处理心跳超时、连接超时、延迟任务。最直接的做法是在event loop里维护一个最小堆定时器,每次epoll_wait只阻塞到最近要触发的定时器时间点,醒过来后检查堆顶的定时器是否已到期。
int timeoutMs_ = nextTimerExpiration(); int n = epoll_wait(epollFd_, events, maxEvents, timeoutMs_); handleExpiredTimers();注意这样做的效果是:没有IO事件时,定时器到点也能精准唤醒。比单独开一个定时器线程更高效,不会因为锁竞争影响主循环。
跨线程唤醒用eventfd最干净。别的线程要往Reactor注册事件或者投递任务时,只需写入一个8字节整数到eventfd,I/O线程就会被唤醒。
// 子线程唤醒 uint64_t one = 1; write(eventFd_, &one, sizeof(one)); // 主循环中注册eventFd的读回调 read(eventFd_, &one, sizeof(one));3.6 内存池与缓冲区:百万连接背后的隐性成本
一百万连接,如果每个连接动态分配64KB读写缓冲区,光缓冲内存就120GB,直接爆掉。所以高并发框架通常都做内存池和分级缓冲区。
连接建立时只分配一个小块初始缓冲区,比如4KB读缓冲、4KB写缓冲。随着数据量增长,再按需扩容,或者使用链表式缓冲块,避免大块连续内存。释放时也不真正free,而是把内存块归还给内存池,减少系统调用和内存碎片。
缓冲区设计还有个细节:为了避免频繁的用户态/内核态拷贝,很多框架会提供直接操作读缓冲区已读区域的接口,业务处理完再更新读指针。这样可以少一次memcpy,在高吞吐时差异很大。
4. 从十万到百万:Linux系统级调优实战
4.1 文件描述符上限:第一道坎
百万连接首先意味着百万个fd。用户进程默认的fd上限是1024,不调的话一万连接都到不了。需要设置两个层面的限制。
# shell层面 ulimit -n 1048576 # 全局层面 sysctl -w fs.file-max=1048576注意ulimit只影响当前shell及其子进程,如果你用systemd托管服务,还要在service文件里设置LimitNOFILE。另外打开文件过多时,fd号会增大,epoll底层效率跟fd号无关,但程序里如果用了fd作为循环下标之类的技巧,要注意溢出。我见过有代码用char存fd,连接数一多就出诡异问题。
4.2 端口范围与TIME_WAIT:客户端侧的瓶颈
百万连接服务器,如果是客户端主动连接出方向,需要大量本地端口。默认端口范围只有28000多个,根本不够用。
sysctl -w net.ipv4.ip_local_port_range="1024 65535"同时要处理TIME_WAIT过多的问题。短连接场景下,主动关闭方会进入TIME_WAIT,会占用端口和内存。可以开启端口复用:
sysctl -w net.ipv4.tcp_tw_reuse=1要注意的是tcp_tw_reuse只对客户端的出方向连接有效,而且依赖时间戳选项。在服务端接受大量连接时,TIME_WAIT基本不是瓶颈,主要问题在于四元组复用,不要盲目以为开启reuse就能解决一切。
4.3 TCP内核参数:精细化调整
百万级并发下,TCP协议栈参数需要逐项优化。比如:
# 最大半连接队列,accept慢于SYN到达时增大 sysctl -w net.ipv4.tcp_max_syn_backlog=65536 # listen的backlog,应用层accept够快时也要设置大 # 代码里 listen(fd, 65536) # TCP可用端口范围 sysctl -w net.ipv4.ip_local_port_range="1024 65535" # 减少TIME_WAIT快速回收 sysctl -w net.ipv4.tcp_max_tw_buckets=2000000 # 增大TCP读写缓冲区上限 sysctl -w net.core.rmem_max=16777216 sysctl -w net.core.wmem_max=16777216 # 开启reuseport sysctl -w net.ipv4.tcp_reuseport=1SO_REUSEPORT是Linux 3.9引入的能力,允许多个进程/线程bind同一个端口,内核在accept之前做负载均衡。这个特性配合主从Reactor天然合适:多个subReactor线程各自创建一个子进程或各自bind同一端口,kernel会把连接均匀分发到不同线程,有效减少单个epoll实例的锁竞争。但要注意,使用SO_REUSEPORT时如果业务需要全局状态,比如全局连接计数,就必须处理多进程或线程间的状态同步。
4.4 内存估算:一条TCP连接到底吃多少内存
很多人对百万连接的内存有一种天生的恐惧,总觉得要几个T内存。实际上计算一下心里就有底了。
一个TCP socket在内核里有两个缓冲区,默认情况下读缓冲约128KB,写缓冲约128KB,但实际是按页分配的,初始不会真的用满。加上tcp_sock等内核数据结构,一条连接在内核态大约消耗3KB到5KB的私有内存。用户态每个连接还有EventContext、读写缓冲区、业务上下文,如果设计合理,一个连接控制在1KB到2KB是能做到的。
保守估算:一百万连接 = 内核态500MB + 用户态200MB,再算上应用代码和堆内存,单机2GB是够的。真正吃掉CPU的不是内存,而是每一条连接上的IO事件唤醒频率,所以压测时要关注的指标是“每秒活跃连接数”而不是单纯连接数。
4.5 线程模型与CPU绑核
主从Reactor落地时,subReactor线程数量一般和CPU核数一致。虽然线程可以自动被调度,但为了减少线程切换,最好把每个subReactor线程绑到固定CPU上。
taskset -c 0,1,2,3 ./server也可以在代码里设置CPU亲和性:
cpu_set_t set; CPU_ZERO(&set); CPU_SET(cpuId, &set); pthread_setaffinity_np(thread, sizeof(set), &set);绑核之后,连接被分发到哪个subReactor,就由这个线程一直负责,缓存利用率会更高。唯一要注意的是,不要让mainReactor和subReactor共享CPU核心,不然accept线程和IO线程互相抢占,延迟会变高。
5. 百万并发压测:方法论与真实问题排查
5.1 压测工具与连接模拟
压测百万并发连接,最直接的问题是一台客户端机器能发起的连接数有限,普通压测工具到几万连接就上不去了。常用做法是找多台压测机分布式灌连接,或者利用同一台机器多网卡、多IP的方式增加连接数量。
工具方面,wrk适合压吞吐和短连接,但长连接并发场景下表现一般,因为每个线程受端口数限制。更灵活的办法是写一段epoll客户端程序,用非阻塞connect在有限线程里模拟数十万连接。比如一台客户端,绑多个IP地址,每个IP能建立六万多个连接,三个IP就能到二十万。需要注意的是一开始大量connect会产生SYN队列堆积,服务端要相应调大tcp_max_syn_backlog。
5.2 listen队列溢出:连接建立失败的隐形元凶
压测时最常遇到的现象是:高并发下客户端connect返回Connection refused,但服务端进程明明活着。问题很可能出在accept队列满了。Linux内核里每个监听socket有两个队列:SYN队列(半连接队列)和accept队列(全连接队列)。如果用户态事件循环accept得不够快,全连接队列会堆积,超过内核参数后,新连接被直接丢弃。
用ss -lnt查看监听socket的Send-Q和Recv-Q,Recv-Q代表accept队列当前长度。如果Recv-Q长期接近backlog值,就需要扩大backlog,同时排查为什么accept慢。正常情况下,accept只是从队列里拿一个fd,不应该慢,慢往往是因为业务回调阻塞了主循环,或者事件分发逻辑里有锁竞争。
5.3 惊群效应与EPOLLEXCLUSIVE
多个subReactor线程如果同时监听同一个监听socket,accept事件来临时会唤醒所有被阻塞的线程,但最终只有一个线程accept成功,其他线程空跑一圈,这就是惊群。Linux 4.5之后,可以用EPOLLEXCLUSIVE标记监听fd,让内核只唤醒等待队列中的一个线程。
ev.events = EPOLLIN | EPOLLEXCLUSIVE; epoll_ctl(epollFd, EPOLL_CTL_ADD, listenFd, &ev);但更推荐配合SO_REUSEPORT使用,每个subReactor线程自己有一个监听socket,内核在三次握手时就做好负载均衡,完全避免惊群。这里有一个细节:使用REUSEPORT时,如果某个subReactor线程挂了,它绑定的监听socket也会消失,内核会自动把新连接转发给其他socket,但已经分发到该线程的连接会全部断开,所以业务上要做好重连。
5.4 ET模式下的写事件风暴
ET模式下写事件很容易踩坑。假设某个socket发送缓冲区已满,你注册了EPOLLOUT,然后业务把数据写完了,但没把EPOLLOUT去掉。下次该socket又有可写事件时,即使你没有新数据要写,内核一样会唤醒你,造成空转。解决办法是只在“数据没写完”的瞬间注册写事件,写完立刻摘除。
冲刷逻辑里还有一个容易忽略的点:用ET写数据时,可能要反复调用write直到EAGAIN。但如果循环里一次write就写完了,需要记得主动摘下EPOLLOUT,否则下次可写事件会再次触发。我见过不少框架在这里因为“忘记remove”导致CPU idle时候也在死循环唤醒,压测指标看起来很高,实际都是空转。
5.5 排查武器:ss、perf、strace
遇到性能问题时,我第一反应是用ss -s看全局socket统计,确认连接总数、内存占用、溢出计数。然后结合ss -lnt看监听队列状态。怀疑epoll问题的话,可以用perf top看热点函数,如果热点集中在epoll_wait和tcp_recvmsg之间反复跳,观察锁相关的热点如spinlock,多半是共享队列竞争太严重。strace -p可以看系统调用序列,但高并发下慎用,会产生大量跟踪输出干扰压测。
另外/proc/net/udp和/proc/net/tcp可以分段检查连接分布。比如统计哪个subReactor的fd数量不平衡,可以判断连接分发策略是否合理。
6. 写在最后:我踩过的坑与几点建议
6.1 别急着上协程
第一次接触百万人并发时,我一度觉得Reactor太底层,想直接上协程库把同步业务包起来。实际踩坑后发现,协程切换也有成本,而且大多数协程库底层还是依赖epoll,最终逃不出Reactor的框架。与其引入一层抽象,不如先把Reactor本身做扎实,业务复杂了再考虑用协程封装回调地狱。很多问题不是模型不够好,而是回调链上没有做清晰的错误处理。
6.2 用无锁队列替代锁
Reactor线程和业务线程之间的任务队列,一开始我用mutex加条件变量,压测到几十万QPS时,锁竞争变得非常明显。后来换成了带SPSC/MPSC语义的无锁队列,用内存屏障控制consumer和producer链,性能提升很明显。但无锁队列不是银枪头,它要求入队对象生命周期管理必须严谨,否则ABA问题会引发连环崩溃。如果团队对无锁结构不熟,可以先从细粒度锁开始,再根据热点逐步优化。
6.3 一定要压测再上线
百万级别连接的系统,最怕的不是设计不够好,而是没有在真实流量模型下压测。连接建立完不发送数据的“死连接”压测,能测出内存和fd管理问题,但测不出CPU瓶颈。一定要模拟一定的消息频率,比如每秒每条连接产生1到10条消息,观察CPU、延迟、丢包曲线。见过太多系统在压测脚本里只connect不read,上线后被小流量直接打崩。
最后再分享一个经验:Reactor模型并不是越复杂越好,如果你的业务只有几百个连接,用多线程阻塞式模型开发效率反而高得多。百万并发是目标场景,不是资历勋章。真正到了百万量级,你会感谢当初愿意花时间把每个write、每个EAGAIN、每块内存都抠到位的那个自己。