asyncio 异步
asyncio 的基本用法与常见问题。
asyncio 通过事件循环调度任务,让多个 I/O 操作的等待时间重叠。
运行环境:Python 3.13,默认启用 GIL 的 CPython。
1. 同步与异步
这里比较同步阻塞调用与 asyncio 异步调用:
| 对比项 | 同步调用 | asyncio 异步调用 |
|---|---|---|
| 等待 I/O | 调用线程等待操作完成 | 当前任务挂起,事件循环可以运行其他任务 |
| 取得结果 | 函数调用结束后返回 | 用 await 等待结果 |
| 多项操作并发 | 通常借助线程池等机制 | 把多个协程调度为 Task |
| 使用条件 | 库提供同步接口 | 库提供异步接口,调用链能配合 await |
| 编写与排查 | 调用顺序直观 | 需要处理任务之间的等待、取消和共享资源 |
优点:多个 I/O 操作可以重叠等待,一个线程就能管理多个任务,减少为每个等待中的操作分配线程的开销。
限制:阻塞调用或长时间计算会占住事件循环线程,影响其他任务;依赖的库也需要支持异步接口。纯 Python 重计算不会因此自动使用多个 CPU 核心。
异步不等于并发。逐个 await 协程仍可按顺序执行;任务少、等待短或前后依赖紧密时,改成异步未必有收益。
2. 协程与事件循环
2.1 协程函数与协程对象
用 async def 定义协程函数。调用它时,先创建一个协程对象;此时函数体还没有开始执行。
示例:
| |
| |
| 名称 | 类型 | 说明 |
|---|---|---|
fetch_price | 协程函数 | 定义查询逻辑 |
query | 协程对象 | 由 fetch_price(101) 创建,此时查询尚未执行 |
result | 字典 | await query 完成后取得的查询结果 |
只创建协程,却没有等待它或交给任务调度,函数体就不会执行。协程对象被回收时会出现以下警告,这里省略文件路径和行号:
| |
2.2 await 与事件循环
可等待对象是能放在 await 后面的对象,常见的有协程对象、Task 和 asyncio.Future。普通字典、整数不能直接 await。
await query 直接等待协程。也可以先用 create_task() 把协程包装为 Task(任务对象),交给事件循环调度。把上例的 main() 换成:
| |
| |
create_task() 返回 Task,本身不等待任务完成。await task 在任务尚未结束时挂起当前任务,完成后取得返回值。
同一事件循环线程中,同一时刻只执行一个任务的代码。 一个任务挂起后,事件循环可以运行其他就绪任务;没有任务就绪时,可以等待 I/O 或定时器通知。
await 不保证切换任务。 如果协程能直接返回,或所等待的 Task 已经完成,当前任务可以接着执行。长时间计算即使用 async def 定义,也不会自动让出执行机会。
2.3 创建与运行事件循环
事件循环负责调度任务、执行回调,以及等待 I/O 和定时器通知。脚本通常通过 asyncio.run(main()) 启动:它创建循环,运行 main(),结束时清理资源并关闭循环。
| 接口 | 作用 |
|---|---|
asyncio.run(main()) | 创建、运行并关闭事件循环,返回 main() 的结果 |
asyncio.get_running_loop() | 获取当前线程正在运行的循环;没有时抛出 RuntimeError |
asyncio.get_event_loop() | 获取当前线程的当前循环;在协程内返回正在运行的循环 |
asyncio.new_event_loop() | 创建循环对象,不启动它 |
asyncio.set_event_loop(loop) | 将循环设为当前线程的当前循环,不启动它 |
协程内部需要获取循环时,优先用 get_running_loop()。
需要手动管理时,可以把创建、运行和关闭分开:
| |
| |
run_until_complete() 驱动循环,直到传入的协程结束。close() 关闭循环,但不会替你等待未结束的任务。普通脚本使用 asyncio.run() 即可,手动管理还需要自行处理剩余任务、异步生成器和线程池的清理。
Jupyter 等已有事件循环的环境通常直接写
await main()。同一线程已有事件循环在运行时,不能再调用asyncio.run()。
3. 顺序执行与并发执行
3.1 create_task()
sleep() 与 create_task() 作用不同:
await asyncio.sleep(delay)暂停当前任务。create_task()创建并调度一个任务。创建 Task 不会让其中的阻塞代码变成异步代码。
| |
使用默认任务调度方式时,示例输出如下:
| |
顺序写法先等 101 返回,再调用 102。并发写法通过 create_task() 创建两个 Task(任务对象),把它们交给事件循环调度,然后等待结果。
并发部分的执行过程如下:
| 步骤 | 执行过程 | 等待中的查询 |
|---|---|---|
| 创建任务 | main() 创建两个 Task,随后等待 101 | 两个查询尚未开始 |
| 启动查询 | 101 打印开始,进入等待;102 打印开始,进入等待 | 101、102 |
| 102 的等待结束 | 恢复 102,打印完成并返回价格 | 101 |
| 101 的等待结束 | 恢复 101,打印完成并返回价格 | 无 |
| 取齐结果 | main() 拿到 101 的价格,再取得已完成的 102 的价格 | 无 |
按顺序等待 Task,不会把已经调度的任务变成串行。 await first_task 暂停的是 main(),102 仍然可以继续执行。
3.2 任务结果
await task:等待任务,成功时返回结果,失败时抛出异常。task.result():直接读取结果,不会等待;任务尚未完成时会报错。
保留 create_task() 返回的 Task,方便等待结果、处理异常;一组任务可以交给 TaskGroup 管理。
4. TaskGroup 与结果收集
4.1 批量任务
TaskGroup 创建并管理一组任务,退出时等待组内任务结束。
async with 是异步上下文管理语法,允许在进入、退出时执行异步操作。任务组的等待发生在退出时:
| |
| |
所有任务成功、正常离开 async with 后,才读取 task.result()。
4.2 异常处理
任务组中的任务抛出非 CancelledError 异常时,会触发以下处理:
- 请求取消组内其余未完成任务。
- 等待这些任务清理并退出。
- 将普通异常合并成
ExceptionGroup抛出。
下面让 102 查询失败,101 仍处于等待中:
| |
| |
102 抛出异常后先执行 finally。101 随后被取消,也执行 finally,但没有返回价格。两条“结束查询”都只是清理阶段的输出,不表示查询成功。
except* ConnectionError 从异常组中选出匹配的异常,组成异常组交给 errors。本例用 errors.exceptions 取得其中的连接错误并打印;如果组内还有未被处理的其他异常,它们会继续向外抛出。
4.3 create_task、TaskGroup 与 gather
asyncio.create_task()创建单个任务;TaskGroup管理一组任务的等待和退出;gather()汇总多个操作的结果;group.create_task()会把新任务登记到任务组,普通的asyncio.create_task()不会。
| 接口 | 结果与等待 | 普通异常的处理 |
|---|---|---|
asyncio.create_task(coro) | 返回单个 Task,需要自行等待或读取结果 | 等待或读取结果时抛出;不自动取消其他独立任务 |
group.create_task(coro) | 返回单个 Task,退出 TaskGroup 时统一等待 | 任务组取消其余任务并等待退出,再报告异常组 |
asyncio.gather(...) | 等待后得到列表,顺序对应传入顺序 | 默认立即抛出第一个异常,不自动取消其他任务 |
gather() 可以接收已经创建的 Task;传入协程时,它会自动把协程调度为 Task。
默认的 gather() 不会因为一项失败就取消其余任务。但如果异常使 main() 退出,asyncio.run() 会清理剩余任务;这是运行入口的收尾行为。
4.4 按完成顺序取结果
asyncio.as_completed() 适合每完成一项就保存结果或更新进度。
Python 3.13 起,可以用 async for 遍历它。传入 Task 时,异步迭代返回已完成的原 Task,便于关联输入。async for 可以等待下一项,但不会让循环体自动并发。
5. 超时与取消
5.1 单次调用超时
asyncio.timeout() 限制一段异步操作的等待时间。示例设置 0.02 秒超时,查询需要等待 1 秒:
| |
| |
超时后,timeout() 请求取消当前任务。fetch_price() 收到取消异常,执行 finally 后退出。
离开超时上下文时,这次取消会被转换成 TimeoutError。因此,except TimeoutError 要写在 async with 外侧。
5.2 整批任务超时
把任务组放在同一个 timeout() 内,可以让整批查询共用 2 秒:
| |
| |
整批超时从进入外层 timeout() 开始计算。给每个查询分别设置 2 秒,则是各自计时,不能保证整批在 2 秒内结束。timeout() 从 Python 3.11 起提供。
5.3 取消与清理
task.cancel() 发出取消请求。任务在后续可处理取消的位置收到 CancelledError,不会从任意一行被强行终止。
关闭文件、释放连接等清理操作放在 finally 中,或交给资源的上下文管理器处理。这样正常结束、异常和取消都能执行清理。
捕获 CancelledError 后,通常应在清理完毕后重新 raise。 任务组和超时管理依赖取消来结束工作,吞掉它可能让外层继续等待。
标准库中的定义如下:
| |
它直接继承 BaseException,因此 except Exception 不会捕获它;需要单独处理时写 except asyncio.CancelledError。
超时不保证立即返回。 阻塞调用会使事件循环无法及时处理取消,清理操作本身也可能耗时。取消也不会撤销已经完成的远端写入。
单个可等待操作也可以用 asyncio.wait_for(awaitable, timeout=...) 设置超时。超时后,它会请求取消并等待取消处理,所以实际耗时也可能超过指定值。
6. 并发控制
6.1 Semaphore
需要给一组异步操作设置并发上限时,可以使用标准库的 asyncio.Semaphore。它适合限制同时占用某项资源的任务数,不是每段异步代码都需要加。
Semaphore 保存可用名额数。进入 async with semaphore 时取得一个名额,退出时归还;名额用完,后来的任务就等待。
| |
| |
六个任务共用同一个 semaphore,最多两个进入查询区间,其余任务等待名额。每个任务各建一个信号量,无法限制整批并发。
工程中也可能由连接池、任务队列或框架控制容量。已有组件的限制能覆盖同一资源时,可以直接使用其配置;需要跨多个操作限制业务并发时,再共享一个信号量。这个上限只对共享它的任务生效,多个进程或服务实例之间不会自动合并计数。
6.2 并发数、请求速率与任务数量
| 限制项 | 作用 | 常用方法 |
|---|---|---|
| 同时进行的请求数 | 下游同时承受的工作量 | 共享的 Semaphore、客户端连接池 |
| 每秒请求数 | 单位时间发起多少请求 | 速率限制策略 |
| 已创建、尚未结束的任务数 | 内存占用和排队规模 | 分批创建任务,或使用有界队列和固定数量的处理任务 |
并发上限不等于每秒请求数。 请求返回越快,名额释放越快,后续请求就能更早开始。连接数也不等于请求并发数:HTTP/2 可以在一个连接上承载多个请求。
信号量不限制 Task 的创建数量。 即使同时只允许 10 个任务查询,一次创建的大量 Task 仍会占用内存、等待名额。
数据量大时,可以分批创建任务,处理完一批再取下一批。这样会等该批最慢的任务结束;需要持续补充任务时,可以使用有界队列和固定数量的处理任务。
多个入口各自创建 Semaphore(10),合计并发仍可能超过 10。要限制这些入口的总并发,需要让它们在同一事件循环内共享一个信号量。
7. 调用同步函数
如果现有 SDK 只提供同步函数,可以用 asyncio.to_thread() 把调用交给线程执行,避免它占住事件循环线程。
| |
| |
await 取得结果,return 将结果传给调用方。少了 return,main() 默认返回 None,asyncio.run() 得到的也就是 None。
传入函数和参数,不要先调用函数。 to_thread(fetch_price_sync, item) 把调用交给线程;写成 to_thread(fetch_price_sync(item)) 则会先在当前线程执行同步函数,仍然阻塞事件循环。
to_thread() 返回协程,需要 await 或交给 Task 调度。线程执行同步函数时,事件循环线程可以继续处理其他任务。若多个线程共用 SDK 客户端,还要确认它是否支持线程安全访问。
取消等待,不能强行停止已经运行的线程函数。 SDK 本身仍需要超时设置。asyncio.run() 退出时也要处理线程池关闭,取消等待不等于底层调用已经结束。
默认启用 GIL 的 CPython 中,to_thread() 通常用于阻塞 I/O。纯 Python 重计算通常考虑进程池;会释放 GIL 的扩展计算则按具体库判断。
评论
使用 GitHub 登录后参与讨论,评论将公开保存在 GitHub。
站长管理删除会同步到 GitHub,无法撤销。
阅读到这里时加载评论。