create_task 和直接 await 到底有什么不一样?asyncio 的 Task、gather 与并发数量控制

「Python 进阶之路」系列 Day20

写在前面

Day19 用 asyncio.gather 让两个协程并发跑了起来,但没细说 gather 背后到底发生了什么。今天把 Taskgather 的工作原理讲透,再补上实战中绕不开的两个问题:并发数量怎么控制(不能无限制地同时发起几千个请求),以及某个任务出错时该怎么处理。


一、是什么:Task 与裸协程的区别

Day19 讲了协程调用不会立即执行,需要驱动才行。asyncio.create_task() 就是"驱动"的一种方式:把一个协程包装成 Task 对象,立即提交给 event loop 去调度执行 ,不需要等 await 才开始跑。

python 复制代码
import asyncio, time

async def fake_io(name, seconds):
    print(f"{name} 开始")
    await asyncio.sleep(seconds)
    print(f"{name} 完成")
    return name

async def demo():
    t1 = asyncio.create_task(fake_io("A", 1))
    t2 = asyncio.create_task(fake_io("B", 1))
    print("两个task已经创建,还没await,做点别的事...")
    await asyncio.sleep(0)   # 让出一下,两个task才有机会真正开始跑
    print(not t1.done(), not t2.done())   # True True ------ 已经在运行了
    r1 = await t1
    r2 = await t2

实测:create_task 调用之后,A 开始/B 开始 就已经打印出来了------比后面真正 await t1/await t2 还早。Task 是"已经提交调度"的协程,可以脱离 await 立即开始执行,还能随时查询它的状态(done())、结果(result())或者取消它(cancel()),这是裸协程对象做不到的。


二、为什么:create_task 与 gather 各自的价值

如果只是简单地想让多个协程并发跑起来,asyncio.gather() 已经够用了(Day19 用过)。但 create_task 提供了更细粒度的控制------先把任务都启动起来,中间去做点别的事情,最后再回来收结果,这在需要更灵活地安排执行顺序的场景很有用。

python 复制代码
async def demo_gather():
    results = await asyncio.gather(fake_io("C", 1), fake_io("D", 1))
    print(results)   # ['C', 'D']  ------ 按传入顺序返回,不是完成顺序

实测耗时约 1.00s,和手动 create_task 效果完全一致------因为 asyncio.gather() 内部会自动把传入的裸协程包装成 Task ,一起提交给 event loop 并发调度,等全部完成后按传入时的顺序(不是完成的先后顺序)把结果收集成一个列表返回。


三、怎么用

1. 并发数量控制:Semaphore

实际场景里,"并发数量不受限"往往是危险的------比如写爬虫,如果同时对一个目标网站发起几千个请求,可能直接把对方服务器打垮,或者自己的 IP 被封。用 asyncio.Semaphore 可以限制同一时刻允许运行的任务数量:

python 复制代码
async def limited_task(sem, name, running_counter, max_seen):
    async with sem:
        running_counter[0] += 1
        max_seen[0] = max(max_seen[0], running_counter[0])
        await asyncio.sleep(0.05)
        running_counter[0] -= 1

async def demo_semaphore():
    sem = asyncio.Semaphore(5)   # 最多同时5个任务能进入
    tasks = [limited_task(sem, i, [0], [0]) for i in range(100)]
    await asyncio.gather(*tasks)

实测:100 个任务,限制并发为 5,实际观察到的最大同时运行数精确等于 5 ,验证了 Semaphore 确实把并发数量卡死在了设定的上限。

flowchart LR A[100个任务排队] --> B[Semaphore限制<br/>最多5个同时通过] B --> C[同时运行的任务] C --> D[执行完释放许可] D --> B

2. gather 的异常处理:默认行为与 return_exceptions

默认情况下,gather 里只要有一个任务抛出异常,就会直接把这个异常向上传播出去

python 复制代码
async def maybe_fail(name, should_fail):
    await asyncio.sleep(0.1)
    if should_fail:
        raise ValueError(f"{name} 出错了")
    return name

results = await asyncio.gather(
    maybe_fail("task1", False),
    maybe_fail("task2", True),
    maybe_fail("task3", False),
)
# ValueError: task2 出错了   ← 直接抛出来了,task1、task3的结果拿不到

如果希望所有任务都跑完,把异常也当成一种"结果"收集起来 ,而不是中途被打断,可以加上 return_exceptions=True

python 复制代码
results = await asyncio.gather(
    maybe_fail("task1", False),
    maybe_fail("task2", True),
    maybe_fail("task3", False),
    return_exceptions=True,
)
# ['task1', ValueError('task2 出错了'), 'task3']

这样每个位置的结果要么是正常返回值,要么是异常对象本身,需要自己遍历判断------适合"哪怕部分失败也要拿到其余结果"的场景(比如批量请求多个接口,一个挂了不影响其他的正常处理)。

3. Task.cancel():取消一个正在运行的任务

python 复制代码
async def long_task():
    try:
        print("长任务开始")
        await asyncio.sleep(5)
        print("长任务完成(不应该看到)")
    except asyncio.CancelledError:
        print("长任务被取消了!")
        raise   # 按惯例应该重新抛出

async def demo_cancel():
    task = asyncio.create_task(long_task())
    await asyncio.sleep(0.2)
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        print("外部确认: task确实被取消了")

调用 task.cancel() 会在这个任务下一次 await 的地方 抛出 asyncio.CancelledError------实测里 long_task 正卡在 await asyncio.sleep(5),取消请求发出后立刻在这里抛出了异常,打印"长任务被取消了!"。按惯例,捕获到 CancelledError 后应该做完清理工作再重新 raise,把取消信号继续传播出去,让外部的 await task 也能感知到这次取消(task.cancelled() 会返回 True)。


四、面试追问

Q1:asyncio.create_task() 和直接 await 协程的区别是什么?

create_task 会立即把协程提交给 event loop 调度,不需要 await 就已经开始执行,并且返回一个可以查询状态、取消的 Task 对象;直接 await 一个裸协程则是"挂起当前协程去等它执行完",不会有"提前启动、之后再收结果"这种灵活性。

Q2:asyncio.gather() 做了什么?

把多个协程/Task 一起提交给 event loop 并发执行,内部会自动把传入的裸协程包装成 Task;等所有任务都完成后,按传入时的顺序(不是完成的先后顺序)把各自的结果收集成一个列表统一返回。

Q3:为什么要限制并发数量,怎么做?

不受限制的并发可能会打垮目标服务器、触发对方的限流或封禁机制,或者耗尽本地的连接数/内存等资源。常见做法是用 asyncio.Semaphore(n) 包裹住需要限流的代码段(async with sem:),可以保证同一时刻最多只有 n 个任务真正在执行,其余的会排队等待许可。

Q4:gather 里某个任务抛异常会怎样?return_exceptions 参数的作用是什么?

默认情况下,只要有一个任务抛出异常,gather 就会立即向上抛出这个异常,其他任务的结果拿不到(即使它们已经执行完了)。加上 return_exceptions=True 之后,所有任务都会正常跑完,抛出异常的任务对应位置会变成那个异常对象本身,作为结果的一部分收集进返回列表,不会中断整体流程。

Q5:怎么取消一个正在运行的 Task?

调用 task.cancel(),这会让该任务在下一次遇到 await 的地方抛出 asyncio.CancelledError;任务内部可以捕获这个异常做一些清理工作,按照惯例应该在清理完之后重新 raise,把取消信号继续向外传播,外部通过 await task(会重新抛出这个异常)或 task.cancelled() 可以确认取消是否真的生效了。


下一篇预告

Day21 是模块四的收官篇------把 threading、multiprocessing、asyncio 三种并发方案放在一起对比,用一张图说清楚到底该怎么选。

相关推荐
李可以量化1 小时前
Redis 从了解到精通(四・下):分区技术原理与选型全解析
python
喜欢吃豆1 小时前
5 分钟,让网页里站着一个会说话的 3D 数字人
后端
Python私教1 小时前
创业团队做管理系统,别先堆页面:我把 14 个问题做成了需求门禁
后端·python·架构
攻城有术1 小时前
专项攻克-SpringBoot启动后 Bean的实例数量问题
java·spring boot·后端
云半S一1 小时前
Python学习总结
python·学习·学习分享
苏子寒1 小时前
Nano-VLLM全代码解析笔记(6)-embed_head和linear
pytorch·笔记·python·机器学习·ai·nlp·vllm
JimmtButler1 小时前
功能明明正常,架构为什么还是会腐化?<第一章>
后端·架构
用户077911816611 小时前
记一次 yfinance 源码调试:SOCKS5 代理下 Chart API 正常,历史数据却一直超时
后端
步行cgn1 小时前
PageHelper 的用法
java·后端