python 协程可以嵌套协程吗_Python线程、协程探究(3)——协程的调度实现

9712927b8d46ff7b8316b666195fea6b.png

前言的前言

同学们,别光点赞收藏,记得关注我的专栏哦,每周都有更新!

前言

这篇文章我们再次回到协程。协程的技术实际上是非常重要的,比如微信的后台就大量的使用协程来进行并发量的提高,只用多线程和多进程很难低成本、高效地满足全球数十亿用户的各种操作请求。协程的底层实现可以定制的,微信团队就开源了一个他们实现的协程库。直接分析他们的实现比较困难,所以我们先分析python官方的协程库asyncio的底层实现。

Tencent/libco​github.com
8a207576fb83ebd08cd1edd6be8572ab.png

这篇文章主要基于之前的两篇文章。在协程文章中,主要介绍了协程的理解和特点,在Redis单线程请求响应文章中,主要介绍了IO复用技术,以及Redis是如何利用IO复用来实现定时任务(TimeEvent)和IO任务(FileEvent)的调度。本篇在前两篇文章的基础上,来研究Python的协程调度到底是如何实现的。

大龙:Python线程协程探究(二)——揭开协程的神秘面纱​zhuanlan.zhihu.com
d082ccfe7aa142e4472d410c2643d7ce.png
大龙:Redis详解(2)——Redis是如何做到单线程服务10000客户端的​zhuanlan.zhihu.com
d082ccfe7aa142e4472d410c2643d7ce.png

本篇假定读者对asyncio的使用以及python协程有基本的熟悉并主要涉及到如下的内容:

  • 内容回顾: 首先对上两篇文章进行一个简单的回顾,回顾对协程的理解和特点以及Redis事件驱动的实现
  • Python的asyncio框架中事件循环的实现和任务调度的整体流程
    • asyncio实现中的三个重要概念及协程调度:TimeHandler, Eventloop,Task
    • 以asyncio.sleep()调用和实现为例介绍

内容回顾

协程回顾

我们曾总结协程具有两大特点

  • 可保留运行时的状态数据
  • 可出让自己的执行权,当重新获得执行权时从上一次暂停的位置继续执行


总结而言就是协程相比较常规的函数,是可以被打断以及恢复执行流程的。我们可以将之理解为用户级的线程,内核级的线程的调度执行是由操作系统来决定负责的,而协程的调度执行是由线程来负责的。需要再次提醒的一点是,正常情况下,同一个进程的线程之间是可以并行的(python多线程除外,具体详见专栏文章),但是无论怎么样,同一个线程内的各个协程不是并行的,只能并发执行,具体解释见专栏中的协程介绍文章。

217b15c8e09997f4a878b5de1356458f.png
协程即为用户级线程

既然协程的调度执行需要线程来负责,那么我们就需要实现一个调度器来实现协程的调度,在asyncio中,eventloop我们可以认为就是一个协程调度器。

45278e40ef56d1601cced26881c90191.png
调度器实现线程调度

Redis事件(任务)调度

事件驱动编程最重要的两个元素就是

  • 事件循环(即Redis中和asyncio中的eventloop)
  • 事件在事件循环的注册及发生事件时的回调函数绑定

Redis事件调度中主要调度IO事件和定时事件,每一个事件都会指定一个回调函数,当该事件发生的时候执行指定回调函数。同时我们还介绍IO事件的发生与否是托管给操作系统进行管理,但时间事件的管理是Redis自己来管理的。Redis事件循环执行一次的逻辑如下,首先检查是否有时间事件的发生,然后根据即将最早发生的时间事件的时间戳与当前时间戳的比较来决定epoll调用(我们假定底层采用epoll)的等待时长,接着使用epoll系统调用从内核拿到已经发生的IO事件,然后处理已发生的文件事件和预定时间已经到达的定时事件。

a79d85d8481864e8a02f9e813efcd945.png
Redis事件循环单次执行逻辑

asyncio协程调度

asyncio里协程的调度就是基于事件调度实现的。那么和Redis一样,asyncio中的事件分为IO事件(主要用于socket文件事件的监控)和定时事件,asyncio的协程调度和Redis的事件调度二者从顶层逻辑看也是几乎一致的。即都有一个事件循环,每次执行流程和上图也几乎一致。但协程与函数最大的不同在于协程本身的执行流程是可以被打断的,那么如果一个协程的执行流程被打断了,该如何恢复其调度?答案仍然是回调函数。接下来我们一步步展开asyncio是如何实现协程调度的,以及下面这些代码执行每一行时底层实现到底发生了什么。

# 代码片段1

asyncio实现关键概念

为了方便之后的源码分析,我们先介绍asyncio实现中的几个关键概念。需要说明的是为了方便在说明,如下的代码并不是严格的asyncio底层的实现代码,我将不影响理解的代码全部进行了删除,只留了一些核心逻辑代码。强烈下面的代码认真阅读,代码加了详细的注释比较好懂,且对协程的调度理解非常有帮助。

核心概念1:TimeHandler

#代码片段2

TimeHandler是时间事件的句柄,实际上就是一个时间事件上面封装了一层,支持指定事件发生的时间。实例化一个TimeHandler时,需要指明该事件指定的发生时间以及该事件的回调函数。当执行一个TimeHandler对象的_run()方法时,执行该时间事件的回调函数。

核心概念2:Eventloop

事件循环的概念我们应该不陌生,在Redis中每一次事件循环都会执行asProcessEvent()的函数,对应的在asyncio中,每一次事件循环我们都会执行_run_once()函数。asyncio中所有就绪的任务/事件都放在self._ready中,所有待发生的时间事件都放在self._scheduled中进行存储管理,其中self._scheduled是一个按照时间事件的发生时间来构成的最小堆。

#代码片段3

每个任务加入到事件循环中时,会实例化一个对应的TimeHandler对象,ready和scheduled中存储的就是一个个TimeHandler对象。为了方便用户指定TimeHandler中的when参数,eventloop提供了三个接口让用户把时间事件加入到事件循环中,他们分别是call_later(), call_at(), call_soon(), 其中call_soon()是直接将回调函数加到ready对列中,然后在下一次run_once函数被执行时该回调函数被执行,而call_later和call_soon将定时任务加入到schduled中。asyncio中的eventloop会循环执行run_once函数,run_once函数的执行逻辑如下:

  1. 检查ready队列中是否有任务,如果ready队列中已经有任务,则设置timeout为0,不获取IO事件,然后获取所有预计发生时间小于当前时间的的时间事件加入到ready队列,并执行ready队列中的事件。
  2. 如果ready队列中没有任务,就根据最早发生的时间事件的时间与当前的时间的比较结果来确定self.selector.select()函数的超时时间。(不失一般性,我们可以认为self.selector.select()底层使用的是epoll系统调用)。如果获取到了IO事件,则首先进行IO事件的处理,然后获取该发生的时间事件加入到ready队列中,最后执行所有的ready队列中的任务。

上述的代码流程我认为是比较简单易懂的,没什么特殊之处。核心的过程在于获取发生的事件,并执行事件的回调函数。使用epoll系统调用中获得IO事件并执行其回调函数,从scheduled中获取时间事件,并执行其回调函数。需要注意的是,在run_once函数中,只有最后的部分才是执行回调函数的部分,前半部分做的各种事件检查都是检查事件的回调函数是否能加入到ready队列中,即最终只执行ready队列中的回调函数

核心概念3:Task

Task是协程调度的核心实现,所有的协程都是一个Task,对协程的执行、挂起、切换、恢复执行等都是在Task中进行。Task的实现代码中集合核心方法如下,代码我都加了详细的注释,还比较易懂。还是需要重申一遍的是,下面的代码并不是严格asyncio底层实现代码,为了方便介绍,我将Python的实现源码的很多部分进行了删除,以及将Task从Future类继承的方法加入到了Task类的方法中。

class 

在Task中实际上完成了协程函数的执行、挂起、切换、结束之后调用其回调函数。对于一个Task,很重要的一点在于可以对其加入回调函数,即该Task的协程运行结束后,会调度执行所有的回调函数。我们按照一个协程被创建为一个Task到Task执行完毕的执行过程来分析上述的代码。

  1. __init__()函数:使用协程创建一个Task,我们命名为Task1,同时在__init__函数的最后,将Task1的__step()函数加入了事件循环的就绪队列中,下一次执行run_once函数时,这个_step函数就会被执行。
  2. __step()函数:实际上为一个Task的执行函数, 首先会调用当前协程的send()函数进行当前协程的执行
  3. 如果当前协程中没有使用yield/yield from/await,则会顺利的执行完毕(因为不会被打断)。协程执行结束时会raise一个stopIteration的异常,__step()中捕获到后,就执行Task1的结果设置函数set_result()
    1. set_result(): 在set_result()函数中,设置了result结果,同时执行了所有回调函数的调度函数。(可以对一个Task对象重复调用add_done_callback()加入多个回调函数,这些回调函数被放在一个列表中维护。)
    2. __schedule_callbacks():在回调函数调度中,将回调函数列表中的所有的回调函数加入到事件循环的ready队列中,下次run_once函数被运行时,这些回调函数会被执行。
  4. 如果当前协程中使用了yield from或者await等待另一个协程的执行完毕,则就把当前Task的唤醒函数_wakeup()加入到被执行协程的回调函数
    1. _wakeup():当被等待写成执行完毕,执行其回调函数wakeup时,wakeup函数重新执行step函数

我们发现有了Task类的辅助,协程的运行、切换、挂起、唤醒变得非常的容易实现和理解。即

  1. 如果一个Task的协程正常执行完,我们就设置Task的结果属性,然后执行Task的回调函数。
  2. 如果该Task的协程由于中间使用了yield/yield from/ await从而被打断了执行流程,则将当前Task的协程的唤醒函数作为被调用的协程所属的Task的回调函数。在被调用协程顺利执行完毕后,按照情况1的执行流程,当前Task的唤醒函数会被作为回调函数执行,从而又可以继续执行当前的协程。

这基本就是协程切换、执行的核心内容了,应该还是比较好懂的。接下来我们以一段示例代码来将上面的所有内容串起来,研究每一步底层到底都执行了什么。

以asyncio.sleep()调用为例

本节我们以asyncio.sleep()调用为例,来追踪每一步到底发生了什么,调用官方的代码应该是下面的代码这样。但是为了更好的研究,我将asyncio.sleep()函数的实现进行了少量的修改,把实现代码都放在同一个代码文件中,所以我们跳过这个代码片段,直接研究下一个代码片段。

#一个协程执行流程被打断的最主要的原因就是在协程内部调用了另一个协程函数

我们按照整个函数的执行流程来研究其每一步到底发生了什么。

# 代码片段1
  1. 首先通过get_event_loop()获得一个事件循环 event_loop。
  2. 使用协程cor1()创建一个Task对象(为方便指代,我们命名为Task1) 在Task1的创建函数的最后,Task1的_step函数被加入到事件循环的ready队列中,进而在下一次run_once函数被执行时Task1的协程会被调度执行。(asyncio.create_task()就是执行Task实例化,然后返回Task对象,Task对象初始化时最后一步将step函数加入到事件循环的ready队列中)
  3. 对Task1注册一个回调函数call_back(),当Task1执行结束后,在set_result()函数中会将所有的回调函数加入到事件循环的ready队列中。
  4. 开始执行事件循环,每次事件循环都会执行run_once函数。进入到run_once函数中,首先由于第2步在ready队列中加入了Task的step函数,所以开始执行step函数,而step函数会通过协程的send()函数触发cor1()协程的执行。
  5. cor1()函数中遇到await,于是将cor1对应的Task1的wakeup函数加入到dalong_sleep()协程对应的Task的回调函数中(将dalong_sleep()对应的Task命名为Task2
  6. 如果调用dalong_sleep时delay参数为0。
    1. 则dalong_sleep中直接调用yield,dalong_sleep执行流程被打断,但按照step函数中的逻辑,由于Task2并没有等待任何其他的协程执行完毕,所以Task2的step函数会被重新加入到事件循环的ready队列中,然后再下一次run_once函数被执行时再次执行Task2的step函数。
    2. 当dalong_sleep协程被再次继续执行时,就从yield的下一句开始执行,由于紧接着就是return语句,所以协程执行完毕正常退出并执行其set_result函数。在该函数中设置结果并执行回调函数即Task1的wakeup函数,于是Task1被继续执行。
    3. Task1的step函数被继续执行,在step函数中使用send()恢复cor1协程的执行流程。由于后续没有遇到任何的yield/await/yield from,所以协程顺利执行到结束。cor1结束时,调用Task1的set_result函数,设置result属性并将其回调函数加入到事件循环的ready队列中。在下一次的run_once函数被执行时,其回调函数被执行。
  7. 如果delay不为0。
    1. dalong_sleep中首先创建一个future对象(不影响理解,可以近似认为就是创建了一个Task对象,我们将之命名为Task3虽然实现上Future为Task的父类)。指定其发生的时间为delay时长之后的时间戳,然后将其加入到事件循环的schedule堆中,并绑定回调函数为_set_result_unless_cancelled, 在该时间事件发生后该回调函数被执行。
    2. 继续向下执行遇到yield from等待Task3,按照协程的切换执行流程。会将Task2的唤醒函数wakeup注册为Task3的回调函数。此时Task3有两个回调函数,一个是set_result_unless_cancelled,一个是Task2的唤醒函数。
    3. 当睡眠的时间到达后,在run_once函数中就会执行Task3的回调函数。执行Task2的唤醒函数之后,Task2可以被继续执行。
    4. Task2遇到dalong_sleep协程中的return语句,故Task2执行结束,调用其set_result函数并将其回调函数加入到事件循环的ready队列中,Task2的其中一个回调函数就是Task1的唤醒函数
    5. Task1被唤醒之后被继续执行到结束。然后将其回调函数加入到ready队列中
    6. Task1的回调函数被执行。

总结

本篇中我们主要介绍了python的asyncio中协程的调度的底层实现,我们发现asyncio事件循环的run_once函数的执行逻辑与Redis事件循环的aeProcessEvent()函数几乎一致。与Redis不同的是,在协程的调度执行中,我们需要关注协程被打断执行流之后如何恢复其执行。而在asyncio的实现中我们也发现:asyncio借助了Task类,通过将当前协程所对应的Task的唤醒函数设置为被调用协程的回调函数从而实现了协程的切换、挂起以及唤醒。最后我们通过一个函数代码示例来详细的分析函数的每一步中协程调度底层到底发生了什么,示例代码虽然较短,却也几乎包含了所有协程中常用的概念和使用方法,希望这篇文章对大家更深入的了解协程有所帮助。当然了,协程的调度我们是研究清楚了,但是协程还有一个最关键的特点是:可以被打断执行流程和恢复执行流程。那么如何实现在协程被打断时保存上下文,并在下一次恢复执行时再恢复执行流程?这个内容就会在后续的博客介绍。感兴趣的话可以关注我的专栏~

后记

最近的两篇博客都涉及到比较多的源码分析,之后的文章应该会主要交替进行。一些文章专注于一些系统设计、概念理解的内容,一些博客专注于这些系统和概念背后的具体实现代码。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值