Python并发

4 阅读11分钟

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/awaitto_thread
CPU 密集(计算/编码)无效(GIL)multiprocessingProcessPoolExecutor

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)