Python 并发模型:一家餐厅的故事
用一个餐厅的比喻,彻底讲清楚 Python 的 event loop、async/await、线程、进程、
to_thread之间的关系。
1. 先认识主角们
| 概念 | 餐厅角色 | 一句话解释 |
|---|---|---|
| 进程 (Process) | 一家独立的餐厅 | 有自己的地盘、厨具、员工,完全独立运营 |
| 线程 (Thread) | 餐厅里的一个员工 | 在同一家餐厅里干活,共享厨房和食材 |
| Event Loop | 前台调度员 | 一个坐在某个员工工位上的调度循环,分配任务、跟踪进度 |
| Coroutine (async def) | 订单 | 一张写好步骤的工单,可以被挂起、恢复 |
| await | "等出餐"的标记 | 告诉调度员:"这一步要等,先去忙别的" |
| to_thread() | 把活递给后厨帮工 | 把一个耗时操作扔给帮工线程,调度员不用干等 |
| Worker 线程 | 后厨帮工 | 线程池里的线程,纯干活,没有调度能力 |
| Worker 进程 | 连锁分店 | uvicorn --workers 启动的多个进程,每个都有自己的调度员 |
| GIL | 唯一的一把菜刀 | 同一餐厅内,同一时刻只有一个员工能用刀(CPU) |
2. 场景一:纯 async — 一个人的前台
想象一个只有一个调度员的餐厅,没有后厨。
调度员(Event Loop)
├── 顾客A点单 → 下单给厨房(发HTTP请求)→ ⏳ 等出餐
│ ↓
│ 这时调度员不是傻等!
│ 而是转头去服务顾客B ←──┐
│ │
├── 顾客B点单 → 下单给厨房 → ⏳ 等出餐 │
│ │
└── 顾客A的菜好了 ← 通知到达 ──────────────────────────┘
→ 端菜上桌
对应代码:
async def serve_customer(name):
order = take_order(name) # 瞬间完成,不用等
meal = await kitchen.cook(order) # ⏳ 等待期间,event loop 去服务别人
deliver(meal) # 菜好了,继续
# event loop 同时调度多个顾客
async def main():
await asyncio.gather(
serve_customer("A"),
serve_customer("B"),
serve_customer("C"),
)
关键洞察:
- Event loop 是单线程的,只有一个调度员
await不是"阻塞等待",而是"挂起自己,让出控制权"- 适合 I/O 密集场景:网络请求、数据库查询、文件读写
- 所有
await的东西必须也是异步的,否则整个餐厅卡住
3. 场景二:灾难 — 调度员亲自下厨
如果调度员自己去炒菜(在 async 函数里调用同步阻塞代码):
调度员(Event Loop)
├── 顾客A点单 → 调度员亲自去炒菜...
│ ↓
│ 炒了 5 分钟...
│ ↓
│ 这 5 分钟里:
│ ❌ 顾客B 进门没人理
│ ❌ 顾客C 的外卖通知没人接
│ ❌ 所有 WebSocket 心跳超时
│ ❌ 整个餐厅冻结
│ ↓
│ 炒完了,终于回来...
└── 顾客B/C/D 已经走了(连接超时)
对应代码(错误示范):
async def handle_message(msg):
# ❌ 灾难!这是同步阻塞调用
result = requests.get("https://api.example.com/slow") # 3秒
# 这 3 秒里,整个 event loop 冻结
# 所有其他协程、WebSocket、定时器全部停摆
return result
这就是 CoPaw 项目 CLAUDE.md 里反复强调的 "Async Safety Rules"。
4. 场景三:to_thread — 请后厨帮忙
解决方案:调度员把耗时工作递给后厨帮工。
调度员(Event Loop)
├── 顾客A点单 → 递给后厨帮工张三 → "炒好了叫我"
│ ↓
│ 调度员继续: 张三在后厨炒菜
├── 顾客B点单 → 递给帮工李四 → (独立线程)
│ ↓
├── 顾客C点单 → 纯点饮料 → 张三:"好了!"
│ (不用后厨,直接出) ↓
│ 调度员收到通知
└── 给顾客A上菜 → 端菜上桌
对应代码(正确做法):
async def handle_message(msg):
# ✅ 扔给线程池,调度员(event loop)不阻塞
result = await asyncio.to_thread(requests.get, "https://api.example.com/slow")
return result
asyncio.to_thread() 的本质:
┌─────────────────────────────┐
│ Event Loop 线程(主线程) │
│ │
│ async def foo(): │
│ ... │
│ result = await ────────────► ┌──────────────────┐
│ to_thread( │ │ Worker 线程 │
│ sync_func, │ │ │
│ arg1, arg2 │ │ sync_func( │
│ ) │ │ arg1, arg2 │
│ # 这里 event loop │ │ ) │
│ # 去忙别的了 │ │ # 真正阻塞在这 │
│ ... │ │ # 但只阻塞这个 │
│ # 结果回来,继续执行 │ ◄───│ # worker 线程 │
│ print(result) │ └──────────────────┘
└─────────────────────────────┘
5. Event Loop 和线程的绑定关系
前面反复说"event loop 是单线程的",这里把这个关系说透:
Event loop 必须绑在一个线程上运行,且独占该线程。
线程(Thread)是操作系统概念 —— 一条执行流
Event Loop 是 Python 概念 —— 一个在线程上跑的"调度循环"
打个比方:
线程 = 一条马路
Event Loop = 一个在马路上跑的交警
一条路上最多一个交警(一个线程最多一个 event loop)
交警必须站在某条路上才能指挥(event loop 必须绑定一个线程)
但大多数路上根本没有交警(大多数线程不跑 event loop)
这就引出了一个关键区别——一个进程里的线程分两种角色:
┌─────────────────────────────────────────────────────┐
│ Python 进程 │
│ │
│ 主线程 ─── 跑着 Event Loop │
│ │ 这个线程被 event loop 完全占据 │
│ │ 它的全部工作就是: │
│ │ while True: │
│ │ 拿出下一个就绪的协程 │
│ │ 推进一步 │
│ │ 有 I/O 完成?恢复对应协程 │
│ │ │
│ Worker 线程 2 ─── 没有 event loop │
│ │ 就是个普通线程,跑同步阻塞代码 │
│ │ 跑完了把结果丢回给 event loop │
│ │ │
│ Worker 线程 3 ─── 没有 event loop │
│ │ 同上 │
│ │ │
│ Worker 线程 N ─── 没有 event loop │
└─────────────────────────────────────────────────────┘
线程池里的 worker 线程,根本没有 event loop。 它们就是最朴素的劳动力——接活、干活、交结果。Event loop 是"指挥官",worker 线程是"士兵",它们虽然都是线程,但角色完全不同。
6. 全景图:单进程内的协作
理解了绑定关系后,再来看完整的单进程架构:
┌─────────────────────────────────────────────────────────┐
│ Python 进程 │
│ │
│ ┌──────────────────────────────────┐ │
│ │ Event Loop(主线程) │ │
│ │ │ │
│ │ ┌─────┐ ┌─────┐ ┌─────┐ │ │
│ │ │协程A│ │协程B│ │协程C│ ... │ ← 成百上千个 │
│ │ └──┬──┘ └──┬──┘ └──┬──┘ │ 协程并发运行 │
│ │ │ │ │ │ │
│ │ 调度员轮流推进每个协程 │ │
│ │ 遇到 await → 挂起 → 推进下一个 │ │
│ │ I/O完成 → 恢复对应协程 │ │
│ └──────────────┬───────────────────┘ │
│ │ │
│ │ to_thread() / run_in_executor() │
│ ▼ │
│ ┌──────────────────────────────────┐ │
│ │ ThreadPoolExecutor │ │
│ │ (Worker 线程池) │ │
│ │ │ │
│ │ ┌────────┐ ┌────────┐ ┌──────┐ │ │
│ │ │Worker 1│ │Worker 2│ │ ... │ │ ← 默认 │
│ │ │同步阻塞│ │同步阻塞│ │ │ │ min(32, cpu+4) │
│ │ │任务执行│ │任务执行│ │ │ │ 个线程 │
│ │ └────────┘ └────────┘ └──────┘ │ │
│ └──────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────┐ │
│ │ GIL │ │
│ │ 同一时刻只有一个线程执行 Python │ │
│ │ 字节码。但 I/O 等待时会释放 GIL, │ │
│ │ 所以 I/O 密集场景线程池仍然有效。 │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
7. GIL:那把唯一的菜刀
GIL(Global Interpreter Lock)是 Python 最令人困惑的概念。继续用餐厅比喻:
一家餐厅有 4 个帮工(4 个线程),但只有 1 把菜刀(GIL)。
场景A:切菜(CPU 计算)
张三拿刀切菜 → 李四等 → 王五等 → 赵六等
效果:4个人 ≈ 1个人的速度 ← 多线程对 CPU 密集任务无用!
场景B:烧水+切菜(I/O + CPU 混合)
张三把锅放上去烧水(I/O等待)→ 放下刀 → 李四拿刀切菜
水开了 → 张三等李四切完 → 拿回刀
效果:烧水和切菜同时进行 ← 多线程对 I/O 密集任务有效!
所以:
| 任务类型 | 多线程有效? | 推荐方案 |
|---|---|---|
| I/O 密集(网络/磁盘) | 有效 | async/await 或 to_thread |
| CPU 密集(计算/编码) | 无效(GIL) | multiprocessing 或 ProcessPoolExecutor |
GIL 是进程级别的 —— 每个进程有自己的 GIL,互不影响。这就引出了下一个话题。
8. 开分店:多 Worker 进程
一家餐厅忙不过来怎么办?开连锁店。
每家分店(进程)有自己的调度员(event loop)、自己的后厨团队(线程池)、自己的菜刀(GIL),完全独立运营。
uvicorn --workers 4 # 开 4 家分店
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Worker 进程 1 │ │ Worker 进程 2 │ │ Worker 进程 3 │ │ Worker 进程 4 │
│ │ │ │ │ │ │ │
│ 主线程 │ │ 主线程 │ │ 主线程 │ │ 主线程 │
│ └─ Event Loop │ │ └─ Event Loop │ │ └─ Event Loop │ │ └─ Event Loop │
│ │ │ │ │ │ │ │
│ Worker 线程池 │ │ Worker 线程池 │ │ Worker 线程池 │ │ Worker 线程池 │
│ ├─ 线程1 │ │ ├─ 线程1 │ │ ├─ 线程1 │ │ ├─ 线程1 │
│ ├─ 线程2 │ │ ├─ 线程2 │ │ ├─ 线程2 │ │ ├─ 线程2 │
│ └─ ... │ │ └─ ... │ │ └─ ... │ │ └─ ... │
│ │ │ │ │ │ │ │
│ 独立 GIL │ │ 独立 GIL │ │ 独立 GIL │ │ 独立 GIL │
│ 独立内存 │ │ 独立内存 │ │ 独立内存 │ │ 独立内存 │
└──────────────────┘ └──────────────────┘ └──────────────────┘ └──────────────────┘
│ │ │ │
└────────────────────┴────────────────────┴────────────────────┘
│
操作系统负责把请求
分发到不同的分店
这就是"一个 worker 就是一个 event loop"的含义:
- 这里的 "worker" 指的是 uvicorn/gunicorn 启动的 worker 进程
- 每个 worker 进程有自己的主线程
- 每个主线程上跑着自己的 event loop
- 进程间内存隔离、GIL 隔离 → CPU 计算终于能真正并行了
9. "Worker" 这个词的三张面孔
到这里你可能已经注意到,"worker" 在不同语境下含义完全不同:
┌─────────────────────────────────────────────────────────────────┐
│ 语境 │ Worker 指的是 │ 有 Event Loop? │
├─────────────────────────────────────────────────────────────────┤
│ uvicorn --workers 4 │ 进程 │ ✅ 有,各自独立 │
│ gunicorn -w 4 -k uvicorn │ 进程 │ ✅ 有,各自独立 │
│ │ │ │
│ ThreadPoolExecutor(workers=32) │ 线程 │ ❌ 没有 │
│ asyncio.to_thread() │ 线程 │ ❌ 没有 │
│ COPAW_AGENT_QUEUE_WORKERS=32 │ 线程 │ ❌ 没有 │
│ │ │ │
│ ProcessPoolExecutor(workers=4) │ 进程 │ ❌ 通常没有 │
└─────────────────────────────────────────────────────────────────┘
记忆口诀:
进程 Worker = 分店 → 有自己的调度员(event loop)、厨具(内存)、菜刀(GIL)
线程 Worker = 帮工 → 在同一家店里干活,听调度员指挥,共用一把菜刀
别被 "worker" 这个词骗了——看它是进程还是线程,一切就清晰了。
10. CoPaw 的实际架构
CoPaw 的并发模型正好是上述概念的完整应用:
┌─────────────────────────────────────────────────────────────┐
│ CoPaw (FastAPI + uvicorn, 单 worker 进程) │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ 主线程 + Event Loop │ │
│ │ │ │
│ │ ├── WebSocket 连接管理(纯 async 协程) │ │
│ │ ├── HTTP 路由处理(纯 async 协程) │ │
│ │ ├── Channel 消息收发(纯 async 协程) │ │
│ │ ├── 心跳检测、定时任务(纯 async 协程) │ │
│ │ │ │ │
│ │ │ 遇到同步阻塞操作时,不亲自干,递给线程池: │ │
│ │ │ │ │
│ │ └── to_thread() ───────────────────────┐ │ │
│ └─────────────────────────────────────────┼──────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Worker 线程池 (COPAW_AGENT_QUEUE_WORKERS=32) │ │
│ │ │ │
│ │ ├── 线程1:LLM API 调用(同步 HTTP) │ │
│ │ ├── 线程2:文件读写 │ │
│ │ ├── 线程3:tokenizer 编码 │ │
│ │ ├── 线程4:其他同步第三方库 │ │
│ │ └── ... │ │
│ │ │ │
│ │ 这些线程没有 event loop,纯粹的"后厨帮工" │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ GIL:所有线程共享一把菜刀 │
│ 但 LLM API 调用主要是等网络 I/O,等待时释放 GIL,所以没问题 │
└─────────────────────────────────────────────────────────────┘
为什么 CoPaw 的 CLAUDE.md 里有严格的 Async Safety Rules?
因为 FastAPI 的 event loop 就是那个唯一的调度员。如果你在 async def 里调用了 requests.get()、open().read()、time.sleep() 这些同步阻塞操作,调度员就"亲自下厨"了——所有 WebSocket 连接、所有 HTTP 请求、所有心跳检测,全部冻结。
11. 决策流程图
当你要写一个函数时,按这个流程判断:
需要写一个函数
│
▼
这个函数会做 I/O 吗?(网络/磁盘/数据库)
│
├── 否 → 普通 def,直接写
│
└── 是 → 会被 async 函数调用吗?
│
├── 否 → 普通 def,直接写
│
└── 是 → 有异步版本的库吗?
│
├── 有 → 用 async def + await
│ 例:httpx.AsyncClient
│ aiofiles.open()
│ asyncio.sleep()
│
└── 没有 → await asyncio.to_thread(同步函数)
例:to_thread(requests.get, url)
to_thread(open(f).read)
to_thread(tokenizer.encode, text)
需要更大的吞吐量?
单进程扛不住了
│
▼
瓶颈在哪?
│
├── I/O 等待太多 → 加大线程池 (max_workers)
│ 或用纯 async 库替代同步库
│
├── CPU 计算太重 → 多进程 (ProcessPoolExecutor)
│ 或 uvicorn --workers N
│
└── 两者都有 → uvicorn --workers N(每个进程独立 event loop + 线程池)
12. 常见误区
误区 1:async 就是多线程
错。 async 是单线程并发。只有一个线程,但通过"挂起/恢复"实现并发。多线程是多个线程真正同时存在。
async(协程):一个人同时煮3锅面——不是分身,而是趁等水开去忙别的
多线程: 3个人各煮1锅面——真的有3个人
误区 2:await 就是等待
不完全对。 await 的意思是"我这里要等一下,event loop 你先去忙别的"。它是让出控制权,不是阻塞等待。
await asyncio.sleep(5) # ✅ 让出 5 秒,event loop 去忙别的
time.sleep(5) # ❌ 真的卡 5 秒,谁都别想动
误区 3:to_thread 能加速 CPU 计算
不能。 因为 GIL,CPU 密集任务在多线程里依然是串行的。to_thread 的价值是不阻塞 event loop,不是加速计算本身。
# 这不会让计算变快,但会让 event loop 不卡住
result = await asyncio.to_thread(heavy_cpu_work, data)
# 真正想并行 CPU 计算,用多进程
from concurrent.futures import ProcessPoolExecutor
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(process_pool, heavy_cpu_work, data)
误区 4:所有函数都应该是 async
不是。 只有需要做 I/O 且会被 event loop 调度的函数才需要 async。纯计算、工具函数、数据转换等用普通 def 就好。
误区 5:线程池里的 worker 有自己的 event loop
没有。 线程池里的 worker 线程是最朴素的线程,接活、干活、交结果,没有 event loop。只有 uvicorn/gunicorn 的 worker 进程才各自带一个 event loop。看到 "worker" 时,先分清它是进程还是线程。
13. 一张表总结
| 你想做的事 | 用什么 | 比喻 |
|---|---|---|
| 同时处理 1000 个网络请求 | async/await + asyncio.gather | 调度员同时跟踪 1000 张订单 |
| 在 async 里调用同步库 | await asyncio.to_thread(fn) | 递给后厨帮工 |
| 并行 CPU 计算 | ProcessPoolExecutor | 开分店,每店一把刀 |
| 更大的 Web 吞吐量 | uvicorn --workers N | 开连锁店,每店一个调度员 |
| 简单脚本,不需要并发 | 普通 def | 自己一个人的小摊 |
| 定时任务/后台任务 | asyncio.create_task() | 调度员贴一张便签:"5分钟后提醒顾客A" |
附:关键 API 速查
import asyncio
# 1. 运行 async 入口
asyncio.run(main())
# 2. 并发执行多个协程
results = await asyncio.gather(coro1(), coro2(), coro3())
# 3. 同步函数扔进线程池(不阻塞 event loop)
result = await asyncio.to_thread(sync_function, arg1, arg2)
# 4. 创建后台任务
task = asyncio.create_task(some_coroutine())
# 5. 非阻塞睡眠
await asyncio.sleep(1.0)
# 6. 自定义线程池/进程池
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
pool = ThreadPoolExecutor(max_workers=8)
result = await loop.run_in_executor(pool, sync_function, arg)
pool = ProcessPoolExecutor(max_workers=4)
result = await loop.run_in_executor(pool, cpu_heavy_function, arg)