KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
01 · 三种并发单元:进程、线程、事件循环怎么选 — keel 龙骨
后端服务的每一个「并发」决定,最终都要落到三种单元之一上。选错单元的症状各不相同:该用进程的用了线程,表现为 CPU 打不满却卡顿;该用 asyncio 的用了进程池,表现为内存翻倍还慢。这一章先把三种单元的边界画清楚,再用一个真实拓扑把决策走一遍。
后端服务的每一个「并发」决定,最终都要落到三种单元之一上。选错单元的症状各不相同:该用进程的用了线程,表现为 CPU 打不满却卡顿;该用 asyncio 的用了进程池,表现为内存翻倍还慢。这一章先把三种单元的边界画清楚,再用一个真实拓扑把决策走一遍。
一、现场:一个 Agent 服务的并发需求
假设你要实现这样的服务:
- HTTP 接口毫秒级返回(用户提交任务后立刻拿到凭证);
- 若干运行几十秒的后台任务(跑 Agent、解析文档),任务之间不能互相拖垮;
- 某类任务堆积时,可以单独给那一类加机器,不动其他类型。
把这三条需求记在心里,我们逐个对照三种并发单元。
二、三种单元的精确边界
| 进程 | 线程 | event loop(单线程 asyncio) | |
|---|---|---|---|
| 隔离 | 内存完全隔离,崩溃互不影响 | 共享内存,一个崩全崩 | 同一个线程,阻塞全阻塞 |
| 并行能力 | 真并行(多核) | 受 GIL 限制:IO 并行,CPU 串行 | 无并行,靠 IO 等待间隙切换 |
| 切换成本 | 最重(上下文切换 + IPC) | 中 | 最轻(函数级切换) |
| 资源归属 | 每进程一套 fd / 连接池 | 同进程共享一套 | 绑定创建它的那个 loop |
| 典型用途 | worker 子进程、故障隔离 | 少量阻塞库的兼容 | 高并发 IO(HTTP、SSE、DB) |
三个容易含糊的点,逐个钉死:
1. GIL 挡住的是 CPU 并行,不是 IO 并行。 线程在等网络包时释放 GIL,所以 100 个线程发 HTTP 请求是真能并发;但 100 个线程各自算一段哈希,同一时刻只有一个在算。这就是「CPU 密集选进程、IO 密集选 asyncio 或线程」的物理根源。
2. event loop 不是并行,是调度。 一个 loop 就是一个线程里的 while True:谁在等 IO 就挂起谁,谁就绪了就跑谁。它把「等待」重叠起来,但任意时刻只有一段同步代码在执行。在 loop 里跑一段 5 秒的 CPU 计算,整个进程所有连接都卡 5 秒——这是 async 服务最常见的事故。
3. 资源归属和单元绑定。 这是后面两章的主题,这里先给结论:进程各有各的 fd 表(第 2 章),asyncio 连接绑定创建它的 loop(第 3 章),同步连接池可以跨线程但不能跨进程(第 3 章)。选单元之前先想清楚「这些连接归谁」。
三、决策:什么时候选哪种
flowchart TD
A["一段并发负载要安排执行"] --> B{"CPU 密集还是 IO 密集?"}
B -- "CPU 密集(哈希 / 图像 / 大 JSON 解析)" --> C["多进程<br/>GIL 下线程无法并行"]
B -- "IO 密集" --> D{"需要故障隔离或独立扩缩吗?"}
D -- "是:任务可能崩溃 /<br/>需要按类型加机器" --> E["独立子进程跑 worker<br/>每进程一个 event loop"]
D -- "否:都是短平快请求" --> F["单进程 asyncio<br/>(必要时加进程数)"]
C --> G{"并发量大吗?"}
G -- "批量、可排队" --> H["进程池 + 任务队列"]
G -- "常驻少量" --> I["daemon 拉起固定子进程"]
对照开头的三条需求:HTTP 快速返回用 asyncio(需求一);几十秒的后台任务放进独立子进程——崩了不带垮 HTTP 进程(需求二);「某类堆积只扩那类」的前提是每类任务一条队列、一条队列一组子进程(需求三)。三条需求合起来,就是一个常见的生产拓扑:
flowchart TD
subgraph P0["HTTP 进程(uvicorn)"]
API["asyncio:接请求、快速返回凭证"]
end
subgraph P1["worker 子进程 A(队列甲)"]
W1["asyncio:消费甲类任务"]
end
subgraph P2["worker 子进程 B(队列乙)"]
W2["asyncio:消费乙类任务"]
end
API -->|"任务载荷投进队列"| Q["任务队列(Redis 等)"]
Q -.->|W1| W1
Q -.->|W2| W2
注意这个拓扑里每一层都是「进程 + 其内的 asyncio」:进程负责隔离和扩缩,asyncio 负责进程内的 IO 并发。两者不是竞争关系,是分工关系。「进程还是 asyncio」是个伪问题,真问题是「边界画在哪」。
四、失败注入:把 CPU 密集塞进 loop
在 async 服务里跑这段:
@app.get("/hash")
async def bad_hash():
start = time.monotonic()
for _ in range(2_000_000): # 纯 CPU 计算,约 2 秒
hashlib.sha256(b"x" * 64).digest()
return {"elapsed": time.monotonic() - start}
观察结果:/hash 自己返回约 2 秒——这正常;但同时打开的另一个 /health 请求也要等 2 秒才返回,尽管它什么都不做。原因:health 的响应被排在 loop 队列里,等 hash 那段同步代码让出控制权才有机会跑。
修复方向按代价从低到高:换流式分块计算(每块之间 await asyncio.sleep(0) 让出);用 loop.run_in_executor(None, fn) 丢进线程池(CPU 密集时线程不并行,仅适合 IO 型阻塞调用);真 CPU 密集丢进进程池或独立 worker。判断依据回到第二节那张表。
五、生产边界
- 教学替身与真实替换:本章的「任务队列」在生产里可能是 Redis(见 Redis 工程课)或消息队列;「daemon 拉起子进程」的精确规则是第 2 章的内容。
- 监控:进程拓扑要回答三个问题——每个进程还活着吗(health 端口)、每条队列堆积多少(队列深度指标)、哪个进程在吃 CPU(按进程粒度的指标,不是整机)。
- 取舍声明:「每类任务一组子进程」用部署复杂度换故障隔离。任务种类少、稳定性要求不高时,单进程多 loop 或纯 asyncio 服务是更省心的选择,不要为了架构图好看而引入进程拓扑。
六、练习
- 把第 4 节的
/hash改成run_in_executor版本,验证/health不再被卡;再回答:为什么这个修复对 CPU 密集任务并不增加并行度?(提示:GIL。) - 假设乙类任务开始堆积,而你只想给乙类加机器。对照第三节拓扑图,说出你需要改动哪一层(答案:只加 worker 子进程 B 的副本数;队列和 HTTP 进程不动)。
- 用一句话解释:为什么「HTTP 进程顺手把后台任务也跑了」在故障隔离上是个坏主意?(提示:把第 4 节的失败注入搬进来。)
现在你能解释:三种并发单元各自挡住了什么、放过了什么;一个多进程 + asyncio 的混合拓扑里,每一层各自负责什么。下一章我们往下钻一层——进程是怎么被创建的,创建那一刻操作系统复制了什么。