「Python 进阶之路」系列 Day20
写在前面
Day19 用 asyncio.gather 让两个协程并发跑了起来,但没细说 gather 背后到底发生了什么。今天把 Task、gather 的工作原理讲透,再补上实战中绕不开的两个问题:并发数量怎么控制(不能无限制地同时发起几千个请求),以及某个任务出错时该怎么处理。
一、是什么: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 确实把并发数量卡死在了设定的上限。
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 三种并发方案放在一起对比,用一张图说清楚到底该怎么选。