5.8 KiB
5.8 KiB
id, title, type, domain, stack, tags, level, prerequisites, est_hours, related, week, order
| id | title | type | domain | stack | tags | level | prerequisites | est_hours | related | week | order | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| python.asyncio.concurrency-control | 并发控制:gather / TaskGroup、Semaphore、超时、取消、阻塞调用 | lesson | python |
|
|
intermediate |
|
3 |
|
|
2 |
并发控制:gather / TaskGroup、Semaphore、超时、取消、阻塞调用
解释说明
等待一组任务的三种方式
| 写法 | 一个失败时 | 适合 |
|---|---|---|
gather(*coros) |
第一个异常立即抛给调用方,其他任务继续运行(不会被取消) | 简单的并发 |
gather(*coros, return_exceptions=True) |
异常作为结果返回,不抛出 | 允许部分失败 |
async with asyncio.TaskGroup() as tg:(3.11+) |
取消其余任务,异常合并为 ExceptionGroup 抛出 |
要么全部成功、要么全部取消 |
限制并发:asyncio.Semaphore(n),async with sem: 包住真正的调用。没有限制时,一次性并发 1000 个请求会触发下游限流、耗尽连接池。(对照 Go:带缓冲的 channel 当信号量,或 errgroup.SetLimit。)
超时
async with asyncio.timeout(秒):(3.11+)或await asyncio.wait_for(coro, 秒);超时抛TimeoutError,内部的任务会被取消。- 分清单次调用超时和整体截止时间(例如接口必须 10 秒内返回,内部每次模型调用最多 5 秒)。
取消
task.cancel()会在任务下一个await点抛出CancelledError。- 捕获
CancelledError做清理后必须重新抛出,否则取消被吞掉,调用方以为任务还在正常运行。 except Exception不会捕获CancelledError(它继承自BaseException),这是有意设计的。
阻塞调用:必须用同步库(某些 SDK、文件解析、CPU 计算)时,await asyncio.to_thread(func, 参数) 放到线程池执行,不阻塞事件循环。CPU 密集且耗时长的用进程池。
背压:生产者比消费者快时,用有界的 asyncio.Queue(maxsize=n),队列满时 put 会等待。
案例
批量为 2000 篇文档生成摘要:用 Semaphore 限制最多 8 个并发请求(符合模型服务商的速率限制);每次调用 30 秒超时;限流错误(429)指数退避重试;单篇失败记录下来,最后汇总重跑,而不是让整个批次失败。
代码示例
import asyncio
async def fetch(i: int) -> int:
await asyncio.sleep(0.05 * (i % 3))
if i == 4:
raise ValueError(f"任务 {i} 失败")
return i * 10
async def main() -> None:
# 1. gather + return_exceptions:部分失败也拿到所有结果
results = await asyncio.gather(*(fetch(i) for i in range(6)), return_exceptions=True)
print([r if not isinstance(r, Exception) else f"错误:{r}" for r in results])
# 2. TaskGroup:一个失败,其余被取消,异常以 ExceptionGroup 抛出
try:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(fetch(i)) for i in range(6)]
except* ValueError as eg: # except* 处理 ExceptionGroup(3.11+)
print("TaskGroup 失败:", [str(e) for e in eg.exceptions])
print("被取消的任务数:", sum(t.cancelled() for t in tasks))
# 3. Semaphore 限制并发
sem = asyncio.Semaphore(2)
running = 0
async def limited(i: int) -> None:
nonlocal running
async with sem:
running += 1
assert running <= 2
await asyncio.sleep(0.01)
running -= 1
await asyncio.gather(*(limited(i) for i in range(10)))
print("Semaphore:同时运行的任务从未超过 2 个")
# 4. 超时
try:
async with asyncio.timeout(0.1):
await asyncio.sleep(1)
except TimeoutError:
print("超时")
# 5. 取消:清理后重新抛出
async def worker() -> None:
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
print("收到取消,释放资源")
raise # 必须重新抛出
t = asyncio.create_task(worker())
await asyncio.sleep(0.01)
t.cancel()
try:
await t
except asyncio.CancelledError:
print("任务已取消:", t.cancelled())
# 6. 同步阻塞函数放到线程里执行
def parse_pdf_sync(path: str) -> str:
import time
time.sleep(0.1) # 模拟同步库的阻塞调用
return f"{path} 的文本"
print(await asyncio.to_thread(parse_pdf_sync, "manual.pdf"))
asyncio.run(main())
把这些组合起来(并发上限 + 单次超时 + 重试 + 部分失败 + 整体截止时间)的完整实现和测试见 examples/fanout。
易错点
- 用
gather以为失败时会取消其他任务:不会,其他任务继续跑(可能继续消耗模型额度)。需要“一个失败全部取消”用 TaskGroup。 - 吞掉
CancelledError:任务无法被取消,服务关闭时卡住。 - 没有并发上限:一次性创建成千上万个请求,下游限流、连接池耗尽、内存上涨。
- 只设整体超时不设单次超时(或反过来):一个慢请求占满全部时间,或者总时长失控。
- 重试不区分错误类型:参数错误(400)重试多少次都一样失败,只重试超时、429、5xx。
- 在
to_thread里跑 CPU 密集任务:受 GIL 限制,没有并行效果,用进程池。
面试常问
见 asyncio 面试题:gather 和 TaskGroup 的区别、怎么限制并发、超时和取消如何实现、怎么在异步代码中调用同步库。