操作系统学习笔记 · 第 24 课 · 高并发服务器模型
上一课我们用 select + 一个线程就扛住了多个客户端。但一个更深的问题浮出水面:一个连接,到底是"开个进程"伺候,还是"开个线程",还是"什么都不开、用事件驱动"? 这三种做法各有优劣,没有银弹。这一课,我们站在更高处,看清高并发服务器的三种模型,以及那个经典的设计模式——Reactor(反应器)。这是第六阶段(网络编程)的收官课。从"开餐馆"说起
你开了家餐馆,客人络绎不绝。怎么安排服务员?三种思路:
- 一人一桌:每来一桌客人,单独配一个服务员全程伺候——服务好、隔离强,但人力贵;
- 一人多桌:每个服务员照看几张桌子——省人力,但要协调好;
- 一个全能高手:一个眼观六路的大堂经理,哪桌举手就过去——极致省人力,但要求高。
这三种思路,恰好对应服务器并发处理的三种模型:多进程、多线程、事件驱动。
24.1 多进程模型
一句话理解:每来一个连接 fork 一个子进程去服务,父进程只管接客——隔离好、简单,但进程开销大,连接多了扛不住。还记得第 7 课的 fork 吗?多进程模型就是:父进程 accept 到一个新连接,就 fork 一个子进程,子进程专门服务这个连接,父进程回去继续 accept。
- 优点:隔离性好——一个进程崩了不影响别的(还记得第 15 课的地址空间隔离吗);逻辑简单;
- 缺点:进程开销大——每个进程都有独立的地址空间、页表,创建和切换都贵。连接一多(比如上千),进程数爆炸,内存和 CPU 都扛不住。
例子:Apache 早期 prefork 模式,就是典型的多进程。
24.2 多线程模型
一句话理解:每来一个连接开一个线程去服务,共享内存、比进程轻,但要处理并发安全(锁),且线程数有限。
线程比进程"轻"(第 5 课:线程共享地址空间、切换快),所以每连接开一个线程,比开进程划算。
- 优点:共享内存,线程间通信方便;比进程轻,能扛的连接更多;
- 缺点:共享内存带来并发安全问题——多个线程同时改同一块数据,就要加锁(第 13 课),加锁又有死锁风险(第 14 课)。而且线程数也不是无限的,开太多照样扛不住。
例子:pthread_create 给每个连接一个线程;Java Tomcat 早期的线程池模型。24.3 事件驱动 / Reactor 模型
一句话理解:一个线程 + epoll 管理所有连接,哪个连接就绪就处理哪个——没有"每连接一个进程/线程"的开销,是现代高并发服务器(Nginx/Redis/Node)的主流。
这就是上一课 select/epoll 的思路,升级成一个设计模式——Reactor(反应器):
- 事件循环(event loop) 是"反应器",不断收集事件;
- 事件来了,分发给对应的处理函数(handler);
accept有新连接 → 调"连接 handler";read有数据 → 调"读 handler"。
一句话理解 Reactor:一个循环,不断"收集事件 → 分发回调"。没有"每连接一个进程/线程"的开销,所以一个线程能扛住成千上万连接。
这就是 Nginx、Redis、Node.js 的底层思想——为什么它们能用很少的资源扛住海量连接。
24.4 三种模型对比
| 模型 | 并发能力 | 复杂度 | 隔离性 | 代表 |
|---|---|---|---|---|
| 多进程 | 低(进程贵) | 低 | 强 | Apache prefork |
| 多线程 | 中(线程轻些) | 中(要锁) | 中 | Tomcat |
| 事件驱动 | 高(单线程管万连) | 高(回调) | 弱(共享内存) | Nginx/Redis |
- 并发能力:进程最贵 → 扛得少;线程中等;事件驱动最省 → 扛最多;
- 复杂度:多进程/多线程逻辑直观简单;事件驱动要写回调、状态机,复杂;
- 隔离性:多进程最强(崩了不影响别人);事件驱动最弱(都在一个线程里,一个 bug 全崩)。
一句话:没有最好的模型,只有最合适的模型。
24.5 怎么选
一句话理解:CPU 密集型用多进程/多线程(好吃满多核),I/O 密集型用事件驱动(连接多但每个活儿轻)。
选型口诀:
- 连接多、每个连接活儿轻(比如转发、echo、静态文件)→ 事件驱动(Nginx/Redis/Node 的强项);
- 活儿重、要榨干多核 CPU(比如复杂计算、图像处理)→ 多进程/多线程,把任务分到多个核上并行跑;
- 要强隔离、一个任务崩了不影响全局 → 多进程。
现实中,成熟方案常是混合的:Nginx 用"多进程 + 每个进程里一个 epoll 事件循环",既利用了多核,又扛住了海量连接。
动手实验:Python 用 socketserver 三模型对照
# lesson24.py —— socketserver 快速搭服务器
import socketserver
class EchoHandler(socketserver.BaseRequestHandler):
def handle(self):
data = self.request.recv(1024)
self.request.send(data)
# 1. 单线程(一次一个)
srv = socketserver.TCPServer(("127.0.0.1", 8080), EchoHandler)
# 2. 多线程(每连接一个线程)—— 换成这行即可
# srv = socketserver.ThreadingTCPServer(("127.0.0.1", 8080), EchoHandler)
# 3. 多进程(每连接一个进程)—— 换成这行即可
# srv = socketserver.ForkingTCPServer(("127.0.0.1", 8080), EchoHandler)
print("服务器监听 8080……")
srv.serve_forever()一句话理解:把TCPServer换成ThreadingTCPServer/ForkingTCPServer,就切换了三种并发模型——这就是"框架帮你封装好模型"。
你可以分别跑三种,各开几个客户端同时连,体会差异:
TCPServer:一个客户端卡住,其他全等;ThreadingTCPServer:每个客户端一个线程,互不阻塞;ForkingTCPServer:每个客户端一个进程(Linux 下),隔离最强。
深入点:两个进阶话题
① 线程池 vs 每连接一线程
"每来一个连接就开一个线程"有个隐患:恶意客户端狂开连接,线程被开爆,系统崩。
成熟做法是固定大小线程池(ThreadPoolExecutor)+ 队列缓冲:线程数量有上限,多余的连接排队等。既限制了资源,又保证了吞吐。② Reactor 的变体 Proactor
Reactor 还有一个兄弟——Proactor:
- Reactor(Linux 的 epoll):就绪通知——内核告诉你"有数据了",你自己去 read;
- Proactor(Windows 的 IOCP):完成通知——内核帮你 read 好,把结果交给你。
这就是 asyncio 在不同平台用不同后端的底层原因:Linux 上是 epoll(Reactor),Windows 上是 IOCP(Proactor)。上层 API 一样,底层实现因地制宜。小结与思考题
这一课,我们看清了高并发服务器的三种模型:
- 多进程:隔离好、简单,但进程贵、扛得少;
- 多线程:共享内存、比进程轻,但要处理锁;
- 事件驱动/Reactor:一个线程 + epoll 扛万连,是现代高并发主流;
- 选型:CPU 密集用进程/线程,I/O 密集用事件驱动,成熟方案常混合。
留三个问题:
- 多进程、多线程、事件驱动各自的适用场景?
- Reactor 模式的核心思想?
- 为什么 Nginx 能用很少的资源扛住海量连接?
到这里,第六阶段(网络编程,22~24 课)收官。从下一课起,我们换个完全不同的赛道——Shell 与脚本编程。前面你一直是"用现成的命令"和"写 C/Python",但从没想过:你天天敲的那个ls、cd、grep,到底是谁在背后翻译、执行? shell 是什么?它和你写脚本有什么关系?下一课,我们回到"终端"这个最熟悉的地方,揭开 shell 的面纱。