☰
从Reactor到百万连接:Linux服务端高并发实战指南
2026/10/11 13:28:39 网站建设 项目流程

如果你最近两三年才开始写Linux服务端,大概率看到过那张非常经典的图:一个叫 Reactor 的框把 accept、read、write 这些事件当作对象轮转分发,旁边标注着“百万级并发”。图看懂了,代码也抄了,用 epoll 写了一个 echo 服务,本机开 10 万个连接,CPU 才跳了 20%,忽然觉得自己离“百万”就差一个锦旗。等真拿 100 万连接去压的时候,发现卡住你的根本不是 Reactor 那段核心逻辑,而是文件描述符上限、内核参数、内存,甚至是你对“百万并发”的定义。

这篇文章把这条路上真正要过的关卡拆开讲:Reactor 模型本身是怎么工作的,它为什么适合高连接数场景,从单线程到多线程再到多进程的演进逻辑,最后到一台普通服务器上把连接数推到百万级的具体路线和坑点。适合刚接触服务端编程的同学,也适合那些已经写过 epoll、但从来没把系统参数当回事的朋友。

1. 先把Reactor和“百万级并发”这两件事说清

1.1 从一连接一线程到事件驱动

很多教材喜欢从 BIO(Blocking IO)讲起:一个客户端连接来了,服务端创建一个线程去 read。这个模型在连接数少的时候完全没有问题,因为每个线程的处理逻辑很直白。但连接数一旦过万,问题就出现了。

第一是线程本身的开销。每个线程默认栈空间在 8MB 左右,虽然实际提交不是一次性全部分配,但 1 万个线程的创建、销毁、调度切换,足以让操作系统陷入上下文切换的泥潭。第二是大部分连接并不是一直在收发数据,可能 99% 的时间都在沉默。让一个线程阻塞着等一个几乎不说话的连接,等于把资源白扔在那里。

事件驱动模型换了一种思路:我不给每个连接配一个线程,而是让一个循环统一盯着所有连接。哪个连接有数据可读、哪个连接可以写了、哪个连接刚完成握手,都由内核告诉你,你再去处理那一个"有事情发生"的连接。这个"盯着所有连接"的动作叫 IO 多路复用,这个"循环+分发"的结构就是 Reactor 模型的核心。

1.2 事件循环的三件套

一个典型的 Reactor 由三部分拼起来:

  • IO 多路复用器:在 Linux 上,目前的主流选择就是 epoll。它负责报告"哪些 fd 就绪了"。
  • 事件循环:一个 while 循环,反复调用 epoll_wait 拿到就绪事件列表,然后逐个处理。
  • 事件处理器:针对不同类型的事件写具体的回调逻辑,比如 accept 新连接、read 请求、write 响应。

代码层面长这样:

while (1) { int n = epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i = 0; i < n; i++) { if (events[i].data.fd == listen_fd) { accept_new_connection(); } else { handle_io_event(events[i].data.fd); } } }

这个结构没有锁、没有复杂的并发协调,一个线程就能撑几万甚至几十万连接。你不太需要关心那些没就绪的连接,它们就是内核里一个数据结构而已,花钱的是内存,不是 CPU。

1.3 百万连接不等于百万QPS

先把这个最容易误解的概念单独拎出来。社区里说"百万级并发",绝大多数时候指的是百万 TCP 长连接保持,也就是服务端同时维持 100 万个 ESTABLISHED 状态的连接,但每个连接并不一定在持续发数据。像消息推送、在线状态、长连接网关,都是这种模式。

百万 QPS 是另一回事。它意味着每秒有 100 万个请求进来,每个请求哪怕只有 1KB 的业务数据,那也是 100 万 KB,大约 1GB/s 的量。这个吞吐已经不是单台机器靠一个事件循环能独立扛住的了,网络带宽、网卡中断、协议栈处理都会先到极限。

所以正确理解是:Reactor 模型解决的是"连接数规模"的问题,让一台机器可以保持大量空闲或低频活跃的连接;而"每秒百万请求"是整体吞吐架构的问题,需要多机集群、流量分发、数据分片一起配合。把这两个概念分开,你后面做压测设计时才不会拿错指标。

2. 引擎对比:为什么最终是epoll扛旗

2.1 select和poll痛在哪里

每次面试都会被问一遍 select、poll、epoll 的区别,我很少给出教科书式的答案,因为只有真把 10 万连接跑起来,你才会理解那几条区别到底意味着什么。

select 的问题很硬。第一,它用一个 fd_set 位图表示关心的 fd,在 Linux 上默认 FD_SETSIZE 是 1024,也就是说一个 select 最多盯着 1024 个 fd,虽然你可以改头文件重新编译,但治标不治本。第二,每次调用 select,你都得把整个 fd_set 从用户态拷贝到内核态,内核要遍历全部 fd,看哪些有事件,再拷贝回来。连接数到 10 万,一次 select 就是 10 万个 fd 的全量搬运和遍历,这开销可不小。

poll 解决了 1024 的限制,改用 pollfd 数组,不再受固定位图大小约束。但它的核心问题没有变:每次调用还是要全量扫描所有 fd,时间复杂度是 O(n),而且依然存在用户态和内核态之间的数组拷贝。连接少的时候无所谓,连接越多,这个线性开销越致命。

2.2 epoll的效率来源

epoll 聪明在把"关注哪些 fd"这个信息留在了内核里。你用 epoll_ctl 把 fd 注册到一张红黑树上,内核维护这棵树;当某个 fd 上有事件发生时,内核通过回调把 fd 挂到一个就绪链表上。用户调用 epoll_wait 时,内核只需把就绪链表里的条目拷出来给你,不需要扫描全部 fd。

这就是"O(1)获取就绪事件"的真相。你关注 100 个 fd 和关注 100 万个 fd,只要就绪的数量差不多,epoll_wait 返回的速度就不会有明显差异。代价是每个注册的 fd 在内核里要分配 epitem 节点,会消耗一点内存,但换来的是规模化的效率。

用生活里的话说:select 是每天全校点名,不管有没有人找你都点一遍;epoll 是每个班自己记好谁有状况,报到校长办公室的只是一张"今天有事的人"的纸条。

2.3 LT还是ET:一次别再被面试题绕晕

水平触发(LT)和边沿触发(ET)这两个词把很多人劝退,其实落地差异很具体。LT 模式只要 fd 上有数据没读完,每次 epoll_wait 都会通知你;ET 模式只在状态变化的那一刻通知一次,比如缓冲区从空变成有数据时,只报一次,之后哪怕缓冲区里剩着数据,也不会再报,直到你把它读完。

对新手来说,我强烈建议第一版先用 LT。它符合直觉,不容易丢事件,就算你读了一半,下一轮 epoll_wait 还会再给你一次机会。ET 需要你配合非阻塞 IO,并且在可读事件到达后循环 read,一直读到 EAGAIN,否则没读完的数据可能永远晾在那里。很多线上事故就是这么出的:ET 模式下 read 了一次就 break,剩下的半包数据留在内核缓冲区,客户端一直等响应,服务端一直等新事件。

很多人觉得 ET 性能更高,但就 epoll 内部的通知机制来说,LT 和 ET 的差距并没有传说中那么大。真正让你性能提升的,是你为了配合 ET 而养成的"循环读到 EAGAIN"的习惯,以及非阻塞 IO 带来的整体流畅性。所以不要为了用 ET 而用 ET,先保证逻辑正确。

2.4 顺带纠正一个流传很广的说法:epoll不是异步

这个误区在网络上特别常见:有人把 epoll 说成"异步非阻塞 IO",面试官一问就露馅。epoll 本质上是同步 IO 多路复用,它只负责通知"哪个 fd 就绪了",数据能不能读、读多少、数据完整不完整,它一概不管。通知来了,还是你这个线程自己去 read、write,而且 read 有可能返回 EAGAIN,说明内核缓冲区暂时没数据了,你得回头再等通知。

真正的异步模型是另一类"完成回调"模式:内核把数据从 socket 缓冲直接搬到你指定的用户态内存,搬完了再通知你,全程你不用参与复制。这种模型在 Linux 上对应的 io_uring 等机制也一直在发展,和 Reactor 属于不同的设计哲学。Reactor 对应的严格说是"同步非阻塞 + 多路复用",理解了这一点,你和别人聊网络框架时就不会把概念搅成一锅粥。

3. 并发瓶颈一步步往上架构:Reactor的四种演进

3.1 单Reactor单线程:够用但不耐用

一个线程一个事件循环,连接处理、业务计算、数据读写全都在这个循环里做,这就是最早的 Reactor 形态。它有个非常诱人的优点:不用考虑锁,因为所有代码在同一线程里顺序执行,不可能出现并发写同一个变量的情况。

这种模型适合业务逻辑极轻的服务,比如纯转发、协议解析但不做复杂计算,或者像那些只做静态内容回显的网关。单线程的 CPU 上限就是你把一个核吃满的极限,再往上只能换架构。它的最大软肋是:一旦某个事件的处理函数里出现耗时操作,比如同步查询数据库、调用一个慢接口,整个循环就被卡住了。你在处理这个请求的时候,后面成千上万个已经就绪的连接都在等,新事件排在后面,掉一个事件循环的节拍,整体延迟立刻恶化。

3.2 单Reactor多线程:IO与业务分离

为了不让业务处理堵住事件循环,一个自然的演进是:事件循环线程只负责网络 IO,读到的请求数据封装成任务,丢给一个线程池去算;业务线程算完之后,再把结果通过队列送回事件循环线程,由它去做最终的 write。

这个模型比单线程能扛更多业务压力,线程池的核数可以利用多核 CPU。但你仔细想一下,事件循环线程仍然只有一个,它既要做 accept,又要管所有连接的读写,还要承担业务线程与 IO 线程之间的队列同步。连接数多了之后,这个唯一的 IO 线程会慢慢变成新的瓶颈。线程池的任务队列也会成为锁竞争热点,尤其当业务粒度很小但频率很高的时候,线程之间疯狂抢锁的时间可能比真正算业务的时间还长。

所以在工程里,单 Reactor 多线程通常只用在业务较重但连接规模不太夸张的场景,或者作为你理解下一个演进阶段的中间跳板。

3.3 主从Reactor多线程:主流默认解

主从 Reactor 把"管新连接"和"管已有连接"分开。有一个 MainReactor,也叫主线程,只做一件事:accept 新连接,然后把新连接分配给某个 SubReactor。每个 SubReactor 是独立的事件循环线程,自己维护一批连接的读写事件。

这个拆分的价值在哪里?首先,accept 和读写不会互相拖后腿。某一路连接突然密集收发时,不会影响新连接的建立。其次,多个 SubReactor 可以绑定到不同 CPU 核,每个线程只处理自己那一批连接,线程内部不用加锁,跨线程只有"连接转移"那一下需要同步。

很多主流网络库的 boss/worker 线程模型就是这种套路:boss 线程负责 accept,worker 线程负责 IO。你实现的时候,SubReactor 数量一般按 CPU 核数来定,每个 SubReactor 在自己的 epoll_fd 上运行一个 while(1)。分配连接时可以简单轮询,也可以用更精细的负载均衡策略。这套模型是目前做高连接数业务时最不容易出错、扩展性也最稳妥的默认解。

3.4 多进程Reactor与SO_REUSEPORT实践

多线程模型的敌人之一是共享状态。如果换成一个进程一个事件循环,多个进程分别 listen 同一个端口,再让内核把新连接分摊给不同进程,这就是多进程 Reactor 的思路。Linux 上要实现的关建参数是 SO_REUSEPORT:允许多个 socket 绑定同一个 IP 和端口,内核根据四元组哈希把连接分发到不同的监听 socket 上,天然做到负载均衡。

多进程的好处是隔离性更好,一个进程出问题不容易立刻拖垮整个服务,也不存在多线程之间的锁竞争。代价是连接之间要共享数据时很麻烦,你得走进程间通信、共享内存或外部存储,架构复杂度一下子就上去了。而且每个进程要维护自己的事件循环和连接集合,内存总体开销比多线程大。实践中,无状态、每个连接独立、需要多核扩展的服务,很适合用多进程 Reactor;而连接之间频繁需要协作的服务,主从多线程模型会更顺手。

3.5 怎么选型

场景推荐形态核心理由
简单 echo、协议转发、吞吐优先单 Reactor 单线程无锁、代码简单、延迟低
业务较重但连接规模中等单 Reactor 多线程IO 和业务分离,吃满多核
连接规模大、业务复杂、需共享状态主从 Reactor 多线程accept 与读写解耦,线程内无锁
无状态高并发、多核服务器多进程 Reactor + SO_REUSEPORT内核自动均衡,进程间隔离

我自己的经验是,选型不用太纠结。Reactor 框架解决的是 IO 分发问题,真正决定你能不能扛住百万连接的,是业务代码是否阻塞了事件循环、共享数据是否引入了大量锁竞争、连接的空闲与活跃比例是否合理。模型只是第一步,剩下的功夫在内存和系统参数上。

4. 冲100万连接:真实参数、压测路线与坑

4.1 先算一笔账:百万连接的内存成本

在动手调参数之前,先算个账,免得压了一半机器 OOM。一个 TCP 连接在 Linux 内核里有一堆结构体:socket、sock、tcp_sock、inode 等,粗略估算平均 3KB 左右;不同内核版本和编译选项会有差异。100 万连接那就是约 3GB 内核内存。

这还不算用户态的消耗。你每 accept 一个连接,用户程序里也要为它保存业务状态、读写缓冲区、定时器信息。如果每个连接预留 8KB 的读缓冲,那就是 800MB;如果预留更多,数字会成倍上涨。再加上系统本身的页缓存和进程镜像,我建议压测机器的物理内存不要低于 16GB,最好 32GB 以上。

CPU 方面,空闲保持连接几乎不耗 CPU,事件循环基本睡在 epoll_wait 里。真正吃 CPU 的是流量:如果 100 万个连接里每秒有 1% 的连接产生一个包,那就是每秒 1 万个包,这个量级对 4 核机器已经是可见的压力。所以压测至少要分两种场景:纯保持连接,以及持续小流量,才能看到模型和机器的真实边界。

4.2 内核与进程配置参数整理

这一节给你一份可以直接抄的参数表。调这些参数之前先备份,别在别人的生产服务器上乱试。

# 系统级文件描述符上限 sysctl -w fs.file-max=12000000 # 全连接队列长度 sysctl -w net.core.somaxconn=4096 # 临时端口范围,压测客户端尤其重要 sysctl -w net.ipv4.ip_local_port_range="1024 65535" # TIME_WAIT 快速回收相关 sysctl -w net.ipv4.tcp_fin_timeout=30 sysctl -w net.ipv4.tcp_tw_reuse=1 sysctl -w net.ipv4.tcp_max_tw_buckets=2000000

进程级限制要单独设。只改内核参数还不够,一个普通进程默认只能打开 1024 个文件描述符,不调它你连接数到一千多就卡死了。

cat >> /etc/security/limits.conf <<'EOF' * soft nofile 1048576 * hard nofile 1048576 EOF

如果你用 systemd 跑服务,还要在 service 文件里加 LimitNOFILE=1048576,否则 limits.conf 对守护进程不生效。这个细节坑过不少人:明明配了 limits.conf,用 systemctl start 拉起服务后 ulimit -n 还是老样子,就是因为 systemd 自己控制资源限制。

还有一个参数不要碰:tcp_tw_recycle。它在较新内核里已经被移除了。有些旧教程让你开这个来回收 TIME_WAIT,但它依赖时间戳,在某些 NAT 环境下会导致丢包,属于得不偿失的调优。

4.3 服务端骨架代码

给你一个最简的 Reactor 骨架,只保留核心结构,方便你把注意力放在模型本身。生产环境里还要加内存池、连接状态机、优雅退出,这里先不展开。

#include <errno.h> #include <netinet/in.h> #include <sys/epoll.h> #include <sys/socket.h> #include <unistd.h> #define MAX_EVENTS 4096 int main(void) { int lfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0); struct sockaddr_in addr = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_ANY), .sin_port = htons(9090), }; bind(lfd, (struct sockaddr *)&addr, sizeof(addr)); listen(lfd, 4096); int epfd = epoll_create1(0); struct epoll_event ev = {.events = EPOLLIN, .data.fd = lfd}; epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev); struct epoll_event events[MAX_EVENTS]; while (1) { int n = epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i = 0; i < n; i++) { int fd = events[i].data.fd; if (fd == lfd) { while (1) { int cfd = accept4(lfd, NULL, NULL, SOCK_NONBLOCK); if (cfd == -1) { if (errno == EAGAIN) break; if (errno == EMFILE) { /* 需要预留fd保命 */ } break; } struct epoll_event cev = { .events = EPOLLIN | EPOLLET, .data.fd = cfd, }; epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &cev); } } else { // 这里用 ET,需要循环读到 EAGAIN char buf[4096]; ssize_t r; while ((r = read(fd, buf, sizeof(buf))) > 0) { // 业务处理;绝对不能在这里做耗时操作 } if (r == 0) { close(fd); } else if (r < 0 && errno != EAGAIN) { close(fd); } } } } }

注意几个细节。listen 的 backlog 参数不要用默认的 128,这里设到 4096,配合 net.core.somaxconn,才能在瞬间大量并发握手时减少全连接队列满导致的丢包。accept4 后面的 SOCK_NONBLOCK 标记保证新连接直接是非阻塞状态,省去单独调 fcntl 的步骤。

EMFILE 是文件描述符耗尽时 accept 返回的错误。真实的服务器里通常预留一个"救命 fd":程序启动时先打开一个空闲 fd 占位,accept 返回 EMFILE 时先关闭这个占位 fd,再 accept 一次拿到新连接,然后马上再打开一个新的占位 fd。这样不至于让已经完成握手的连接堆积在 accept 队列里,等到你腾出 fd 再处理。没有这个兜底,fd 一满,服务端就进入"客户端疯狂重连、服务端一直失败"的死循环。

4.4 压测客户端的正确姿势

wrk 之类的高性能压测工具是测请求量用的,不适合用来做"保持百万连接"这种场景。你需要一个专门的长连接制造机:不断创建 socket、connect、然后挂起维持住。

这里有一个非常现实的坑:一个客户端源 IP 的可发起连接数量受 ip_local_port_range 限制,默认范围大概 6 万多个端口,也就是说一个源 IP 最多同时往外建 6 万多个连接。想从本机压出 100 万连接,必须准备多个源 IP。实测时可以直接给 lo 回环接口配一段 IP,比如 127.0.0.2 到 127.0.0.30,每个源 IP 扛几万个连接,凑够目标数。

ip addr add 127.0.0.2/8 dev lo ip addr add 127.0.0.3/8 dev lo

客户端代码里还有个容易踩的细节:不要用阻塞 connect 一个接一个地建连接。100 万次串行 connect,即使每次只要 1ms,那也是 1000 秒起步。正确做法是把所有 socket 设为非阻塞,批量调用 connect,然后用 epoll 等待 EPOLLOUT 事件确认连接真正建立完成;对 connect 返回 EINPROGRESS 的 fd,等 epoll_wait 报可写,就说明三次握手完成了。核心片段如下:

int fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0); int rc = connect(fd, (struct sockaddr *)&addr, sizeof(addr)); if (rc < 0 && errno != EINPROGRESS) { close(fd); continue; } // 把 fd 加入 epoll,监听 EPOLLOUT;触发后连接建立完成

压测观察结果用 ss 会比 netstat 快很多,因为 ss 从内核取数据的方式更高效。你想看连接状态分布,直接执行ss -tan state established | wc -l数一下 ESTABLISHED 数量,顺便用ss -tan state time-wait | wc -l看 TIME_WAIT。

4.5 实测里必然遇到的几个坑

第一,fd 上限不生效。如果你改了 limits.conf 还是卡在 1024,多半是 systemd 没配 LimitNOFILE;另一个可能是你直接在当前 shell 里跑测试,而 shell 的软限制没被重新加载。压测脚本里自己先执行ulimit -n 1048576更省事。

第二,锁竞争隐藏得深。主从 Reactor 多线程跑起来之后,如果业务代码里有一个全局计数器,每秒更新一百万次,你很快会发现 CPU 全耗在自旋锁上。原因是多核 CPU 上所有线程都在抢同一个缓存行。这个领域的经典缓解办法是拆锁、无锁队列、线程本地化,以及尽量把共享数据变成每线程独有。

第三,TIME_WAIT 堆积。如果你压测时用的是短连接,服务端主动关连接,几万几十万的 TIME_WAIT 会直接占满连接表。方法上有两种思路:要么调整 tw_reuse 和 tw_max_tw_buckets,但本质上只是在延缓问题;要么把业务协议设计成长连接,让连接尽量别频繁创建销毁。做网关系统的话,长连接带来的收益远大于处理 TIME_WAIT 的折腾。

第四,心跳机制不能依赖内核。TCP keepalive 默认空闲 2 小时才探测,比大多数业务可以容忍的断线检测周期长得多。真实的长连接应用基本都会做应用层心跳:客户端每隔几十秒发一个 ping,服务端超时未收到就主动断开。否则客户端拔网线走人,服务端那边的 fd 可能一直挂着到天荒地老,连接数莫名其妙就被僵尸连接撑爆。

第五,监看内存要盯 Slab。压测过程中 CPU 可能看起来不高,但内存会先爆。你除了看 free -g,还要看 /proc/meminfo 里的 Slab 字段,TCP 连接的内核数据结构就挂在这里。连接数从 0 涨到 100 万的过程里,Slab 会稳定增长,提前算好余量能避免 OOM 把整个测试进程一起带走。

最后讲一条我的实操体会。不要一上来就定 100 万的目标。先跑 1 万、5 万、10 万的梯度,每次观察连接建立的耗时、内存上涨曲线、ss 输出的状态分布。等你完全明白每一档的变化,再一次性冲到 100 万,成功率会高很多。百万连接这个数字,更像是一套系统调试能力的检验:Reactor 模型给你搭好了舞台,真正的主角是你对 Linux 资源管理和性能细节的掌控。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询