异步、并发、取消与限流
本节目标
理解协程为何适合大量等待型 I/O,能用结构化并发管理任务生命周期,并为超时、取消、并发上限和部分失败设计明确行为。这里不把并发当作默认优化,也不把它误称为 CPU 并行。
先判断是否需要并发
| 工作类型 | 主要时间花在哪里 | 常见选择 |
|---|---|---|
| I/O-bound | 等网络、磁盘、数据库、模型服务 | asyncio,或受控线程 |
| CPU-bound | 编码、图像处理、大量 Python 计算 | 测量后考虑进程、原生库或其他运行时 |
| 很小或必须顺序 | 数据有依赖、调用量低 | 普通同步代码更清楚 |
并发(concurrency)表示多个任务的执行区间重叠;并行(parallelism)表示同一时刻真的在多个计算资源上运行。单线程事件循环可以高效并发等待,但不会自动让 CPU 密集 Python 代码更快。
协程与事件循环
调用 async def 函数只创建协程对象;只有被 await 或注册为任务后才执行:
import asyncio
async def fetch_one(name: str) -> str:
await asyncio.sleep(0.05) # 模拟非阻塞等待
return name.upper()
async def main() -> None:
result = await fetch_one("rag")
print(result)
if __name__ == "__main__":
asyncio.run(main())在库代码中通常不调用 asyncio.run();它属于应用最外层入口。Notebook、Web 框架和异步服务已经拥有事件循环,重复启动会报错。
用 TaskGroup 管理生命周期
Python 3.11+ 的 asyncio.TaskGroup 将“创建哪些任务、何时等待、一个失败后其余任务怎么办”放进同一作用域:
async def fetch_all(names: list[str]) -> list[str]:
async with asyncio.TaskGroup() as group:
tasks = [group.create_task(fetch_one(name)) for name in names]
return [task.result() for task in tasks]当某个子任务首次抛出非取消异常时,TaskGroup 会取消其他尚未完成的任务;退出时可能用 ExceptionGroup 聚合多个异常。它适合“这些子任务共同构成一次操作”的 fail-fast 语义。
若业务允许部分成功,应显式把每项结果建模为成功/失败记录,而不是用宽泛 gather(..., return_exceptions=True) 后忘记检查异常。
并发上限与背压
一次创建十万个请求即使都异步,也会耗尽连接、内存或服务配额。Semaphore 限制同时进入外部边界的数量:
async def bounded_map(names: list[str], limit: int) -> list[str]:
if limit < 1:
raise ValueError("limit 必须至少为 1")
semaphore = asyncio.Semaphore(limit)
async def run(name: str) -> str:
async with semaphore:
return await fetch_one(name)
async with asyncio.TaskGroup() as group:
tasks = [group.create_task(run(name)) for name in names]
return [task.result() for task in tasks]并发上限不等于每秒速率限制。前者限制同时在途数量,后者限制时间窗口内请求数;生产客户端往往同时需要服务端配额、队列长度和总任务预算。
超时与取消是控制流
async def fetch_with_deadline(name: str) -> str:
try:
async with asyncio.timeout(2.0):
return await fetch_one(name)
except TimeoutError as exc:
raise TemporaryDependencyFailure("读取超时") from exc超时作用域取消当前任务,并在作用域外转换为内置 TimeoutError。取消可能在任意 await 点到达;用 try/finally 收尾,然后重新抛出 CancelledError:
async def worker() -> None:
resource = await open_resource()
try:
await use_resource(resource)
except asyncio.CancelledError:
# 只做必要的取消记录或状态转换
raise
finally:
await resource.aclose()吞掉 CancelledError 会破坏 TaskGroup、超时和应用关闭。除非真的要把取消转成一种已定义业务结果,否则必须传播。
不要阻塞事件循环
以下调用会阻塞当前线程,不能直接放在异步热路径:
time.sleep();- 同步 HTTP/数据库 SDK;
- 大文件同步读写;
- CPU 密集循环。
小型不可避免的同步 I/O 可用 await asyncio.to_thread(function, *args) 隔离,但线程中的函数仍可能在协程取消后继续运行。必须设置底层超时并控制提交量;不要把 to_thread 当作可强制终止任意代码的机制。
结果顺序、重复与共享状态
- 完成顺序不一定等于输入顺序;接口应明确使用哪一种;
- 外部调用超时后可能已成功,重试仍需幂等语义;
- 避免多个协程直接修改同一可变字典;优先让任务返回值,再在单一位置汇总;
- 若必须共享状态,使用锁并把临界区保持很小,且不可在持锁时等待慢 I/O。
练习
- 用
asyncio.sleep()模拟 20 个任务,将并发上限设为 3;记录并证明峰值不超过 3。 - 让第 5 项失败,比较
TaskGroup的 fail-fast 与“每项返回结果对象”的部分成功设计。 - 给整个批次设置 0.3 秒总时限,检查所有任务是否收尾。
- 把
time.sleep()错放进协程并测量,再改为asyncio.sleep();解释差异。
自测
- 能区分协程对象、任务和事件循环。
- 能解释并发、并行、并发上限和速率限制的差别。
- 能说明
TaskGroup中一个任务失败后发生什么。 - 能正确传播取消,并指出资源关闭路径。
- 能识别会阻塞事件循环的同步调用。
相关概念与下一步
- 前置:单元测试、Mock 与回归。
- 下一节:项目结构、CLI 与可复现运行。
- 服务端速率策略见 API;工作流并行与恢复见 工作流自动化。
参考资料
获取日期:2026-07-14。课程以 Python 3.11+ 的稳定接口为基线,不依赖 3.14 新增的 TaskGroup.create_task() 参数。