如果你用top盯着一台流量突然上涨的服务器,最直观的感受是CPU占用一路飙升,但并发连接数可能才几千。我刚接手一个长连接网关项目时就是这个状态:客户端一多,accept就开始报EMFILE,系统日志里刷满了Too many open files,可单机内存明明还很宽裕。后来我把IO模型从“一连接一线程”改成了Reactor,才真正把连接数的水位从几万拉到几十万,也理解了为什么几乎所有高性能网络库都在讲这个模型。
这篇内容不打算堆概念,而是把一套适合Linux环境下使用的Reactor网络模型从设计、epoll细节、代码骨架,一直聊到内核参数调优和真实踩坑。它适合想搞懂高并发服务器原理的后端开发者,也适合正在做长连接网关、即时通讯、推送服务、IoT接入层的人参考。文章里的代码和配置都是我实际压测环境下用过的,你可以照着搭一份出来。
1. 为什么百万级并发最终选了Reactor
1.1 阻塞式“一连接一线程”到底卡在哪
很多人第一次写高并发服务,最直觉的方案是“每个连接开一个线程”。代码逻辑确实简单:accept到一个连接,就pthread_create启动一个线程,在线程里阻塞读socket。但真正的生产环境一旦连接数上了几千,这个方案就会开始崩。
问题不在CPU计算本身,而在线程上下文切换。一个线程被阻塞在read时,它的内核栈、用户态栈、寄存器现场都要保留;操作系统每次调度都要经历用户态到内核态再到用户态的完整路径。一次上下文切换的耗时通常在5到10微秒,看起来不夸张,但如果系统里有几千个线程在同时抢CPU,每秒切换次数会膨胀到百万甚至千万级别,CPU时间全耗在切换上,真正处理业务的比例会非常低。
更麻烦的是内存占用。线程默认栈大小通常是8MB,即使使用精简栈也要至少数百KB到1MB。算一笔账:想让单机支持10万并发连接,假设每个活跃连接都会占用一个线程,哪怕每个线程栈压到1MB,光是线程栈就是100GB,这还没算内核task_struct、thread_info和信号相关资源。所以“一连接一线程”在C10K时代就被否定了,并不是大家不爱写,是硬资源穷尽之后写不下去。
1.2 C10K到C1000K:事件驱动才是正路
行业里讨论C10K问题时,主流解法是IO多路复用,也就是让一个线程管理成千上万个socket。它的核心思路不再是“忙等某个连接的数据”,而是把socket都注册到一个内核事件表里,由内核告诉我们哪些fd可读、哪些fd可写。
这就是Reactor模型的土壤:事件驱动加上非阻塞IO。Reactor可以拆成两部分看,“反应器”负责等待事件、分发事件;“事件处理器”负责对就绪事件做具体处理。整个过程是:把关心的事件注册进去,然后阻塞等待,一旦有事件就一次性取出,分发给对应的回调。用户代码不再是“我read这个fd直到有数据”,而是“这个fd可读的时候,请调用我的onRead”。
从C10K到C1000K,不是单点性能发生了魔法式提升,而是把等待成本从线程占用量级压缩到了事件循环内部。一个进程能同时持有的文件描述符上限提高到百万级是可能的,但前提是你不能用“一个占用一个线程”的思路去管理它们。
设计选型上,异步IO模型常被拿来和Reactor对比。异步IO(比如Linux AIO)是把读写请求交给内核,完成后通知用户;Reactor是用户自己循环读取非阻塞fd。Linux平台下成熟的异步IO实现比较受限,而epoll配上非阻塞IO,性能和实现可控性都更好。所以主流网络库Muduo、Netty、libevent在Linux上基本都是基于事件驱动的Reactor思路,这不是偶然。
1.3 先认清“百万级并发”的真实含义
我接触过一些项目,把“百万级并发”等同于“百万级QPS”,这是两种完全不同的东西。百万级并发通常指系统能同时维持的连接数量,连接可能大多处于长连接空闲状态;而百万级QPS指每秒能处理的请求数量。一个连接每秒发一条应用层心跳,百万连接可能对应巨大的事件量;但如果是百万个空闲长连接,事件循环大部分时间都在等最小超时。
所以,要让系统承受“百万连接”,首先要明确到底哪些连接是活跃的、哪些是空闲的。如果连接平均每秒一个包,百万连接会带来每秒百万次的事件通知,这会进一步要求用户态处理做到轻量、低拷贝;如果只是养着连接不发数据,资源核心消耗是内存和fd表,CPU压力反而不是最大的。
Reactor能撑住高并发,不是因为它是银弹,而是它把连接与线程解耦,让IO事件和数据处理的资源分配变得可控。接下来的实践都围绕这个前提展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Reactor核心机制拆解:从epoll到事件分发全链路
2.1 epoll为什么会成为Linux上的主角
Linux下的多路复用接口经历了select、poll到epoll的演进。select有FD_SETSIZE限制,默认只能管理1024个fd;poll虽然去掉了数量上限,但每次调用都要把全量fd集合从用户态拷贝到内核态,内核再线性扫描哪些fd有事件,当fd数量上升到十万时,这个扫描成本是灾难性的。
epoll把复杂度降了下来。它通过epoll_create创建一个epoll实例,内部维护一棵红黑树用于管理注册的fd;epoll_ctl负责添加、修改、删除fd;epoll_wait只返回有事件触发的fd,不再需要每次全量拷贝扫描。性能上,事件发生时内核通过回调把fd放到就绪链表,用户态只需要取走现成就绪的事件。
不过,epoll并不是所有场景都完胜poll。如果你管理的是几百个连接,且每个连接都频繁活跃,epoll的红黑树和回调机制优势并不明显,poll的线性扫描可能更简单。但当连接数达到数万、数十万时,epoll的收益就会非常明显。我们讲百万级并发,技术上默认会把epoll放在核心位置。
给socket设置非阻塞也很关键。Reactor期待的是“事件来了,我马上去读,读不到就算了”,这个逻辑必须配合非阻塞IO使用。如果fd是阻塞的,在读事件回调里执行read就会因为对方刚好没发完整数据而挂住线程,直接拖垮事件循环。所以凡是注册进Reactor的连接socket,都要在accept之后立刻设置O_NONBLOCK。Linux下用accept4可以一步完成,省一次系统调用。
2.2 LT和ET不是玄学,是两种触发哲学
epoll的事件通知有两种模式,水平触发LT和边缘触发ET。LT下,只要缓冲区里还有没读完的数据,内核就会持续通知你“可读”;ET下,只有缓冲区从空变为非空、或者有新数据到来时,才会通知一次。
从写法上看,LT更宽容。你用LT时,即使一次只读一部分,下一轮epoll_wait还会再通知你,不容易漏事件。ET则要求你必须一次性把数据都读出来,一般通过while循环read到返回EAGAIN为止,否则剩余数据可能要等下一次新数据到来才会再次触发,从而造成数据滞留。
我在实际项目里更推荐LT起步。很多人觉得ET性能一定比LT高,其实在epoll的实现里,水平触发只是每次epoll_wait返回时会多检查一次就绪状态,并不会造成数量级差距。瓶颈往往在用户态内存拷贝和业务处理上,而不是这一下触发方式。我见过用ET却一直不读EAGAIN导致CPU飙到100%的例子,这种问题在LT上几乎不会出现。
如果你确实想用ET,有几个条件必须具备:一是fd必须是非阻塞;二是读取要循环到EAGAIN;三是最好配合大块buffer和使用readv分散读,减少系统调用次数;四是对应用层的粘包处理要非常严谨。原生的网络通信协议,比如WebSocket或自定义长度帧,数据未完整到达时,你需要把半包先暂存在用户态缓冲区,等下一个可读事件到达时再拼接。
2.3 一个连接从建立到销毁要经过哪些事件
Reactor把一个连接的完整生命周期拆成若干个
