Personal Blog

develop

认识异步协程的工作原理

初步介绍python3中异步协程的工作原理,为什么异步可以提升性能的原因

1. 引言

asyncio 是用于编写异步代码的标准库,基于 async/await 语法,能够高效处理 I/O 密集型任务。asyncio.create_task() 则是创建并发任务的重要函数。

1.1 I/O 密集型任务

I/O 密集型任务指 CPU 运算量较小,而 I/O 运算量较大的任务。简单来说,发送网络请求、接受网络请求、文件读写、数据库查询等等都是典型的I/O 密集型任务。‘

假设有10个任务,每个任务需要1000ms的话,可能999ms都在等待I/O操作完成,只有1ms的CPU运算时间。

  1. 同步模式下,这100个任务会顺序执行,总耗时1000ms * 100=100000ms=100s, cpu实际耗时1ms * 100=100ms,cpu耗时与总时长的占比还是1/1000
  2. 异步模式下,这100个任务会并发执行,总耗时还是1000ms=1s,cpu实际耗时1 * 100=100ms,cpu耗时与总时长的占比来到了100/1000=1/10

可见,在I/O密集型的场景下,使用异步协程,CPU的使用率从1/1000提升到了1/10。

2. 举个栗子来说明

上面的栗子,我们将提供一个10个任务的栗子,每个任务需要1秒,模拟I/O密集型任务。 可见最后的结果是10个任务同时开始,然后10个任务同时完成。

import asyncio
import logging

# 配置日志
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(threadName)s - %(levelname)s - %(message)s',
    datefmt='%H:%M:%S'
)

# 定义一个协程(模拟耗时操作)
async def work(name, delay):
    logging.info(f"任务 {name} 开始,将等待 {delay}")
    await asyncio.sleep(delay)  # 模拟异步等待(非阻塞)
    logging.info(f"任务 {name} 完成")
    return f"{name} 的结果"  # 任务返回值

# 主协程(程序入口)
async def main():
    # 1. 创建10个任务(将协程包装为Task,自动加入事件循环)
    tasks = []
    for i in range(10):
        delay = 1  # 每个任务延迟时间都是1秒
        task = asyncio.create_task(work(f"任务{i+1}", delay), name=f"task-{i+1}")
        tasks.append(task)
    
    # 2. 等待所有任务完成(可并发执行)
    logging.info("等待所有任务完成...")
    
    # 3. 收集所有任务的结果
    results = []
    for i, task in enumerate(tasks):
        result = await task  # 获取每个任务的返回值
        results.append(result)
        logging.info(f"任务{i+1} 结果:{result}")
    
    logging.info(f"所有任务完成,共处理了 {len(results)} 个任务")

# 启动事件循环,运行主协程
asyncio.run(main())

Log输出如下:

17:09:58 - MainThread - INFO - 等待所有任务完成...
17:09:58 - MainThread - INFO - 任务 任务1 开始,将等待 1 秒
17:09:58 - MainThread - INFO - 任务 任务2 开始,将等待 1 秒
17:09:58 - MainThread - INFO - 任务 任务3 开始,将等待 1 秒
17:09:58 - MainThread - INFO - 任务 任务4 开始,将等待 1 秒
17:09:58 - MainThread - INFO - 任务 任务5 开始,将等待 1 秒
17:09:58 - MainThread - INFO - 任务 任务6 开始,将等待 1 秒
17:09:58 - MainThread - INFO - 任务 任务7 开始,将等待 1 秒
17:09:58 - MainThread - INFO - 任务 任务8 开始,将等待 1 秒
17:09:58 - MainThread - INFO - 任务 任务9 开始,将等待 1 秒
17:09:58 - MainThread - INFO - 任务 任务10 开始,将等待 1 秒
17:09:59 - MainThread - INFO - 任务 任务1 完成
17:09:59 - MainThread - INFO - 任务 任务2 完成
17:09:59 - MainThread - INFO - 任务 任务3 完成
17:09:59 - MainThread - INFO - 任务 任务4 完成
17:09:59 - MainThread - INFO - 任务 任务5 完成
17:09:59 - MainThread - INFO - 任务 任务6 完成
17:09:59 - MainThread - INFO - 任务1 结果:任务1 的结果
17:09:59 - MainThread - INFO - 任务2 结果:任务2 的结果
17:09:59 - MainThread - INFO - 任务3 结果:任务3 的结果
17:09:59 - MainThread - INFO - 任务4 结果:任务4 的结果
17:09:59 - MainThread - INFO - 任务5 结果:任务5 的结果
17:09:59 - MainThread - INFO - 任务5 结果:任务5 的结果
17:09:59 - MainThread - INFO - 任务 任务7 完成
17:09:59 - MainThread - INFO - 任务 任务8 完成
17:09:59 - MainThread - INFO - 任务7 结果:任务7 的结果
17:09:59 - MainThread - INFO - 任务8 结果:任务8 的结果
17:09:59 - MainThread - INFO - 任务 任务9 完成
17:09:59 - MainThread - INFO - 任务 任务10 完成
17:09:59 - MainThread - INFO - 任务9 结果:任务9 的结果
17:09:59 - MainThread - INFO - 任务10 结果:任务10 的结果
17:09:59 - MainThread - INFO - 所有任务完成,共处理了 10 个任务

由结果可知:

  1. 通过协程处理,虽然每个协程的运行时间是固定的1秒,但10个协程是同时运行的,所以总耗时仅为1秒。
  2. 10个任务先同时启动,然后前6个任务完成,并返回了对应的结果后,任务7、8完成、然后9、10最后完成。如果多执行几次,还会发现每次的结果可能都会不同。

如果你对照log,用脑debug一下代码的执行流程,发现和同步的代码比,执行顺序会有一点乱。没事下面讲故事来说明。

3. 协程的实现原理

事件循环

asyncio 异步原理基于事件循环(Event Loop) 和协程(Coroutine) 机制,核心是通过非阻塞 I/O 实现高效的并发处理,特别本质是单线程内的任务调度优化。

举个现实中的栗子

这里先不讨论协程的实现原理,举个现实的栗子来感性的理解一下(摘抄来源fastapi解释concurrency的文档):

异步协程版

  1. 你和暗恋的人一起去买快餐,你排队,收银员为你前面的人点单。😍
  2. 然后轮到你了,你为你喜欢的人和你点了两个非常精致的汉堡。🍔🍔
  3. 收银员对厨房里的厨师说了些什么,这样他们就知道必须准备你的汉堡(即使他们目前正在为之前的顾客准备汉堡)。
  4. 你付款。💸 收银员会告诉您轮到您的号码。
  5. 在等待的时候,你和你的暗恋对象一起去选一张桌子,坐下来和你的暗恋对象聊了很长时间(因为你的汉堡很精致,需要花一些时间来准备)。当你和你的暗恋对象坐在桌旁等待汉堡时,你可以花时间欣赏你的暗恋对象是多么棒、可爱和聪明✨😍✨。
  6. 在等待和与你喜欢的人交谈时,你会不时查看柜台上显示的号码,看看是否轮到你了。然后,终于轮到你了。你去柜台,拿了汉堡,然后回到餐桌旁。
  7. 你和你的暗恋对象一起吃汉堡,度过了愉快的时光,增进了感情。✨

想象一下你就是那个故事中的计算机/程序🤖。

  1. 排队的时候,你只是闲着😴,等着轮到你,没做什么“有成效”的事。不过,排队速度很快,因为收银员只负责接单(不负责准备),所以也还好。
  2. 然后,轮到你了,你开始做真正的“生产性”工作,处理菜单,决定你想要什么,得到你喜欢的人的选择,付款,检查你是否给了正确的账单或卡,检查你是否被正确收费,检查订单是否有正确的物品,等等。
  3. 但是,即使你还没有拿到汉堡,你与收银员的合作也会“暂停”⏸,因为你必须等待🕙你的汉堡准备好。
  4. 但当你离开柜台,坐在桌边,拿着号码牌等着轮到你的时候,你可以把注意力🔀转移到你的暗恋对象身上,并为此“努力”⏯🤓。这样你又能做一件非常“有成效”的事情了,就像和你的暗恋对象调情😍。
  5. 然后收银员💁把你的号码牌放到柜台显示屏上,说“汉堡我做完了”。但当显示屏上的号码变成你的轮次号时,你并没有立刻跳起来。你知道没人会偷你的汉堡,因为你有你的轮次号,他们也有他们的轮次号。
  6. 于是你等你暗恋的人讲完故事(完成当前工作⏯/正在处理的任务🤓),轻轻一笑,说你要去吃汉堡⏸。
  7. 然后你去柜台🔀,执行现在已经完成的初始任务⏯,挑选汉堡,道谢并将它们端到餐桌上。这样就完成了与柜台互动的步骤/任务⏹。这又创建了一个新的任务“吃汉堡”🔀⏯,但之前的“取汉堡”任务已经完成了⏹。

同步版

如果不使用异步协程,会发生什么?

  1. 你和暗恋的人一起去吃快餐,整个店里只有他一个人,既是收银也是厨师。
  2. 然后终于轮到你了,你为你和你暗恋的人点了两个非常精致的汉堡。
  3. 收银员兼大厨去厨房开始制作。你和你的暗恋的人只能站在柜台前,不能交谈、只能全神贯注的等待收银员兼大厨👨‍🍳回来,否则就会有讨厌的人在你之前插队拿走属于你的汉堡!!!
  4. 然后,在柜台前等待了很长时间之后,收银员兼大厨👨‍🍳终于带着你的汉堡回来了。
  5. 你拿着汉堡,和你喜欢的人一起去餐桌旁,然后吃完了汉堡,一看时间不早了,你的暗恋的对象要回家了。

结果就是:因为大部分时间都在柜台前全神贯注的等待🕙,你和你暗恋的对象并没有太多的交谈或增进感情的机会。所以显然这次约会不太成功😴。

对代码栗子的说明

为什么异步版本的代码执行流程乱?因为CPU在单线程中,依据不同的事件,在循环监听中反复横跳,执行代码。为什么反复横跳,就类似异步版本中的例子:

  1. 有10个任务,而你和暗恋的对象买汉堡就是一个任务。
  2. 同样,还有9个和你一样的人也在同时买汉堡,就是另外的9个任务。
  3. 任务告诉CPU自己的需求后,就不再占用CPU时间,而是拿着自己的点餐号码去其他地方等待被召回。
  4. CPU完成了谁的任务,就召回对应号码的任务,给他执行后的结果,所以1-6先后完成,然后是7、8,最后是9、10。当然多次执行代码,会发现这个顺序也不是确定的,因为事件循环中哪个事件被激活,是随机的。

好了,你反复想想这个栗子,今天就到这了!

DEVELOP · ASYNCIO
develop python3 async coroutine