KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

01 · 三种并发单元:进程、线程、事件循环怎么选 — keel 龙骨

后端服务的每一个「并发」决定,最终都要落到三种单元之一上。选错单元的症状各不相同:该用进程的用了线程,表现为 CPU 打不满却卡顿;该用 asyncio 的用了进程池,表现为内存翻倍还慢。这一章先把三种单元的边界画清楚,再用一个真实拓扑把决策走一遍。

后端服务的每一个「并发」决定,最终都要落到三种单元之一上。选错单元的症状各不相同:该用进程的用了线程,表现为 CPU 打不满却卡顿;该用 asyncio 的用了进程池,表现为内存翻倍还慢。这一章先把三种单元的边界画清楚,再用一个真实拓扑把决策走一遍。

一、现场:一个 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。判断依据回到第二节那张表。

五、生产边界

六、练习

  1. 把第 4 节的 /hash 改成 run_in_executor 版本,验证 /health 不再被卡;再回答:为什么这个修复对 CPU 密集任务并不增加并行度?(提示:GIL。)
  2. 假设乙类任务开始堆积,而你只想给乙类加机器。对照第三节拓扑图,说出你需要改动哪一层(答案:只加 worker 子进程 B 的副本数;队列和 HTTP 进程不动)。
  3. 用一句话解释:为什么「HTTP 进程顺手把后台任务也跑了」在故障隔离上是个坏主意?(提示:把第 4 节的失败注入搬进来。)

现在你能解释:三种并发单元各自挡住了什么、放过了什么;一个多进程 + asyncio 的混合拓扑里,每一层各自负责什么。下一章我们往下钻一层——进程是怎么被创建的,创建那一刻操作系统复制了什么。

进入 keel 阅读