目录

如何理解协程

如何理解协程?

普通的函数

我们先来看看普通的函数,从简到繁嘛

1
2
3
4
def func():
  print("a")
  print("b")
  print("c")

这是一个简单的函数,当我们调用这个函数的时候会发生啥?

  1. 调用func
  2. func开始执行,直到return
  3. func执行完毕,返回函数A

是不是很简单,函数func执行直到返回,并打印出:“a, b, c”

从普通函数到协程

协程是可以有多个返回点的,这是什么意思呢?

  • 1
    2
    3
    4
    5
    6
    7
    
    void func() {
      print("a")
      暂停并返回
      print("b")
      暂停并返回
      print("c")
    }
    

普通函数下,只有当执行完print(“c”)这句话后函数才会返回,但是在协程下当执行完print(“a”)后func就会因“暂停并返回”这段代码返回到调用函数

有的同学可能会一脸懵逼,这有什么神奇的吗?我写一个return也能返回,就像这样

  • 1
    2
    3
    4
    5
    6
    7
    
    void func() {
      print("a")
      return
      print("b")
      暂停并返回
      print("c")
    }
    

直接写一个return语句确实也能返回,但这样写的话return后面的代码都不会被执行到了

协程之所以神奇就神奇在当我们从协程返回后还能继续调用该协程,并且是从该协程的上一个返回点后继续执行

这时我们就可以返回到调用函数,当调用函数什么时候想起该协程后可以再次调用该协程,该协程会从上一个返回点继续执行

这个暂停并返回 在编程语言中一般叫做yield(其它语言中可能会有不同的实现,但本质都是一样的)

需要注意的是,当普通函数返回后,进程的地址空间中不会再保存该函数运行时的任何信息,而协程返回后,函数的运行时信息是需要保存下来的,那么函数的运行时状态到底在内存中是什么样子呢,关于这个问题你可以参考这里

Show Me The Code

下面我们采用python来讲解一个例子,不用担心看不懂,在python中,同样使用yield,这样我们的函数就变成了

  • 1
    2
    3
    4
    5
    6
    7
    
    def func() {
      print("a")
      yield
      print("b")
      yield
      print("c")
    }
    

注意,这时候这个函数就不再是简简单单的函数了,而是升级成为了协程,然后我们该怎么使用呢

  • 1
    2
    3
    4
    5
    
    def A():
      co = func() # 得到该协程
      next(co)    # 调用协程
      print("in function A") # do something
      next(co)    # 再次调用该协程
    

我们看到虽然func函数没有return代码,也就是说虽然没有返回任何值,但是我们依然可以写co = func()这样的代码,意思是说co就是我们拿到的协程了

我们来看一看这个代码做的事情

  1. 我们调用该协程,使用next(co),运行A函数看看执行到第三行的结果是什么?

    • 1
      
      a
      

      显然,和我们预期的一样,协程在print("a")后因执行yield而暂停并返回函数A

  2. 接下来是第4行,这个毫无疑问,A函数在做一些自己的事情,因此会打印

    • 1
      2
      
      a
      in function A
      
  3. 接下来是重点的一行,当执行第5行再次调用协程时该打印什么呢

    • 1
      2
      3
      
      a
      in function A
      b
      

      看到了吧,协程是一个很神奇的函数,它会自己记住之前的执行状态,当再次调用时会从上一次的返回点继续执行

图形化解释

为了让你更加彻底的理解协程,我们使用图形化的方式再看一遍,首先是普通的函数调用

  • https://raw.githubusercontent.com/vlicecream/cloudImage/main/data/202303061509312.png

在该图中,方框内表示该函数的指令序列,如果该函数不调用任何其它函数,那么应该从上到下依次执行,但函数中可以调用其它函数,因此其执行并不是简单的从上到下,箭头线表示执行流的方向

从图中我们可以看到,我们首先来到funcA函数,执行一段时间后发现调用了另一个函数funcB,这时控制转移到该函数,执行完成后回到main函数的调用点继续执行

这是普通的函数调用

接下来是协程

  • https://raw.githubusercontent.com/vlicecream/cloudImage/main/data/202303061511883.png

在这里,我们依然首先在funcA函数中执行,运行一段时间后调用协程,协程开始执行,直到第一个挂起点,此后就像普通函数一样返回funcA函数,funcA函数执行一些代码后再次调用该协程,注意,协程这时就和普通函数不一样了,协程并不是从第一条指令开始执行而是从上一次的挂起点开始执行,执行一段时间后遇到第二个挂起点,这时协程再次像普通函数一样返回funcA函数,funcA函数执行一段时间后整个程序结束

  • https://raw.githubusercontent.com/vlicecream/cloudImage/main/data/202303061512636.png

函数只是协程的一种特例

怎么样,神奇不神奇,和普通函数不同的是,协程能知道自己上一次执行到了哪里

现在你应该明白了吧,协程会在函数被暂停运行时保存函数的运行状态,并可以从保存的状态中恢复并继续运行。

很熟悉的味道有没有,这不就是操作系统对线程的调度嘛,线程也可以被暂停,操作系统保存线程运行状态然后去调度其它线程,此后该线程再次被分配CPU时还可以继续运行,就像没有被暂停过一样。

只不过线程的调度是操作系统实现的,这些对程序员都不可见,而协程是在用户态实现的,对程序员可见

这就是为什么有的人说可以把协程理解为用户态线程的原因

也就是说现在程序员可以扮演操作系统的角色了,你可以自己控制协程在什么时候运行,什么时候暂停,也就是说协程的调度权在你自己手上。

在协程这件事儿上,调度你说了算

当你在协程中写下yield的时候就是想要暂停该协程,当使用next()时就是要再次运行该协程。

现在你应该理解为什么说函数只是协程的一种特例了吧,函数其实只是没有挂起点的协程而已

协程是如何实现的

让我们从问题的本质出发来思考这个问题,协程的本质是什么

  • 其实就是可以被暂停以及可以被恢复运行的函数
  • 好比NBA比赛被暂停,但是也可以继续比赛,因为比赛状态被记录了下来,这里的状态就是上下文-context

协程之所以可以被暂停也可以继续,那么一定要记录下被暂停时的状态,也就是上下文,当继续运行的时候要恢复其上下文(状态),那么接下来很自然的一个问题就是,函数运行时的状态是什么?

这个关键的问题的答案就在《函数运行起来后在内存中是什么样子的》这篇文章中,函数运行时所有的状态信息都位于函数运行时栈中

函数运行时栈就是我们需要保存的状态,也就是所谓的上下文,如图所示:

  • https://raw.githubusercontent.com/vlicecream/cloudImage/main/data/202303061524881.png

从图中我们可以看出,该进程中只有一个线程,栈区中有四个栈帧,main函数调用了A,A调用了B,B调用了C,当C函数在运行时整个进程的状态就如图所示

现在我们已经知道了函数的运行时状态就保存在栈区的栈帧中,接下来重点来了哦

既然函数的运行时状态保存在栈区的栈帧中,那么如果我们想暂停协程的运行就必须保存整个栈帧的数据,那么我们该将整个栈帧中的数据保存在哪里呢?

很显然,这就是堆区啊,heap,我们可以将栈帧保存在堆区中,那么我们该怎么在堆区中保存数据呢?希望你还没有晕,在堆区中开辟空间就是我们常用的C语言中的malloc或者C++中的new

我们需要做的就是在堆区中申请一段空间,让后把协程的整个栈区保存下,当需要恢复协程的运行时再从堆区中copy出来恢复函数运行时状态

再仔细想一想,为什么我们要这么麻烦的来回copy数据呢?

实际上,我们需要做的是直接把协程的运行需要的栈帧空间直接开辟在堆区中,这样都不用来回copy数据了,如图所示

  • https://raw.githubusercontent.com/vlicecream/cloudImage/main/data/202303061526223.png

从图中我们可以看到,该程序中开启了两个协程,这两个协程的栈区都是在堆上分配的,这样我们就可以随时中断或者恢复协程的执行了

有的同学可能会问,那么进程地址空间最上层的栈区现在的作用是什么呢?

这一区域依然是用来保存函数栈帧的,只不过这些函数并不是运行在协程而是普通线程中的

现在你应该看到了吧,在上图中实际上有3个执行流:

  1. 一个普通线程
  2. 两个协程

虽然有3个执行流但我们创建了几个线程呢?

一个线程

现在你应该明白为什么要使用协程了吧,使用协程理论上我们可以开启无数并发执行流,只要堆区空间足够,同时还没有创建线程的开销,所有协程的调度、切换都发生在用户态,这就是为什么协程也被称作用户态线程的原因所在

因此即使你创建了N多协程,但在操作系统看来依然只有一个线程,也就是说协程对操作系统来说是不可见的

现在你应该对协程有一个清晰的认知了吧