11.8 单机高性能模式(PPC / TPC / Reactor / Proactor)

一句话

一台机器要同时服务 N 个连接,只有四种答案:一连接一进程(PPC)、一连接一线程(TPC)、一个事件循环管所有连接(Reactor)、内核替你干完再通知(Proactor);选型的唯一判据是 阻塞点在哪 + 每次切换要付多少钱,不是 “哪个更新”。

先看切换成本:四种模式的差异从这里开始

四种模式的本质区别是 “谁来等 IO”。而 “等” 不花钱,创建与切换才花钱。本机实测(macOS 14.6 / arm64 / Python 3.14,单位 µs):

操作 本机实测 说明
fork + waitpid 一次 791 µs macOS 的 fork 偏贵,Linux 通常低一个数量级,但仍是 “进程 ≫ 线程”
线程创建 + join 一次 40 µs 比进程便宜约 20 倍
selector 注册一个 fd 1.5 µs 事件驱动的一次性成本
事件循环空转一轮(1000+ fd,0 超时) 12.2 µs 每次 select/poll/epoll_wait 的固定开销

由这些数直接推出一个量级结论:

1 万连接:PPC 仅 fork 就要 7.91 s;TPC 线程 0.40 s;Reactor 注册 14.7 ms

⚠️ 这不是 “Reactor 比 TPC 快 27 倍” 的基准测试:上面只算了创建成本,没算稳态吞吐,而且 Python 与 macOS 的数字不能直接搬到 Linux C 服务上。它说明的是量级关系:进程 ≫ 线程 ≫ 事件注册,这决定了 “连接数一多,PPC 就不可能”。

四种模式一张表

模式 谁来等 IO 并发上限(量级) 切换/内存成本 代表
PPC(Process Per Connection) 每个子进程自己阻塞在 read 几百 进程级(页表、COW、IPC) Apache prefork(默认 256 并发)
TPC(Thread Per Connection) 每个线程自己阻塞在 read 几千 线程级(栈、调度、共享 fd 的锁) Apache worker(16 进程 × 25 线程 = 400)
Reactor 一个事件循环等,就绪后回调处理 几万–几十万 近乎零(一个循环 + 非阻塞 fd) Redis、Nginx、Netty、Memcached
Proactor 内核等,完成后通知 同上(取决于实现) 系统调用一次投递/回收 Windows IOCP;Linux io_uring 接近此类

PPC:一连接一进程

listen_fd → accept() → fork() → 子进程 read/业务/write → close() → exit
                                    └─ 父进程立刻回到 accept,不阻塞

关键细节:父子进程都 close(conn_fd) 只是引用计数减一,真正的连接要等两边都关掉才释放。所以 PPC 里 “子进程处理完就 close” 是必须的,否则连接泄漏。

三宗罪,每一条都直接来自 “进程很贵”:

问题 原因 后果
fork 代价高 即使有写时复制(COW),页表与内核结构仍要复制 1 万连接的创建成本就是秒级(本机实测 7.9 s)
父子要 IPC 子进程拿不到父进程的共享状态(计数器、缓存、连接池) 要么每个进程一份(内存 ×N),要么上共享内存
并发上限低 进程数受内存与调度限制 实际几百个就撑不住

prefork 是 PPC 的改良版:父进程先 fork 一批子进程,所有子进程 accept 同一个监听 socket。多个进程同时 accept 会 “惊群”(thundering herd),Linux 2.6 之后由内核唤醒一个,问题基本消失——这也是 prefork 至今还能用的原因。Apache 的 MPM prefork 默认 256 并发就是这么来的。

TPC:一连接一线程

把 fork 换成 pthread_create,其余不变。收益是线程比进程便宜(本机 40 µs vs 791 µs),而且共享文件描述符——处理完只需一次 close。

但换来两个新问题,都是 “共享” 带来的:

问题 现象 为什么
一个线程越界,整个进程退出 段错误带走所有连接 线程共享地址空间
死锁 两个线程互相等对方的锁 共享状态必须加锁,而锁的粒度与顺序靠人保证

所以有一个反直觉结论:并发只有几百时,TPC 不一定比 PPC 划算——线程省下的创建成本,可能被锁竞争和一次崩溃带走全部连接抵消掉。

prethread(Apache worker 模式)同理:先建一批线程,16 个进程 × 25 线程 = 400 并发。

Reactor:一个循环管所有连接

Reactor 的演进链值得记住,每一步都是被上一个问题逼出来的:

阶段 做法 撞上的墙
① 进程池 / 线程池 PPC / TPC 连接数一多,创建与切换成本爆炸
② 一个进程管多连接 循环 read 每个连接 一个连接阻塞,全进程停摆
③ 非阻塞轮询 每个连接设 O_NONBLOCK 反复问 CPU 空转;上万连接时轮询本身成了瓶颈
④ IO 多路复用 select/poll/epoll 一次问一批 就绪列表 O(就绪数),这才撑得住几万连接

底层接口的细节见 11.4 epoll 与 IO 多路复用;Reactor 是在它之上的一层 “事件分发 + 回调”。

三种 Reactor,差别在 “谁做业务”

形态 结构 优点 代价 代表
单 Reactor 单进程 一个循环:accept → read → 业务 → send 无 IPC、无锁竞争,代码最简单 吃不到多核;业务一慢,整个循环停摆 Redis
单 Reactor 多线程 循环只负责 read,业务交给线程池,结果回主线程 send 业务不阻塞 IO 主线程要回传结果;selectedKeys 等结构非线程安全,跨线程操作必须加锁或拷贝 部分 Java 框架的早期实现
多 Reactor 多进程/线程 mainReactor 只 accept,把连接交给 subReactor 职责清晰,不需要回传,实现反而更简单;线性扩展 多一层连接交接 Nginx、Netty、Memcached

🔑 Nginx 与 “标准多 Reactor” 有一处差别:主进程只创建监听端口、不 accept,多个 worker 用文件锁抢 accept——既避免惊群,又让连接直接落在 worker 手里,少一次传递。这个设计是 Nginx 高并发的关键之一。

Proactor:让内核等

Reactor 的回调发生在 “fd 就绪” 时,真正的 read/write 还是你自己做(非阻塞同步);Proactor 的回调发生在 “IO 完成” 时,read/write 由内核代劳(真异步)。

维度 Reactor Proactor
谁等 IO 应用的事件循环 内核
回调时机 可读/可写 已读完/已写完
编程模型 非阻塞同步 异步(提交缓冲,完成后通知)
平台现状 Linux 主力(epoll 之上) Windows IOCP 成熟;Linux io_uring 接近,AIO 长期不完善

一句话记住差别(原文类比):Reactor 是 “来了事件我通知你”,Proactor 是 “事件我处理完再通知你”。

线程/进程数到底给多少

场景 建议 依据
CPU 密集型 线程数 ≈ 核数(+1 兜底) 再多只会增加上下文切换
IO 密集型(阻塞调用) 线程数 ≈ 核数 × 2 起步,按压测上调 线程大部分时间在等,等待不占 CPU(「核数 × 2」出自作者答疑,非正文,按经验值看待)
事件驱动(Reactor) 工作线程 ≈ 核数;每连接不占线程 连接数与线程数解耦
反例 48 核上 HTTP 业务配 512 线程,实测 128 最优 线程数不是越多越好,拐点在压测里(出自作者答疑)

两个 “一行代码决定吞吐” 的真实教训:

变更 结果 根因
在请求热路径加一行 debug 日志 TPS 从 30000 掉到 8000 同步写日志把 CPU 时间换成了 IO 等待
关掉 tcp_nodelay(默认走 Nagle) 响应从 2 ms 变成 40 ms Nagle 与延迟确认的 “40ms 连招”,见 8.2 拥塞控制、Nagle 与延迟

🔑 单机性能往往不是 “模型选错”,而是 模型的假设被一行代码破坏:日志、序列化、锁、DNS 解析,任何一个同步阻塞点都会把 Reactor 退化成 “单连接阻塞”。

选型决策表

你的情况 选 理由
连接数少(几十)、任务计算密集 PPC 进程隔离好,进程数少时创建成本可忽略
几百并发、业务里有阻塞调用(同步 DB/HTTP) TPC 线程便宜,阻塞不牵连别的连接
高并发长连接(IM、推送、代理、缓存) Reactor(多 Reactor) 连接数与线程数解耦
Windows 平台、要极致 IO 吞吐 Proactor(IOCP) 真异步,不用自己管理就绪队列
已选 Nginx/Redis/Netty 直接用它们的模型 这些实现已经把坑趟过一遍,别自己重写

生产落地:指名 → 可抄 → 可验 → 可退

指名:Web 入口用 Nginx(多 Reactor 多进程,worker_processes auto);缓存用 Redis(单 Reactor 单进程,靠 “命令都是内存操作” 保证不阻塞);Java 网络框架用 Netty(多 Reactor 多线程);需要 servlet 模型用 Tomcat(TPC + NIO 连接器);传统 CGI 风格用 Apache prefork/worker。

可抄(三条最常调的参数):

# nginx.conf:worker 数 = 核数;每 worker 的连接上限
worker_processes auto;
events { worker_connections 10240; }   # 单 worker 上限,总量 = worker × 该值
# Apache:PPC 与 TPC 的切换就是换 MPM
# prefork(PPC):默认 256 并发
StartServers 8
MaxRequestWorkers 256
# worker(TPC):16 进程 × 25 线程 = 400
StartServers 16
ThreadsPerChild 25
// Netty:boss 只 accept,worker 做 IO;给 1 个 boss 就够
EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup worker = new NioEventLoopGroup();   // 默认 = 2 × 核数

可验:三条命令判断 “模型有没有被阻塞点破坏”。

检查 命令 看什么
连接数与状态 ss -s;ss -tn state established 再计数 连接数是否远大于线程数(Reactor 的特征)
线程是否在等 pidstat -w -p <pid> 1、top -H -p <pid> 上下文切换暴涨 = 线程在抢锁或在阻塞
事件循环有没有被卡 strace -c -p <pid>(短时)或框架自带慢回调日志 慢回调 / 同步写日志会直接体现在 P99

可退:模型不是一次选定的——Nginx 可调 worker_connections 与 worker_processes 退回到更保守的配置;Tomcat 可在 BIO/NIO 连接器之间切换;Netty 可把业务从 EventLoop 挪到独立业务线程池(这是最常用的一步退路:回调里只做解析,重活丢给业务池)。

反模式

反模式 后果
在 Reactor 的回调里做同步 DB/HTTP 调用 一个慢请求卡住整个事件循环,所有连接一起变慢
在 Netty EventLoop 里跑耗时业务 同上,且很难从监控上看出来(CPU 不高、连接全超时)
无脑把线程数调到几千 上下文切换与内存(每线程栈)压垮机器,吞吐反降
PPC 时代忘了 close 子进程的连接 fd 连接泄漏,句柄耗尽
多 Reactor 里跨线程操作 selectedKeys 非线程安全,偶发丢事件、CPU 100% 空转
用 TPC 扛长连接(IM/推送) 线程数与连接数同阶,几万连接就 OOM
以为换 Reactor 就能提升吞吐 若瓶颈在 DB 或下游,换模型只是把压力换个地方排队
生产上开 debug 日志排查后又忘了关 吞吐掉一个量级(本机案例:30000 → 8000 TPS)

三条铁律

🔑 先找阻塞点,再选模型:模型只决定 “谁等 IO”,不解决 “业务里有慢调用”。

🔑 Reactor 的命门是回调:回调里任何同步操作,都会把 “一个循环管一万连接” 退化成 “一个连接卡死一万连接”。

🔑 并发数不是越大越好:进程/线程数有拐点,拐点在压测里,不在经验值里。

自检

Q1 PPC 里父进程 accept 之后为什么必须 fork,而不是自己处理?

▸ 答案:因为父进程要继续 accept 新连接——它一旦自己去做 read(阻塞),监听队列就会积压。fork 之后子进程阻塞在 read 上,父进程立刻回到 accept,这是 PPC 能 “同时服务多连接” 的唯一方式。代价是每个连接一个进程,创建成本高(本机实测 791 µs/次)。

Q2 本机实测 fork 791 µs、线程 40 µs,为什么还说 “几百并发时 TPC 不一定比 PPC 划算”?

▸ 答案:因为创建成本只是总成本的一部分。TPC 换来了共享地址空间,也换来两个新风险:一个线程越界会让整个进程退出(一次崩溃带走所有连接),以及共享状态要加锁(死锁、锁竞争)。在几百并发的量级上,线程省下的创建时间可能被这两项抵消,所以不能只看 µs。

Q3 Reactor 为什么必须配合非阻塞 IO?只把 epoll 用上够不够?

▸ 答案:不够。epoll 只告诉你 “这个 fd 可读了”,真正的 read 还是你调用;如果 fd 是阻塞的、且对端只发了半个包,read 就会卡住整个事件循环。所以 Reactor 的前提是 fd 设为非阻塞 + 只在就绪时读 + 读不完就留到下次。这也是 “回调里不能做同步操作” 的同一个道理。

Q4 单 Reactor 多线程里,selectedKeys 为什么不能跨线程操作?

▸ 答案:它是事件循环内部的可变集合,主线程遍历它的同时,子线程增删会破坏迭代状态(并发修改),表现为偶发丢事件或 CPU 空转。正确做法是:子线程把结果投递回主线程(队列/任务),由主线程统一操作选择器与注册表。这也是 “多 Reactor” 反而更简单的原因——连接交接一次,之后各管各的,不需要回传。

Q5 Reactor 与 Proactor 的本质区别是什么?为什么 Linux 上 Reactor 是主流?

▸ 答案:Reactor 在 fd 就绪时回调,读写由应用自己完成(非阻塞同步);Proactor 在 IO 完成时回调,读写由内核完成(真异步)。Linux 长期缺少成熟的内核级异步 IO(AIO 不完善),所以主流是 “epoll + Reactor”;Windows 有 IOCP,Proactor 才成为主流。io_uring 让 Linux 也能走 Proactor 路线,但生态仍在演进。

Q6 Nginx 为什么要让 worker 用锁抢 accept,而不是主进程 accept 后分发?

▸ 答案:主进程 accept 再传递连接,需要把 fd 通过 Unix socket 传给 worker(多一次传递与唤醒)。让多个 worker 直接抢 accept,连接一步到位落在真正处理它的进程里;文件锁避免所有 worker 同时被唤醒(惊群)。代价是实现更复杂,收益是高并发下更少的唤醒与更短的路径。

Q7 一个 Reactor 服务 “CPU 不高、但所有连接都超时”,最可能是什么原因?

▸ 答案:事件循环被某个同步阻塞操作卡住了:慢回调(同步 DB/HTTP)、同步写大日志、正则回溯、磁盘 IO、DNS 解析。CPU 不高是因为它在等,而不是在算。排查顺序:① 看框架的慢回调日志/strace -c;② 确认回调里有没有 IO;③ 把重活移到独立业务线程池(Netty 的 DefaultEventExecutorGroup、Redis 的单线程模型则要避免 KEYS/大 ZRANGE 这类慢命令)。

关联