上一课我们用 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 密集用事件驱动,成熟方案常混合。

留三个问题:

  1. 多进程、多线程、事件驱动各自的适用场景?
  2. Reactor 模式的核心思想?
  3. 为什么 Nginx 能用很少的资源扛住海量连接?
到这里,第六阶段(网络编程,22~24 课)收官。从下一课起,我们换个完全不同的赛道——Shell 与脚本编程。前面你一直是"用现成的命令"和"写 C/Python",但从没想过:你天天敲的那个 lscdgrep,到底是谁在背后翻译、执行? shell 是什么?它和你写脚本有什么关系?下一课,我们回到"终端"这个最熟悉的地方,揭开 shell 的面纱。

标签: 计算机基础, 操作系统, Linux

添加新评论