Python异步编程

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 驱动/等待一个可等待对象:暂停当前帧,等它完成并拿返回值
相关推荐
宸津-代码粉碎机1 小时前
Java面试核心:JVM三大GC垃圾回收算法原理与生产场景落地分析
java·大数据·人工智能·python·spring
小静AI工程实验室1 小时前
Codex 实用教程:Windows、macOS、Linux 安装配置到首个可验收项目
人工智能·python·开发工具·codex
黑马水牛2 小时前
Carla仿真系列:17_利用Carla仿真环境跑通3DGS——从采集360张图到高斯泼溅建模
python·ros·colmap·3dgs·新视角合成·carla仿真·三维建图
赵大仁3 小时前
极空间 NAS 没有命令行,我逆向了它的桌面客户端
python·ai编程·nas·mcp·极空间
空白3 小时前
Power BI +VBA 【数据分析】
python
科技苑9 小时前
Python简单网络爬虫教程
爬虫·python
Patrick在香港12 小时前
Claude Prompt 香港场景:公文里「今日」落在 6 个日历日上,「翌日」锚错了 5 天
python·自然语言处理·正则表达式·claude·数据清洗
YsyaaabB13 小时前
Python 数值分析
python
阿洛学长13 小时前
计算机二级 Python 基本操作题(15 分)真题笔记(0101 ~ 1903 全套)
python·pycharm