Files
zhuangang/topics/python/asyncio/02-concurrency-control.md
T

5.8 KiB
Raw Blame History

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
asyncio
TaskGroup
Semaphore
timeout
取消
to_thread
背压
intermediate
python.asyncio.coroutines-tasks
3
python.asyncio.scenario.concurrent-llm-calls
python.asyncio.interview
backend.go.errors-concurrency
4
5
12
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 的区别、怎么限制并发、超时和取消如何实现、怎么在异步代码中调用同步库。