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_searchweb_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 级缓存的实际隔离边界
  1. 进程负责并行,协程负责并发。 多进程利用多核带来真正的“同时执行”;协程则在单线程内通过让出与切换营造“看似同时”。
  2. 一个进程一个事件循环。 事件循环是运行时的调度中枢,与进程一一对应,跨进程不共享。
  3. 隔离的根因是对象身份。 turn 级缓存之所以安全,是因为它挂在每个协程自己的栈帧上,而非依赖进程隔离。
posted @ 2026-09-03 17:56  Sun-Wind  阅读(13)  评论(0)    收藏  举报