Python 异步编程
协程
协程可以看做是一种用户态的轻量级线程,允许程序在执行过程中挂起并在稍后恢复,从而实现高效的多任务切换。
通俗地说,协程就是一个可以暂停执行、之后又能从中断处恢复执行的函数对象。正如定义中提到的,它复用同一个线程:当某个协程遇到 IO 阻塞需要等待时,该协程能够通过await主动让出控制权给事件循环,让事件循环在同一个线程内调度下一个就绪的协程去执行。
下面的例子可以直观体现协程的意义:
python
"""
同步方案:每张图下载需 2 秒,3 张共需约 6 秒。
"""
import requests
def download_image(url):
print("开始下载:", url)
response = requests.get(url)
print("下载完成")
file_name = url.rsplit('_')[-1]
with open(file_name, mode='wb') as file_object:
file_object.write(response.content)
if __name__ == '__main__':
url_list = [
'https://www3.autoimg.cn/newsdfs/g26/M02/35/A9/120x90_0_autohomecar__ChsEe12AXQ6A0OH_AAFocMs8nzU621.jpg',
'https://www2.autoimg.cn/newsdfs/g30/M01/3C/E2/120x90_0_autohomecar__ChcCSV2BBICAUntfAADjJFd6800429.jpg',
'https://www3.autoimg.cn/newsdfs/g26/M0B/3C/65/120x90_0_autohomecar__ChcCP12BFCmAIO83AAGq7vKOsGY193.jpg',
]
for item in url_list:
download_image(item)
python
"""
异步方案:第一张图进入 IO 等待时切换到第二张,第二张等待时切换到第三张,
总耗时约 2 秒(3 张图的下载并发进行)。
"""
# pip3 install aiohttp
import aiohttp
import asyncio
async def fetch(session, url):
print("发送请求:", url)
response = await session.get(url, verify_ssl=False)
try:
content = await response.content.read()
file_name = url.rsplit('_')[-1]
# 建议改用异步写文件,或用 with + sync 写
with open(file_name, mode='wb') as f:
f.write(content)
finally:
response.release()
async def main():
async with aiohttp.ClientSession() as session:
url_list = [
'https://www3.autoimg.cn/newsdfs/g26/M02/35/A9/120x90_0_autohomecar__ChsEe12AXQ6A0OH_AAFocMs8nzU621.jpg',
'https://www2.autoimg.cn/newsdfs/g30/M01/3C/E2/120x90_0_autohomecar__ChcCSV2BBICAUntfAADjJFd6800429.jpg',
'https://www3.autoimg.cn/newsdfs/g26/M0B/3C/65/120x90_0_autohomecar__ChcCP12BFCmAIO83AAGq7vKOsGY193.jpg',
]
tasks = [asyncio.create_task(fetch(session, url)) for url in url_list]
await asyncio.wait(tasks)
if __name__ == '__main__':
asyncio.run(main())
async、协程函数与协程对象
async:用来声明协程函数的关键字,自 Python 3.5 引入。
协程函数 :用 async def 函数名 定义的函数。
协程对象 :假设协程函数为 coroutine,则 coroutine() 的调用结果就是一个协程对象。
需要注意:普通函数 func() 调用即执行函数体;而 coroutine() 只是创建协程对象,并不会执行其中的代码,需要 await 驱动才能真正执行。
| 操作 | 普通函数 func() |
协程函数 coroutine() |
|---|---|---|
| 调用行为 | 立即执行函数体 | 仅创建协程对象,不执行 |
| 返回值 | 函数的 return 值 | 一个 coroutine 对象 |
| 如何真正执行 | 直接调用即可 | 需要 await 驱动或放入 asyncio.run |
await
上面提到协程对象需要 await 才能真正执行。await 是 Python 3.5+ 引入的关键字,只能在 async def 函数内部使用,有两个核心作用:
-
让出控制权 :当前协程 遇到
await时立即挂起,驱动await后面的可等待对象在同一 Task 内执行;若该对象遇到 IO 阻塞,控制权才交还给事件循环,由事件循环调度其他就绪的 Task 或 IO 回调; -
获取结果:等待协程/异步操作完成后,拿到其返回值。
具体而言,await 后面跟一个可等待对象(协程对象、Future、Task)。它的作用是暂停当前协程并驱动该可等待对象执行 ;若可等待对象内部遇到 IO 阻塞,则将控制权交回事件循环,等待 IO 完成后由事件循环唤醒该 Task 继续执行。
以下为一个简单的示例
python
import asyncio
async def coroutine_demo():
print("enter")
await asyncio.sleep(2)
return "finish"
async def demo():
print("hello")
res = await coroutine_demo()
print(res)
print("bye~")
asyncio.run(demo())
在上面这个例子中,asyncio.run 创建了事件循环,并将 demo 协程对象包装成 Task 注册到事件循环中。事件循环调度 demo 协程执行,遇到 await coroutine_demo() 时,demo 挂起、保存执行状态,coroutine_demo 在同一个 Task 内被驱动执行。coroutine_demo 内部遇到 await asyncio.sleep(2) 时再次挂起,asyncio.sleep 向事件循环注册一个 2 秒后的定时回调,然后将控制权交还给事件循环。事件循环等待 2 秒后通过回调唤醒该 Task,继续执行 coroutine_demo 剩余部分,最终返回结果给 demo。输出如下:
text
hello
enter
# 等待2秒
finish
bye~
事件循环
await 能够在遇到 IO 阻塞时将控制权交还给事件循环,那么事件循环是什么?
可以把它理解为一个死循环,持续检查它维护的任务列表:
- 列表中有可执行的任务时,就执行该任务;
- 正在执行的任务进入 IO 阻塞时,该任务转入等待状态,转而执行其他可执行任务;
- 执行完毕的任务会被自动移除。
伪代码示例:
python
任务列表 = [任务1, 任务2, 任务3, ...]
while True:
可执行列表, 待处理列表, 已完成列表 = 检查任务列表中所有任务,
将其分为"可执行""待处理""已完成"
for 任务 in 可执行列表:
执行(任务)
if 任务进入IO阻塞:
将任务移入待处理列表
for 任务 in 已完成列表:
从任务列表中移除(任务)
if 任务列表为空或全部已完成:
终止循环
在 Python 3.7+ 中,创建事件循环并创建Task调度协程只需一行:
python
import asyncio
async def main():
...
# 无需手动创建 loop
asyncio.run(main())
asyncio.run 会创建事件循环,将 main 注册进去并运行。
Task 对象
Task 的作用是把协程注册到事件循环中并发执行。
前面已有 await,为什么还需要 Task?其实前面我们说 await 能够把协程注册到事件循环中是为了方便理解,实际上 await 没有注册到事件循环的能力。真正注册到事件循环的是 asyncio.run------它会创建事件循环,并把传入的协程对象包装成 Task 后注册到事件循环中。因此在这个例子中,demo 协程通过 Task 被注册到了事件循环。asyncio.sleep(2) 和 asyncio.sleep(3) 位于 demo 协程内部,它们共享 demo 所在的同一个 Task,并没有被单独创建 Task 或注册到事件循环------它们只是在该 Task 内部被 await 逐个驱动执行。
python
import asyncio
async def demo():
print("start")
await asyncio.sleep(2) # 停 2 秒
print("middle")
await asyncio.sleep(3) # 再停 3 秒
asyncio.run(demo())
但是这里 asyncio.sleep(2) 没有执行完之前,asyncio.sleep(3) 根本不会被启动------必须等前一个 await 返回后,程序才会走到下一行。所以总耗时是 2 + 3 = 5 秒,而不是并发。
用 asyncio.create_task(或 asyncio.gather)则可以把多个协程立即注册到事件循环,让它们并发推进:
python
import asyncio
async def demo_concurrent():
print("start")
t1 = asyncio.create_task(asyncio.sleep(2))
t2 = asyncio.create_task(asyncio.sleep(3))
await t1
await t2
print("done")
asyncio.run(demo_concurrent())
create_task 调用后立即把协程排入事件循环,两个 sleep 是同时推进的,总耗时约 3 秒。这就是 Task 的意义:解耦"启动"与"等待",实现并发。
| 对比 | 连续 await |
create_task + await |
|---|---|---|
| 执行顺序 | 串行,前一个完成才启动下一个 | 并发,所有任务同时推进 |
| 总耗时 | 各段时间相加 | 取最慢的那段 |
补充:asyncio.gather 的用法
实际项目中更常用 gather 一次性并发运行多个协程:
python
async def main():
results = await asyncio.gather(
fetch(session, url1),
fetch(session, url2),
fetch(session, url3),
)
# results 为按顺序收集到的各协程返回值
gather 会自动为每个参数创建任务并发执行,全部完成后按输入顺序返回结果列表。
注册是 create_task / gather 的职责;await 负责驱动可等待对象并等待其完成。若被 await 的对象运行时遇到 IO 阻塞,会被挂起并进入待处理队列,待 IO 完成后由事件循环重新唤醒。
| 操作 | 做的事 |
|---|---|
create_task(coro) |
把协程注册到事件循环,立即开始调度(并发) |
gather(coro1, coro2) |
内部为每个协程建 Task(注册),并发等待 |
await coro |
驱动/等待一个可等待对象:暂停当前帧,等它完成并拿返回值 |