同步、异步与取消
同步、异步与取消 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
这一课分清 Python 服务里两种等待:网络等待(该异步)和计算等待(异步也救不了)。目标是正确选 def 或 async def、组织并发调用、加超时,并且能在客户端断开时取消上游请求。
问题场景
你给服务加了个「抓取课程更新」的接口,用 requests.get() 拉外部数据。测试时一个请求 10 秒,期间整个服务其他接口全卡死——FastAPI 的 async 事件循环被同步等待堵住了。这就是本课要根治的病。
前端对应物也直白:AbortController 取消 fetch。服务端同样有这个问题,而且更隐蔽。
核心解释
先钉死选型规则,FastAPI 文档里最重要的表格就一句话:
async def路由跑在事件循环里:里面出现的每一次等待都必须是await可等待对象。写了time.sleep(5)、requests.get(...)、或者跑一段 3 秒的纯计算,事件循环就停 5 秒,所有请求陪葬。- 普通
def路由在线程池里跑:阻塞无所谓,不占事件循环。拿不准、依赖库只有同步版时,用 def 更安全。 - 异步网络请求用 httpx 的异步客户端(
AsyncClient),它是 requests 的异步等价物。
并发、超时、取消三个动作的标准写法:
import asyncio
import httpx
from fastapi import FastAPI
app = FastAPI()
async def fetch_one(client: httpx.AsyncClient, url: str) -> str:
r = await client.get(url) # 网络等待:真正让出事件循环
return r.text[:50]
@app.get("/fetch-all")
async def fetch_all():
async with httpx.AsyncClient(timeout=5) as client:
results = await asyncio.gather( # 两个请求并发,总耗时≈最慢那个
fetch_one(client, "https://a.example.com"),
fetch_one(client, "https://b.example.com"),
)
return {"items": results}
@app.get("/fetch-safe")
async def fetch_safe():
async with httpx.AsyncClient() as client:
try:
text = await asyncio.wait_for(fetch_one(client, "https://slow.example.com"),
timeout=3.0) # 超时自动取消并抛 TimeoutError
except asyncio.TimeoutError:
text = ""
return {"text": text}
取消是收尾的一环:客户端断开时,FastAPI 会向该请求的协程抛 asyncio.CancelledError。你在协程里启动的下游请求要把取消传播下去——httpx 请求会随事件循环任务取消而中断。本站 /api/ai/chat 就是这么写的:req.signal 的 abort 事件触发 upstreamController.abort(),把前端点的「停止生成」一路传导到上游模型请求,已收到的部分文本则保留为 partial。
示例拆解
一个实验证明「async def 里的阻塞有多贵」:
@app.get("/blocked")
async def blocked():
import time
time.sleep(3) # 反例:在 async def 里同步睡眠
return {"ok": True}
预期验证:起两个终端,先 curl /blocked,紧接着 curl 任意其他接口——第二个请求要等 3 秒才响应。把 time.sleep(3) 换成 await asyncio.sleep(3) 再试,第二个请求立刻响应。这一组对照就是「网络等待 vs 阻塞调用」的体感教材。CPU 密集任务(大文件解析)两头都不适用:用 run_in_threadpool 或进程池,别挂在事件循环上。
动手任务
- 写一个
async def接口并发抓取两个公开 URL(httpx),用time.perf_counter打印总耗时,验证 ≈ 最慢单请求而非两者之和。 - 给抓取加
asyncio.wait_for超时 2 秒,把一个不可达域名塞进去,确认超时后接口正常返回而不是挂死。 - 用
/docs执行/blocked,同时另开终端请求/docs页面,复现「全服务卡住」,再改成asyncio.sleep复验。
常见错误
- async def 里调同步 IO 库:requests、time.sleep、同步 sqlite 长查询,全是事件循环毒药;换异步库或改用 def。
- 并发写成顺序 await:
await a(); await b()仍是串行;并发用asyncio.gather。 - 忘了超时:外部服务挂了你的接口就永远不返回;所有出网请求必须有 timeout。
- 取消后不清理:协程被取消时没释放连接、没保存已收到的部分结果;catch
CancelledError做完清理再重新抛出。
检查与复盘
自查清单:
- 每个 async def 路由里能指认出所有 await 点,且没有同步阻塞调用
- 出网请求全部带超时,并发用 gather 而非顺序 await
- 能说清客户端断开后取消如何传播到上游请求
参考资料
- FastAPI 官方文档:Concurrency and async / await —— def 与 async def 的选型权威说明
- asyncio 标准库文档 —— gather、wait_for 与取消机制
读完这一课了?
标记完成后,工作台会安排接下来的学习。