asyncio TaskGroup 实战:批量请求的限流、重试与取消一次写对

2026-11-01 0 609

先说结论:如果你现在还在用 asyncio.gather 拼那种几十上百个 URL 的批量请求,建议花十分钟把 TaskGroup 换过来。它的语义更接近“要么全成,要么全收”,配合限流和重试,代码意外地短。

下面这份代码是我最近写一个数据采集小工具时用到的完整实现,可以直接跑,也可以拿去改。依赖只有一个 httpx,Python 版本要求 3.11 以上(因为用到了 asyncio.timeout)。

先聊聊 gather 那点别扭的地方

asyncio.gather 最让人难受的,是它在出错之后的收尾方式。默认 return_exceptions=False,第一个任务抛异常,gather 立刻把这个异常甩给调用方,可其余任务并不会被取消——它们继续在后台跑,占着连接、占着内存,直到自己结束。你在日志里看到一条报错,然后进程退出时一堆 “Task was destroyed but it is pending” 的警告,基本就是这个原因。

有人会说,加个 return_exceptions=True 不就完了?那样确实不会中断,但你又得手动去结果列表里挑哪个是异常、哪个是数据,而且取消信号也传不进去,Ctrl+C 的时候照样一地鸡毛。

TaskGroup 把这件事收回来了:组内任一子任务失败,其余子任务会被取消并等待收尾,然后以一个 ExceptionGroup 的形式把错误抛出来。这就是所谓的结构化并发——任务的生死由它所处的作用域决定,作用域没退出,就不会有孤儿任务到处乱跑。

完整实现

先看代码,后面再逐段拆解。保存成 crawl.py 就能运行。

import asyncio
import random
import time
from dataclasses import dataclass

import httpx

DEFAULT_HEADERS = {
    "User-Agent": "taskgroup-demo/1.0",
    "Accept-Encoding": "gzip, deflate",
}


@dataclass
class FetchResult:
    url: str
    status: int
    attempts: int
    size: int
    elapsed: float


async def _backoff(attempt: int, base_delay: float) -> None:
    """指数退避 + 抖动,避免重试撞车。"""
    delay = base_delay * (2 ** (attempt - 1))
    await asyncio.sleep(delay + random.uniform(0, delay * 0.3))


async def fetch_with_retry(
    client: httpx.AsyncClient,
    url: str,
    sem: asyncio.Semaphore,
    *,
    timeout: float = 5.0,
    retries: int = 3,
    base_delay: float = 0.3,
) -> FetchResult:
    if retries < 1:
        raise ValueError("retries 至少为 1")

    started = time.perf_counter()

    # 名额在整条重试链路外获取,重试期间也占着并发位
    async with sem:
        for attempt in range(1, retries + 1):
            try:
                resp = await client.get(url, timeout=timeout)
                resp.raise_for_status()
                return FetchResult(
                    url=url,
                    status=resp.status_code,
                    attempts=attempt,
                    size=len(resp.content),
                    elapsed=time.perf_counter() - started,
                )
            except httpx.HTTPStatusError as exc:
                # 4xx 是客户端自己的问题,重试没有意义
                if exc.response.status_code < 500 or attempt == retries:
                    raise
                await _backoff(attempt, base_delay)
            except (httpx.TimeoutException, httpx.TransportError):
                if attempt == retries:
                    raise
                await _backoff(attempt, base_delay)

    raise RuntimeError("unreachable")


async def crawl(
    urls: list[str],
    *,
    concurrency: int = 8,
    timeout: float = 5.0,
    retries: int = 3,
    total_timeout: float = 60.0,
) -> tuple[list[FetchResult], list[tuple[str, str]]]:
    sem = asyncio.Semaphore(concurrency)
    results: list[FetchResult] = []
    errors: list[tuple[str, str]] = []

    # 连接池上限跟信号量对齐,否则信号量形同虚设
    limits = httpx.Limits(
        max_connections=concurrency,
        max_keepalive_connections=concurrency,
    )

    async def worker(url: str) -> None:
        try:
            item = await fetch_with_retry(
                client, url, sem, timeout=timeout, retries=retries
            )
            results.append(item)
        except Exception as exc:
            # CancelledError 继承自 BaseException,不会被这里捕获
            errors.append((url, f"{type(exc).__name__}: {exc}"))

    async with httpx.AsyncClient(headers=DEFAULT_HEADERS, limits=limits) as client:
        try:
            async with asyncio.timeout(total_timeout):
                async with asyncio.TaskGroup() as tg:
                    for url in urls:
                        tg.create_task(worker(url))
        except TimeoutError:
            errors.append(("*", f"整体超时,{total_timeout}s 内未跑完"))

    return results, errors


async def main() -> None:
    urls = [f"https://httpbin.org/status/200?n={i}" for i in range(40)]

    t0 = time.perf_counter()
    results, errors = await crawl(urls, concurrency=10, total_timeout=30)
    cost = time.perf_counter() - t0

    print(f"共 {len(urls)} 条,成功 {len(results)},失败 {len(errors)},耗时 {cost:.2f}s")

    retried = [r for r in results if r.attempts > 1]
    print(f"其中重试过的有 {len(retried)} 条")

    for url, reason in errors[:5]:
        print("  !", url, reason)


if __name__ == "__main__":
    asyncio.run(main())

逐段说明

信号量包住整条重试链路

注意 async with sem 的位置:它在 for attempt 循环外面。这一点很关键。如果只把单次 client.get 包在信号量里,那么一旦进入退避等待,名额就被释放了,重试请求会和别的任务挤在一起,实际并发数就不可控了。

代价是:退避期间名额被白白占着。流量小的场景无所谓,如果你要处理几千条 URL 且失败率高,可以改成每个 attempt 单独获取信号量,用吞吐换稳定性。

只对值得重试的错误重试

代码里分了两支。HTTP 状态码错误只重试 5xx,4xx 直接抛出——404、403 这类错误你再重试一百次也是同样的结果,纯属浪费对端资源,还可能触发风控。传输层错误(连接被重置、DNS 抖动、读取超时)则一律重试。

退避用的是 base_delay * 2 ** (attempt - 1),再叠一个 0 到 30% 的随机抖动。抖动这步不是可选项,几十个任务同时失败同时重试,很容易把对端压出 429。

worker 内部把异常吃掉

这是最容易写错的地方。如果把 fetch_with_retry 直接丢进 tg.create_task(),那么任何一条 URL 最终失败,整个 TaskGroup 就会把剩下的任务全取消——你在跑 500 条数据,其中 1 条 404,剩下 300 条全废了。

所以 worker 里套了一层 try/except,把失败降级成错误记录。TaskGroup 的 fail-fast 语义这时候变成了“内部兜底之后,整体仍然完整跑完”。反过来说,如果某类错误你确实希望中断整批,那就别在 worker 里接,让它往上抛。

两层超时各管各的

代码里有两层超时。client.get(url, timeout=timeout) 这一层是单次请求的,5 秒没响应就换一次重试;外面的 asyncio.timeout(total_timeout) 则是整批任务的预算,30 秒还没跑完就直接放弃,剩下的全部取消。

如果没有外层这个预算,一批 2000 条的 URL 在对方持续慢响应的场景下,你能跑十几分钟都出不来。有了它,最坏情况是有界的。

四个容易踩的坑

  1. 不要提前创建、重复 await。tg.create_task() 直接接收协程对象就行,不需要先 asyncio.ensure_future 包一层,也不要在外面再 await 一次。
  2. 连接池和信号量必须对齐。httpx.Limits(max_connections=...) 如果比信号量小,信号量根本不起作用——请求会卡在连接池的等待队列里,看起来像“卡死”。反过来设得太大,又会绕过限流把对端打爆。
  3. 取消信号要能穿透。asyncio.CancelledError 在 3.8 之后就继承自 BaseException 了,所以 except Exception 不会误吞取消。但如果你写了 except BaseException 或者裸 except:,Ctrl+C 就废了,进程得等所有任务自己跑完才能停。
  4. 别和 gather 混着写。在一个 TaskGroup 里面再起 gather,取消的传播路径会变得很难推理。要用就统一用 TaskGroup,嵌套也是可以的,层级清晰。

怎么验证效果

跑完看三个数就够了:总耗时、重试过的条数、失败清单。

假设平均单条延迟 0.3 秒,40 条串行大约 12 秒,并发 10 之后理论值在 1.5 秒上下,实际会略高一些,因为连接建立、TLS 握手和调度都有开销。如果实测明显偏离,通常就是连接池或者信号量的配置没对齐。

重试条数则反映对端的稳定性。如果这个数字长期偏高,说明你该降低并发,而不是加大重试次数——重试是兜底手段,不是性能优化手段。

结尾

这套结构我已经在几个采集脚本里跑了大半年,改动最少的地方恰恰是并发控制那部分——TaskGroup 把任务的边界画清楚了,后面加指标、加日志、加缓存都能塞在 worker 里,不用动骨架。

如果你的项目还在 3.10 及以下,可以用 asyncio.gather(..., return_exceptions=True) 加手动取消来近似模拟,但那种写法在异常路径上永远比 TaskGroup 多几行,而且总有一天会被某个人改坏。

asyncio TaskGroup 实战:批量请求的限流、重试与取消一次写对
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 python asyncio TaskGroup 实战:批量请求的限流、重试与取消一次写对 https://www.taomawang.com/server/python/2991.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务