fastapi中进程、线程、协程与事件循环
fastapi中进程、线程、协程与事件循环
以一个基于 FastAPI + uvicorn 的 AI 教学服务为例
进程与线程是操作系统课程中的经典概念,而协程与事件循环则更多地出现在异步编程的语境里。
一、四种抽象,四种实体
| 抽象 | 项目中的具体实体 | 数量 | 创建者 |
|---|---|---|---|
| 进程 | uvicorn workers=N fork 出的 N 个子进程,各自独立导入 teachos.main:app,持有独立的 app.state |
N | uvicorn 主进程 |
| 事件循环 | 每个 worker 进程内由 asyncio 运行的 loop,随 worker 启动而创建 | 每进程 1 个 | uvicorn worker |
| 线程 | 每个 worker 的主线程(运行事件循环),外加默认 ThreadPoolExecutor 的若干线程(为 run_in_executor 兜底阻塞调用)如asyncio.to_thread |
主线程 1 个 + 线程池若干 | asyncio / uvicorn |
| 协程 / Task | async def 函数经 asyncio.create_task 调度后形成的任务对象 |
数十至数千个并存 | 业务代码 |
这里有一个容易被忽略的要点:进程和线程是操作系统的调度单位,而协程是语言运行时的调度单位。 一个进程可以包含多个线程,一个线程(在这里即主线程)可以承载一个事件循环,而一个事件循环又可以同时管理成百上千个协程。它们之间是层层嵌套、而非相互替代的关系。
二、事件循环:一个进程只有一个
一个常见的疑问是“这个服务里到底有几个事件循环”。答案是:每个 worker 进程恰好一个,进程之间的事件循环互不相干。
这里需要澄清一个容易混淆的表述。此前常被提到的“N”,指的是 uvicorn workers=N 所 fork 出的进程数;而事件循环是“每进程 1 个”。因此准确的说法是:N 个进程对应 N 个事件循环,二者一一对应。 进程之间不共享内存,自然也不共享事件循环——每一个 worker 都在自己的进程内独立地 import 应用、建立 loop、accept 连接。
三、一个 worker 里同时挂着多少协程
“一个事件循环同时管理多少协程”是最能体现“宏观并发、微观串行”的观察点。在服务启动阶段,lifecycle.py 就会在事件循环上挂起若干长生命周期的后台任务:
# lifecycle.py —— worker 启动时注册的常驻任务
app.state.file_gc_task = asyncio.create_task(
_run_file_gc(file_center), name="file-center-gc")
app.state.file_parse_task = asyncio.create_task(
_run_file_parse_tasks(file_center), name="file-parse")
app.state.knowledge_document_task = asyncio.create_task(
_run_knowledge_document_tasks(...), name="knowledge-document")
这三个任务是后台常驻协程,各自以 while True 轮询 PostgreSQL 领取任务,与事件循环同生共死。需要注意的是,它们与“处理 HTTP 请求的协程”运行在同一个事件循环、同一个主线程里,共享同一个 loop。也正因如此,_run_file_gc 的注释才会强调“每个 uvicorn worker 都运行本任务”——N 个进程就有 N 份 GC 协程在同时运行,而它们之间依靠 FOR UPDATE SKIP LOCKED 这一行级锁技巧避免争抢同一条记录。
再看一个 turn(一次完整的对话请求)到来时所挂起的协程:
# agent.py —— 一个 turn 在 worker 内挂起的协程
task = asyncio.create_task(_run_with_turn_lock(turn_lock, operation, ...)) # turn 主体
turn_tasks.add(task)
heartbeat = asyncio.create_task(_renew_turn_lock(lock, owner, ...)) # lease 续期
async def event_stream():
async for event in orchestrator.stream_bus.subscribe(turn_id):
yield ...
return StreamingResponse(event_stream(), ...) # SSE 推流
也就是说,单个 turn 在一个 worker 内至少挂起三个 Task:turn 主体、lease 续期、SSE 推流;而当 agent_loop 内部流式调用模型时,还会再起若干内部 Task。所有这些 Task 都在同一个事件循环、同一个主线程里轮流执行——它们通过 await 主动让出控制权,由事件循环在它们之间切换,从而在一个线程里营造出“并发”的效果。
四、一次请求的完整路径
下面以生产环境 workers=4、两个用户同时 POST /v1/threads/turns/stream 为例,沿着进程、事件循环、协程三个层次,追踪一次请求的完整路径。
┌─ 进程层(OS)──────────────────────────────────────────────┐
│ 4 个 uvicorn worker 进程;内核借助 SO_REUSEPORT 按四元组 │
│ hash 分配 TCP 连接。用户 A 的连接落到 worker1,用户 B 落到 │
│ worker3。一条连接的整个 SSE 长流粘在分配到的那个 worker 上, │
│ 不会漂移。 │
└────────────────────────────────────────────────────────────┘
│ 用户 A 的连接 → worker1
▼
┌─ 事件循环层(worker1 进程内,唯一 1 个)─────────────────────┐
│ uvicorn 在 worker1 的主线程上运行 asyncio loop。此刻 loop 上 │
│ 挂着: │
│ • _run_file_gc Task (lifecycle.py,常驻)│
│ • _run_file_parse_tasks Task (lifecycle.py,常驻)│
│ • _run_knowledge_document_tasks Task(lifecycle.py,常驻)│
│ • 其他正在处理的 HTTP 协程若干 │
│ 新连接到来 → uvicorn 解析 → 路由匹配到 stream_turn,一个新的 │
│ 协程被调度。 │
└────────────────────────────────────────────────────────────┘
│
▼ 进入 stream_turn 协程(agent.py)
┌─ 协程层(同进程、同线程、同 loop)───────────────────────────┐
│ [step1] await _acquire_turn_lock(...) │
│ → Redis lease;await 期间本协程挂起,loop 去跑别的 │
│ [step2] await task_service.start(...) │
│ → asyncpg 写 PostgreSQL;await 期间同样让出 │
│ [step3] asyncio.create_task(_run_with_turn_lock(...)) │
│ → 不 await,立即拿到 Task 对象并登记进 turn_tasks │
│ → turn 主体在另一个协程里与当前协程并发推进 │
│ [step4] await asyncio.shield(started.wait()) │
│ → 等待主体协程报告“已拿到锁、开始运行” │
│ [step5] return StreamingResponse(event_stream()) │
│ → 把 SSE 推流协程交给 uvicorn 持续 yield │
│ │
│ 与此同时,turn 主体协程 _run_with_turn_lock 内部: │
│ create_task(_renew_turn_lock) ← 第 3 个协程(续期 lease)│
│ await operation ← 即 orchestrator.handle │
│ → orchestrator: self.agent_loop.run(...) │
│ → agent_loop: agent.astream(...) │
│ 模型决定调用 web_search 工具: │
│ await client.unified_search_async(...) │
│ ↑ 此处 await 让出 loop,等待搜索服务回包 │
│ ↑ 这段空窗期,loop 转而推送用户 A 的 SSE、续期 │
│ lease、运行 GC 协程,甚至处理其他用户请求 │
│ await runtime.store.aput(...) ← 写 web cache │
│ 模型再调用 web_extract 工具: │
│ await runtime.store.aget(...) ← 读 web cache │
└────────────────────────────────────────────────────────────┘
这段路径清晰地展示了事件循环的“协作式”本质:每当协程遇到 await 一个 I/O 操作,它便主动让出执行权,事件循环随即切换到另一个就绪的协程。因此,一个线程在宏观上表现为“多个请求同时进行”,在微观上却是“任何时刻只有一个协程在真正执行”。所谓“并发”而非“并行”,正是这个意思。
五、缓存的隔离边界:协程帧,而非进程
agent_loop.py 中的 store = InMemoryStore() 是在 CreateAgentLoop.run 里现场新建的,挂在这一次 turn 主体协程的调用栈帧上。据此可以推出一系列隔离性质:
- 同一 turn 内:
web_search与web_extract都在同一个 turn 主体协程里被await,二者拿到的是同一个runtime.store对象,因此共享缓存。 - 跨 turn / 跨用户:不同 turn 对应不同的
run()调用,也就对应不同的InMemoryStore()对象,彼此不可见。 - 跨进程:用户 A 的
store在 worker1、用户 B 的在 worker3,本就不共享——但这里也无需共享,因为它本就是 turn 级的缓存。
由此可以澄清一个更本质的问题:两个用户同时搜索,能否互相隔离?答案是能。即便两人都落到同一个 worker,也对应着两个 stream_turn 协程、两个 _run_with_turn_lock Task、两次 CreateAgentLoop.run 调用、两个 InMemoryStore() 对象。它们挂在不同协程的栈帧上,事件循环在二者之间切换时,各自的局部变量互不混淆。
六、线程
在这套服务里,只有主线程,外加一个默认的 ThreadPoolExecutor 用于为偶尔出现的阻塞式 SDK 调用兜底。真正承载并发的是协程,而非线程池。
之所以能够如此,是因为整条链路都使用了原生异步接口:
- 联网搜索所用的 SDK 提供
*_async接口(web.py中的unified_search_async等),走 asyncio 原生路径,不占用线程池; - 数据库使用
asyncpg,缓存使用redis.asyncio,同样都是原生异步实现。 - asyncio.to_thread 与 run_in_executor(None, ...) 等价,均使用默认 ThreadPoolExecutor
于是,从收到请求到回传 SSE,整条联网搜索链路几乎完全在“单进程、单线程、单事件循环、多协程”的模型内完成。多进程在这里承担的职责是水平扩容——利用多核实现真正意义上的并行,而不是用来弥补单线程的并发能力。
七、总结
把上述内容压缩成一张图,便是这套服务并发模型的全部骨架:
N 个进程(workers=N,由 OS 调度,多核真并行)
└─ 每个进程 1 个事件循环(asyncio,运行在主线程)
└─ 挂着 M 个 asyncio Task(常驻 GC/解析 + 每请求若干个,
由 await 驱动轮流执行,单线程内宏观并发、微观串行)
└─ 每个 turn 主体 Task 的栈帧上挂着局部变量 InMemoryStore(),
这就是 turn 级缓存的实际隔离边界
- 进程负责并行,协程负责并发。 多进程利用多核带来真正的“同时执行”;协程则在单线程内通过让出与切换营造“看似同时”。
- 一个进程一个事件循环。 事件循环是运行时的调度中枢,与进程一一对应,跨进程不共享。
- 隔离的根因是对象身份。 turn 级缓存之所以安全,是因为它挂在每个协程自己的栈帧上,而非依赖进程隔离。
浙公网安备 33010602011771号