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 这类慢命令)。