如果你最近两三年才开始写Linux服务端大概率看到过那张非常经典的图一个叫 Reactor 的框把 accept、read、write 这些事件当作对象轮转分发旁边标注着“百万级并发”。图看懂了代码也抄了用 epoll 写了一个 echo 服务本机开 10 万个连接CPU 才跳了 20%忽然觉得自己离“百万”就差一个锦旗。等真拿 100 万连接去压的时候发现卡住你的根本不是 Reactor 那段核心逻辑而是文件描述符上限、内核参数、内存甚至是你对“百万并发”的定义。这篇文章把这条路上真正要过的关卡拆开讲Reactor 模型本身是怎么工作的它为什么适合高连接数场景从单线程到多线程再到多进程的演进逻辑最后到一台普通服务器上把连接数推到百万级的具体路线和坑点。适合刚接触服务端编程的同学也适合那些已经写过 epoll、但从来没把系统参数当回事的朋友。1. 先把Reactor和“百万级并发”这两件事说清1.1 从一连接一线程到事件驱动很多教材喜欢从 BIOBlocking 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 线程负责 acceptworker 线程负责 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-max12000000 # 全连接队列长度 sysctl -w net.core.somaxconn4096 # 临时端口范围压测客户端尤其重要 sysctl -w net.ipv4.ip_local_port_range1024 65535 # TIME_WAIT 快速回收相关 sysctl -w net.ipv4.tcp_fin_timeout30 sysctl -w net.ipv4.tcp_tw_reuse1 sysctl -w net.ipv4.tcp_max_tw_buckets2000000进程级限制要单独设。只改内核参数还不够一个普通进程默认只能打开 1024 个文件描述符不调它你连接数到一千多就卡死了。cat /etc/security/limits.conf EOF * soft nofile 1048576 * hard nofile 1048576 EOF如果你用 systemd 跑服务还要在 service 文件里加 LimitNOFILE1048576否则 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 资源管理和性能细节的掌控。