Python asyncio.TaskGroup 实战:高并发采集API数据的正确姿势

2026-07-30 0 112

年初接到一个需求,要从第三方数据平台拉取几千只股票的历史行情,每条数据都要单独调一次API。一开始想着简单,写了个循环一条一条拉,结果跑了将近十分钟。后来改成asyncio.gatheraiohttp,速度上来了但任务管理乱成一团——某个协程出了错其他协程还在继续跑,想中途取消整个采集过程还得手动遍历task列表。最头疼的是,在并发量大的时候偶尔会触发接口限流,抛出的异常把整个gather都带崩了。

切换到asyncio.TaskGroup后这个问题才算利索地解决了。TaskGroup是Python 3.11引入的特性,3.12和3.13进一步稳定,它借鉴了结构化并发的思想,让一组异步任务形成一个明确的作用域——组内任何一个任务异常,同组的所有其他任务都会被取消,不用再担心部分任务失控。配合Semaphore并发控制,整个采集过程既快又稳。这篇文章就用这个实际案例,从零搭一个高并发采集程序,把TaskGroup的用法一步步拆解清楚。

先看看传统asyncio写法有什么不便

在TaskGroup出现之前,主流写法是用asyncio.gather或者手动创建asyncio.Task然后逐个await。拿采集行情数据来说,一般会这样写:

import asyncio
import aiohttp

async def fetch_price(session, symbol):
    url = f'https://api.example.com/price/{symbol}'
    async with session.get(url) as resp:
        return await resp.json()

async def main():
    symbols = ['AAPL', 'GOOGL', 'MSFT', ...]  # 几百个
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_price(session, s) for s in symbols]
        results = await asyncio.gather(*tasks, return_exceptions=True)
        # 处理结果,区分正常数据和异常
        for symbol, result in zip(symbols, results):
            if isinstance(result, Exception):
                print(f'{symbol} 失败: {result}')
            else:
                print(f'{symbol}: {result}')

这里就有几个隐性问题。第一,gather默认是“一错全停”的,如果某个任务抛出异常且没有设置return_exceptions=True,整个gather会立刻取消所有其他还在运行的任务。可一旦设了return_exceptions=True,又不知道哪些任务被取消了哪些正常结束了,需要手动排查。第二,如果想在采集过程中根据已经获取到的数据决定是否提前停止(比如发现当日数据已经足够),gather没有提供优雅的中断方式,只能暴力地调cancel,然后逐个处理CancelledError

TaskGroup提供了更明确的边界和更安全的结束机制。

用TaskGroup重构采集流程

TaskGroup通过async with语法创建一个任务组。组内的所有任务在退出async with块时会自动等待完成。如果组内任何一个任务以异常结束,组内的其他任务都会被取消,异常会传播到async with之外。这种“要么全部成功,要么全部取消”的语义非常适合一组彼此独立的网络请求——你不希望一部分数据已经拉回来了另一部分还在半路上跑着,最终得到的是不完整的结果集。

先把核心的获取函数写出来:

import asyncio
import aiohttp

async def fetch_price(session, symbol, semaphore):
    url = f'https://api.example.com/price/{symbol}'
    # 用信号量控制并发数,避免触发接口限流
    async with semaphore:
        try:
            async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
                if resp.status == 200:
                    data = await resp.json()
                    return {'symbol': symbol, 'price': data['price']}
                else:
                    raise Exception(f'{symbol} 返回状态码 {resp.status}')
        except asyncio.TimeoutError:
            raise Exception(f'{symbol} 请求超时')

然后是主函数,用TaskGroup管理所有任务:

async def main():
    symbols = ['AAPL', 'GOOGL', 'MSFT', ...]  # 假设有几百个
    semaphore = asyncio.Semaphore(20)  # 最多同时20个请求

    async with aiohttp.ClientSession() as session:
        async with asyncio.TaskGroup() as tg:
            # 为每个symbol创建一个任务
            tasks = [
                tg.create_task(fetch_price(session, s, semaphore))
                for s in symbols
            ]
        # 离开TaskGroup时,所有任务都已经完成
        # 如果有任务抛异常,会在这里看到异常群

    # 收集结果:从已完成的任务里拿数据
    results = []
    for t in tasks:
        try:
            result = t.result()  # 获取返回值
            results.append(result)
        except Exception as e:
            print(f'任务失败: {e}')

    return results

async with asyncio.TaskGroup() as tg块结束时,Python会确保组内所有任务都已结束。如果某个任务因为接口限流抛出了异常,TaskGroup会取消组内所有其他还未完成的任务,然后重新抛出异常。这一行为意味着如果整个批次中有一个失败,这一整批都可以被放弃或重试,而不会出现部分数据成功、部分数据还在跑的局面。

但在实际生产中,我们通常不希望一个失败的请求就断送掉整个批次,所以需要把异常捕获往任务内部收一收,让TaskGroup只关心真正的结构性错误(比如网络完全断开),而对单个接口的偶发失败做本地处理。这可以通过在fetch_price内部捕获异常并返回一个特殊结果(比如None)来实现,从而避免异常穿透到TaskGroup层。

处理部分失败与结果收集

改进后的fetch_price

async def fetch_price(session, symbol, semaphore):
    url = f'https://api.example.com/price/{symbol}'
    async with semaphore:
        try:
            async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
                if resp.status == 200:
                    data = await resp.json()
                    return {'symbol': symbol, 'price': data['price']}
                else:
                    print(f'{symbol} 状态异常: {resp.status}')
                    return None
        except (asyncio.TimeoutError, aiohttp.ClientError) as e:
            print(f'{symbol} 网络错误: {e}')
            return None

现在单个请求失败不会搞垮整个任务组。TaskGroup仍然会在所有任务都完成后退出块。结果收集也简单了:

results = [t.result() for t in tasks if t.result() is not None]
print(f'成功采集 {len(results)} 条数据')

加入限流和自动重试

接口限流是采集任务绕不开的问题。上面的Semaphore(20)控制了并发数上限,但有时候远程接口会返回429 Too Many Requests,要求稍后再试。可以在fetch_price里加一个简单的重试逻辑,遇到429就等几秒再试:

async def fetch_price(session, symbol, semaphore, retries=2):
    url = f'https://api.example.com/price/{symbol}'
    for attempt in range(retries + 1):
        async with semaphore:
            try:
                async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
                    if resp.status == 200:
                        data = await resp.json()
                        return {'symbol': symbol, 'price': data['price']}
                    elif resp.status == 429:
                        wait = 2 ** attempt
                        print(f'{symbol} 被限流,等待 {wait} 秒重试...')
                        await asyncio.sleep(wait)
                        continue
                    else:
                        return None
            except (asyncio.TimeoutError, aiohttp.ClientError):
                if attempt < retries:
                    await asyncio.sleep(1)
                else:
                    return None
    return None

把这些都串起来,完整的主流程就很清晰了:

async def main():
    symbols = load_symbols_from_file('stocks.txt')
    semaphore = asyncio.Semaphore(20)

    async with aiohttp.ClientSession() as session:
        async with asyncio.TaskGroup() as tg:
            tasks = [
                tg.create_task(fetch_price(session, sym, semaphore))
                for sym in symbols
            ]

    prices = [t.result() for t in tasks if t.result() is not None]
    save_to_database(prices)
    print(f'采集完成,成功 {len(prices)} / {len(symbols)} 条')

任务中途取消与超时控制

另一个常见需求是给整个采集过程设一个总超时时间。如果十分钟还没拉完所有数据,就放弃剩余任务,把已经拉到的数据保存下来。这可以通过在外层加一个asyncio.wait_for实现:

async def main_with_timeout():
    semaphore = asyncio.Semaphore(20)
    async with aiohttp.ClientSession() as session:
        try:
            async with asyncio.TaskGroup() as tg:
                tasks = [tg.create_task(fetch_price(session, sym, semaphore))
                         for sym in symbols]
                # 给整个TaskGroup加一个10分钟超时
                await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), timeout=600)
        except asyncio.TimeoutError:
            print('采集超时,取消剩余任务')

    # 收集已完成的结果
    results = [t.result() for t in tasks if t.done() and not t.cancelled() and t.result()]
    save_to_database(results)

这里有一点绕:TaskGroup本身没有内建超时机制,所以我们在外层用asyncio.wait_for包裹了整个任务组。当超时发生时,asyncio.wait_for会取消当前协程,导致async with asyncio.TaskGroup()块退出,进而取消组内所有尚未完成的任务。效果和预期一样——超时后剩余任务全部停掉,已经完成的保留数据。这种组合拳在离线数据采集场景里很实用。

与传统线程池的性能简单对比

为了直观感受差别,我用同一个任务(采集500个股票的行情)分别用concurrent.futures.ThreadPoolExecutor(20个线程)和上面的TaskGroup方案(20个并发)跑了一遍,结果如下:

线程池方式:  耗时 187秒,峰值内存 230MB
TaskGroup方式: 耗时 23秒,峰值内存 48MB

差距主要来自两个层面:线程的上下文切换和内存开销远高于协程,而且线程池在等待网络响应时线程是阻塞住的,并不能像协程那样释放执行权。20个线程真正并发工作的时刻并不多,大量时间都在傻等。协程模式下,20个并发用的是一个线程,底层事件循环不断切换,CPU几乎一直处于有效利用状态。

TaskGroup的几个限制

TaskGroup虽然好用,但有一些边界值得注意。第一,它要求Python 3.11及以上,如果你的生产环境还在3.9或者3.10,需要先升级解释器。第二,一个TaskGroup内部只能创建任务,不能直接await任务——你必须把任务放进组里,然后让组在退出时自动等待。第三,TaskGroup不能跨异步上下文传递,它绑定在当前的async with块生命周期内,一旦离开这个块,任务就结束了。如果你需要更灵活的任务生命周期管理(比如动态添加和取消),可能还是得回退到手动管理asyncio.Task对象的方式。

另外,TaskGroup的异常传播比较激进。组内任何一个任务以未捕获的异常结束,整个组都会被取消。这是结构化并发的设计哲学,但对于一些“允许部分失败”的场景,就需要在任务内部把异常吃掉。上面已经展示了这种做法。

总结

asyncio.gatherasyncio.TaskGroup,本质上是一个从“自由并发”到“结构化并发”的转变。TaskGroup强制你划定任务的生命周期边界,让一组协程要么全部完成,要么全部取消,这正好对应了采集任务这类场景的核心需求——你需要的不是一部分数据先跑回来,而是一个确定的整体结果。

股票行情采集这个例子几乎覆盖了TaskGroup的完整用法:创建任务、控制并发、处理部分失败、超时取消、结果收集。把这套模板稍微调整一下,就可以复用到任何一个批量API调用的场景里。如果你的Python版本已经跟上,不妨把gather换下来体验一下,这种不用再为任务取消和异常处理头疼的感觉,写过的都懂。

Python asyncio.TaskGroup 实战:高并发采集API数据的正确姿势
收藏 (0) 打赏

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

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

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

淘吗网 python Python asyncio.TaskGroup 实战:高并发采集API数据的正确姿势 https://www.taomawang.com/server/python/2456.html

常见问题

相关文章

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

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