普通的函数

我们先来看一个普通的函数

  1. func test (){
  2. print("a")
  3. print("b")
  4. print("c")
  5. }

执行的顺序会是打印出来 a,b,c

从普通函数到协程
和普通函数只有一个,就是返回点不同,协程可以有多个返回点

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

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

  1. package main
  2. import (
  3. "fmt"
  4. "time"
  5. )
  6. func test() {
  7. print("a\n")
  8. time.Sleep(1000)
  9. print("b\n")
  10. time.Sleep(1000)
  11. print("c\n")
  12. }
  13. func main() {
  14. go test() // 调用协程
  15. fmt.Println("in function A") // do something
  16. go test() // 再次调用该协程
  17. }

当运行到第 17 行的时候,打印出来的是 a,符合预期,然后协程 sleep,到调用第 19 行的时候打印的就是 b
最后的结果是这样的

  1. a
  2. in function A
  3. b

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

图形化的解释一波

普通的函数调用

image.png

在该图中,方框内表示该函数的指令序列,如果该函数不调用任何其它函数,那么应该从上到下依次执行,但函数中可以调用其它函数,因此其执行并不是简单的从上到下,箭头线表示执行流的方向。从图中我们可以看到,我们首先来到funcA函数,执行一段时间后发现调用了另一个函数funcB,这时控制转移到该函数,执行完成后回到main函数的调用点继续执行。

协程的调用

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

协程被成为用户态线程的原因

怎么样,神奇不神奇,和普通函数不同的是,协程能知道自己上一次执行到了哪里。现在你应该明白了吧,协程会在函数被暂停运行时保存函数的运行状态,并可以从保存的状态中恢复并继续运行。很熟悉的味道有没有,这不就是操作系统对线程的调度嘛,线程也可以被暂停,操作系统保存线程运行状态然后去调度其它线程,此后该线程再次被分配CPU时还可以继续运行,就像没有被暂停过一样。只不过线程的调度是操作系统实现的,这些对程序员都不可见,而协程是在用户态实现的,对程序员可见。

协程的实现原理

协程之所以可以被暂停也可以继续,那么一定要记录下被暂停时的状态,也就是上下文,当继续运行的时候要恢复其上下文(状态),那么接下来很自然的一个问题就是,函数运行时的状态是什么?
这个关键的问题的答案就在《函数运行起来后在内存中是什么样子的》这篇文章中,函数运行时所有的状态信息都位于函数运行时栈中。
函数运行时栈就是我们需要保存的状态,也就是所谓的上下文,如图所示
image.png
图中我们可以看出,该进程中只有一个线程,栈区中有四个栈帧,main函数调用A函数,A函数调用B函数,B函数调用C函数,当C函数在运行时整个进程的状态就如图所示。
现在我们已经知道了函数的运行时状态就保存在栈区的栈帧中,接下来重点来了哦。
既然函数的运行时状态保存在栈区的栈帧中,那么如果我们想暂停协程的运行就必须保存整个栈帧的数据,那么我们该将整个栈帧中的数据保存在哪里呢
很显然,这就是堆区啊,heap,我们可以将栈帧保存在堆区中,那么我们该怎么在堆区中保存数据呢?希望你还没有晕,在堆区中开辟空间就是我们常用的C语言中的malloc或者C++中的new。
我们需要做的就是在堆区中申请一段空间,让后把协程的整个栈区保存下,当需要恢复协程的运行时再从堆区中copy出来恢复函数运行时状态。
再仔细想一想,为什么我们要这么麻烦的来回copy数据呢?实际上,我们需要做的是直接把协程的运行需要的栈帧空间直接开辟在堆区中,这样都不用来回copy数据了
image.png
从图中我们可以看到,该程序中开启了两个协程,这两个协程的栈区都是在堆上分配的,这样我们就可以随时中断或者恢复协程的执行了。有的同学可能会问,那么进程地址空间最上层的栈区现在的作用是什么呢?这一区域依然是用来保存函数栈帧的,只不过这些函数并不是运行在协程而是普通线程中的。现在你应该看到了吧,在上图中实际上有3个执行流:

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

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

因此即使你创建了N多协程,但在操作系统看来依然只有一个线程,也就是说协程对操作系统来说是不可见的。
这也许是为什么协程这个概念比线程提出的要早的原因,可能是写普通应用的程序员比写操作系统的程序员最先遇到需要多个并行流的需求,那时可能都还没有操作系统的概念,或者操作系统没有并行这种需求,所以非操作系统程序员只能自己动手实现执行流,也就是协程。