ITPub博客

首页 > 数据库 > 数据库开发技术 > 深入FastAPI异步编程-之并发编程(3)~协程一些概念、await、awaitable对象说明

深入FastAPI异步编程-之并发编程(3)~协程一些概念、await、awaitable对象说明

原创 数据库开发技术 作者:程序员小钟同学 时间:2024-01-05 00:15:12 0 删除 编辑

协程是什么

既然我们探讨的是并发,所以协程和并发肯定是有关系的,前面我们谈论并发的时候都是基于多线程或多进程,然后这两种方式都存在一定的缺陷,所以我们进一步的希望能否在单线程里面也可以实现并发的可能? 所以在asyncio提供了一种单线程并发模型,使用事件循环来调度和管理协程的执行。而我们这里的【协程】是什么呢?

从理论上说,协程(Coroutine)是一种编程概念,用于指代可以暂停执行并在稍后某个时刻从停止的地方继续执行的子程序或函数。协程是异步编程的关键组成部分,允许程序在等待某些操作完成(如I/O操作)时执行其他任务,而不是被阻塞。

换一个方式来说的话,阻塞就像你在餐厅排队等待点餐。在你前面的顾客正在点餐,而你只能耐心等待,不能去干别的事情。当前面的顾客点完餐,服务员才会轮到你,这时你才能进行点餐。在这个过程中,你被“阻塞”了,无法进行其他活动。 在计算机科学中,阻塞调用指的是在程序等待某些操作完成(如IO操作、网络请求等)时,当前线程(或进程)会被挂起,直到所需的结果返回,当前线程才能继续执行。阻塞会使得程序的执行效率降低,因为线程在等待结果的过程中,不能进行其他任务。

协程和线程

协程和线程都是并发编程中的概念,但是他们之间也有所不同:

  • 协程是用户态的轻量级线程,由程序自身控制调度,而线程是操作系统级别的概念,由内核进行调度。

  • 协程和线程的区别在线程的切换需要消耗较大的系统资源,而协程的切换只需要保存和恢复少量的寄存器上下文,开销相对线程比较小。

  • 协程可以在同一个线程中执行多个任务,也可以在多个线程之间切换执行。

  • 线程的资源占用包括内存、IO等,而协程只占用的内存较少。

PS 在前面的部分的文章中其实也有介绍相关的协程,只是说的时候只是涉及内容不够全面,文章贴如下

早期的协程

早期的的协成是不存在的,在Python 2.x版本中,协程的概念并不是直接支持的。开发者通常使用生成器(Generator)来模拟协程的行为,在对应的生成器中,它允许函数通过yield语句暂停执行,并在之后的某个时刻通过next()方法恢复执行。如下示例所示:

def coroutine_setpoint():
    # 协程开始
    print("协程开始")
    yield "第一步"
    # 挂起执行
    print("挂起执行")
    yield "第二步"
    # 完成
    print("完成")

# 使用生成器模拟协程
coroutine = coroutine_setpoint()

# 迭代生成器,模拟协程的执行
for step in coroutine:
    print(step)
    if step == "第一步":
        # 模拟协程的挂起
        coroutine.send("挂起")
    elif step == "第二步":
        # 模拟协程的恢复
        coroutine.send("恢复")

# 协程结束
print("协程结束")

上述代码输出的结果是:

协程开始
第一步
挂起执行
完成
协程结束

在这个示例中,coroutine_setpoint函数是一个生成器,它是通过yield语句产生了两个值。我们在尽显迭代生成器的时候,我们通过了send方法向生成器发送消息,模拟协程的挂起和恢复。所以我们会看到当生成器接收到"挂起"消息时,它会在产生"第一步"后暂停执行;当接收到"恢复"消息时,它会从上次离开的地方继续执行,产生"第二步"。

生成器(Generator)是一种特殊的迭代器,它可以记住遍历时候的的位置。生成器通过yield语句产生值,并在每次产生值后“冻结”当前的执行状态,以便可以在后续的迭代中从上次离开的地方继续执行。这个特性也是我们的协程的行为模式,因为协程也可以暂停执行并在稍后某个时刻从停止的地方继续执行。

后来的协程(python3.6以后)

协程的发展到后期,从Python 3.6开始,asyncio库变得更加成熟和稳定,成为Python异步编程的标准方式。Python 3.7及以后的版本中,asyncio的运行时得到了改进,性能也得到了提升,同时也引入了一些新的功能和原语。也就是进一步的增强了引入了async和await等的使用。也就是我们协程开始越来越成熟了!

这里我们了解一下协程的特点有哪几点:

  • 协作式多任务:协程允许多个任务协作式地共享相同的线程,而不是抢占式地争夺CPU时间。
  • 非阻塞I/O:协程可以用于执行非阻塞的I/O操作,这样在等待I/O响应的同时,事件循环可以执行其他任务,这个也是并发实现的关键之一。
  • 轻量级:协程比线程更轻量级,因为它们不需要线程上下文切换的开销。
  • 可暂停和恢复:协程可以在任何点暂停,并在需要时从暂停点恢复执行。
  • 任务调度:协程通过事件循环进行调度,事件循环负责协程的挂起、恢复和取消。
  • 异常处理:协程中的异常可以通过try...except块进行处理,并且它可以通过await表达式进行传播。

归结起来协程通常应用于网络服务器、数据库操作、文件处理等,特别是在需要处理大量并发连接或请求的应用中,协程可以提供高效的性能和资源利用。

协程的创建

如我们所知,python中我们的协程和多线程相比,协程提供了一种更轻量级的方式来执行并发代码。在Python中,使用async def关键字来定义一个协程。如下示例代码:

import asyncio

async def my_test_coroutine():
    print("协程正在运行...")
    await asyncio.sleep(1)  # 模拟异步操作
    print("协程完成")

如前面所说明我们的定义一个协程是使用了async def关键字,而在我们的在协程函数内部,可以使用await关键字来挂起当前协程的执行,直到等待的操作完成。

但是需要注意,我们上面只是定义了协程,要运行一个协程,我还需要依赖于一个事件循环,所以我们的有了以下的代码:

import asyncio

async def my_test_coroutine():
    print("协程正在运行...")
    await asyncio.sleep(1)  # 模拟异步操作
    print("协程完成")


asyncio.run(my_test_coroutine())

查看控制台输出结果为:

协程正在运行...
协程完成

协程和普通函数的区别

假设我们的只是定义了协程函数,且调用了如下代码:

import asyncio

async def my_test_coroutine():
    print("协程正在运行...")
    await asyncio.sleep(1)  # 模拟异步操作
    print("协程完成")

my_test_coroutine()

运行代码会输出如下的结果:

D:\opencode\likeadmin_python-master\server\阿达.py:8: RuntimeWarning: coroutine 'my_test_coroutine' was never awaited
  my_test_coroutine()
RuntimeWarning: Enable tracemalloc to get the object allocation traceback

RuntimeWarning: coroutine 'my_test_coroutine' was never awaited这错误信息给出了一个警告提示,说我们的定义了一个协程(coroutine)函数my_test_coroutine,但在代码中没有使用await关键字来调用它。协程是一种特殊类型的函数,它可以在执行过程中挂起和恢复,但要让它真正执行,你需要使用await关键字来调用它。如果你没有这样做,Python会发出这个警告。

所以我们运行协程最终还是使用asyncio.run(my_test_coroutine())来完成,也就也是说我们的asyncio.run包含了我们运行协程的必然要数【事件循环】。 从这里我们可以大致得到协程与函数的区别:

  • 定义方式:普通函数使用def关键字,而协程使用async def。
  • 执行方式:普通函数是同步执行的(直接的跑),协程是异步执行的(依赖事件循环)。
  • 返回值:普通函数直接返回值,协程返回的是一个协程对象,需要使用await来获取最终结果。
  • 并发性:普通函数不能并发执行,协程可以与其他协程并发执行。

协程的生命周期

如前面所知,我们的协程(Coroutine)是一种比线程更轻量级的并发执行单元,它是线程内的‘线程’,它可以在一个线程内进行切换,使得在等待某些操作完成时可以切换到其他任务执行,从而提高效率。

协程(Coroutine)的生命周期通常涉及以下几个关键阶段:

  • 创建(Created):当我们的协程对象被创建,但尚未开始执行。在这个阶段,协程对象拥有完整的栈和程序计数器,但它还没有开始被调度进行执行。

也就是说我们的此时使用 async def 声明一个协程函数,并调用该函数来创建协程对象。例如:coro = test_coroutine()

  • 启动(协程) 此时我们开始使用await进行调度,或 调用协程对象的 await() 方法或 使用了asyncio.create_task() 函数来包含协程了并加入到事件循环中。

通常进行调用会返回一个 awaitable 对象,即协程的等待对象。例如:await coro 或 task = asyncio.create_task(coro)

  • 运行中(Running):协程开始被调度执行。当协程的__call__方法被调用时,协程进入运行中状态,开始执行其内部代码。

  • 挂起(Suspended):协程在执行过程中遇到等待操作(如IO操作)时,会被挂起。在挂起状态,协程的执行被暂停,但它的状态被保存,如会保存当前的执行上下文(包括局部变量、指令指针等),以便可以在等待操作完成后恢复执行(在协程被挂起期间,它仍然保持活跃状态,并不会被销毁)。比如,当协程执行到一个IO操作,如网络请求或文件读写时,它需要等待操作完成,这时协程就会被挂起,让出CPU给其他任务。

通常我们是通过 await 关键字暂停协程自身的执行,让出 CPU 资源给其他协程或线程。

  • 返回(Returned):协程完成执行后会返回一个结果,并结束执行。

  • 异常(Exceptional):协程在执行过程中遇到错误或异常时,会进入异常状态。这时,协程会抛出异常,并且不会返回任何结果,我们可以进行异常捕获处理。

  • 结束(Ended):协程执行完毕,当协程函数执行完毕时,协程会自动结束。此时,它会返回一个值或抛出一个异常,作为协程的结果。需注意一点,无论是正常结束还是异常结束,都会进入结束状态。在这个状态下,协程对象可以被垃圾回收。

协程的await

如前面我们所说,异步函数是通过async关键字来定义的,而await关键字用于调用这些异步函数。这两个关键字是Python异步编程的核心,它们的引入使得我们在编写的异步代码的编写和也可以更加的容易理解。

由前面可知我们的async 用于定义异步函数,而 await 用于在异步函数内部等待其他异步操作完成。所以我们的async 和 await 是一起工作的。如下的示例代码:

async def hello_world():
    print("Hello, World!")
async def main():
    await hello_world()  # 等待 hello_world 函数完成
# 运行异步主函数
asyncio.run(main())

在上面的例子中,我们的main 函数它是一个异步函数,它使用 await 来等待 hello_world 函数的处理完成。await 关键字在这里的作用是挂起当前协程的执行,直到 hello_world 函数执行完成。在等待期间,事件循环可以执行其他任务。

你可能会疑问事件循环可以执行其他任务是怎么提现的呢? 因为上面的代码的执行话,你会发现好像还是线性的执行按顺序,没有突出体现出来并发出行,如下的示例代码:

import asyncio
import time


async def say_after(delay, what):
    await asyncio.sleep(delay)  # 异步等待指定时间
    print(what)
async def main():
    print(f"开始时间 at {time.strftime('%X')}")
    await say_after(1, '我是第一名')
    await say_after(2, '我是第二名')
    print(f"结束时间 at {time.strftime('%X')}")
asyncio.run(main())

启动后我们发现我们的代码还似乎串行的输出结果如下所示:

开始时间 at 23:46:02
我是第一名
我是第二名
结束时间 at 23:46:05

那就没有所谓的异步而言啊,!!!!我们希望的并发执行是希望可以同时跑say_after1和say_after2。那我们此时需要引入新的概念就是任务了!!!不过任务到下小小节,我们再展开,这里继续回到关于一个awaitable对象的问题上。

awaitable对象

awaitable 对象是 asyncio 中的一个核心概念,它是异步操作的结果。当一个异步函数(async def 定义的函数)被调用时,它返回的是一个 awaitable 对象。这个对象的本质及时就是一个特殊的迭代器,它允许你在协程中“暂停”执行,并在等待的操作完成时“恢复”执行。所以我们这个对象表示一个尚未完成的操作,它可以被 await 关键字挂起,直到操作完成。

awaitable 对象一些说明:

  • 协作式多任务:asyncio 使用协作式多任务调度。这意味着 awaitable 对象必须显式地交出控制权,以便事件循环可以执行其他任务。这是通过 await 关键字实现的,当 await 一个 awaitable 对象时,当前我们的协程会被挂起,而我们的事件循环可以切换到其他任务,去执行别的任务。

  • 非阻塞I/O:awaitable 对象通常与异步I/O操作相关联。例如,当一个协程执行一个网络请求时,它不会阻塞等待响应,而是返回一个 awaitable 对象,然后继续执行其他任务。当网络响应到达时,相关的事件会被触发,awaitable 对象完成,并且可以通过 await 被唤醒。

  • 事件循环的状态:在 asyncio 中,事件循环可以处于不同的状态,包括运行、挂起、休眠等。awaitable 对象的状态与事件循环的状态紧密相关。当一个 awaitable 对象被挂起时,它对应的事件循环可能处于挂起状态,等待唤醒。

  • 在代码中,如果但凡实现了__await__()方法的对象也可以作为awaitable对象。这也表示了这些对象可以在异步函数中使用await关键字进行等待。

  • 另外,下面我们即将说的asyncio 中的 Task 对象也是 awaitable 的。Task 是 asyncio 的一个高级抽象,代表了一个可调度的异步任务。Task 对象封装了 awaitable 对象,并提供了一些额外的功能,比如取消任务、检查任务状态等,Task 对象是事件循环中的实际执行单元。

  • awaitable对象内部的运行机制其实就是当我们使用 await 关键字去等待一个 awaitable 对象时,对象的会启动一个内部的迭代器,这个迭代器会在 awaitable 对象准备好结果时产生一个值,或者如果 awaitable 对象取消了,则会产生一个 CancelledError 异常,当值还有的时候会不断继续迭代知道没有值。类似迭代器。

  • 在 asyncio 中,异常处理需要特别注意。当一个 awaitable 对象完成时,如果它引发了异常,那么这个异常会被传递给 await 语句,并按照正常的异常处理流程进行处理。

文笔有限,如有笔误或错误!欢迎批评指正!感谢各位大佬!有什么问题也可以随时交流!

结尾

END

简书:https://www.jianshu.com/u/d6960089b087

掘金:https://juejin.cn/user/2963939079225608

公众号:微信搜【程序员小钟同学】

新开QQ群号,欢迎随时加群交流,相互学习。QQ群号:247491107

小钟同学 | 文 【欢迎一起学习交流】| QQ:308711822


原文作者:程序员小钟同学;如有侵权,请联系管理员删除。

请登录后发表评论 登录
全部评论

注册时间:2021-02-20

  • 博文量
    224
  • 访问量
    111685