协程

协程,也称为轻量级线程,具备以下几个特点:

  • 能够在单一的系统线程中模拟多个任务的并发执行;

  • 在一个特定的时间,只有一个任务在运行,即并非真正地并行;

  • 被动的任务调度方式,即任务没有主动抢占时间片的说法。当一个任务正在执行时,外部没有办法中止它。要进行任务切换,只能通过由该任务自身调用yield()来主动出让CPU使用权;

  • 每个协程都有自己的堆栈和局部变量;

每个协程都包含3种运行状态:挂起、运行和停止。停止通常表示该协程已经执行完成(包括遇到问题后明确退出执行的情况),挂起则表示该协程尚未执行完成,但出让了时间片,以后有机会时会由调度器继续执行。

协程库(libtask)

libtask库的下载地址是:http://swtch.com/libtask/,这个库的作者使用的是非常开放的授权协议,因此可以随意修改和使用这些代码,但是必须保持该份代码所附带的版权声明。

这个libtask库实现了以下几个关键模块:

  • 任务及任务管理

  • 任务调度器

  • 异步IO

  • channel

这个静态库直接提供了一个main()入口函数作为协程的驱动,因此库的使用者只需按该库约定的规则实现任务函数taskmain(),启动后这些任务自然会被以协程的方式创建和调度执行。taskmain()函数的声明如下:
Go goroutine机理 - 图1

先来看一下简单的C程序例子:
Go goroutine机理 - 图2
Go goroutine机理 - 图3

该程序从命令行得到一个整型数作为质数的查找范围,比如用户输入了100,则该程序会列出0到100之间的所有质数。

将以上代码翻译成Go语言代码,如下:
Go goroutine机理 - 图4
Go goroutine机理 - 图5

任务

  1. 从上面的例子可以看出,在实现了一个任务函数后,真要让这个函数加入到调度队列中,我们还需要显式调用taskcreate()函数。下面我们大致介绍一下任务的概念,以及taskcreate()到底做了哪些事情。

任务用以下的结构表达:
Go goroutine机理 - 图6
可以看到,每一个任务需要保存以下这几个关键数据:

  • 任务上下文,用于在切换任务时保持当前任务的运行环境;

  • 状态

  • 该任务所对应的业务函数

  • 任务的调用参数

  • 之前和之后的任务

下面我们再来看一下任务的创建过程:
Go goroutine机理 - 图7
Go goroutine机理 - 图8
Go goroutine机理 - 图9

可以看到,这个过程其实就是创建并设置了一个Task对象,然后将该对象添加到alltask列表中,接着将该Task对象的状态设置为就绪,表示该任务可以接受调度器的调度。

任务调度

调度器的代码实现如下:
Go goroutine机理 - 图10
Go goroutine机理 - 图11
逻辑其实很简单,就是循环执行正在等待中的任务,直到执行完所有的任务后退出。读者可能会觉得奇怪,这个函数里根本没有调用任务所对应的业务函数的代码,那么那些代码到底是怎么执行的呢?最关键的是下面这一句调用:
Go goroutine机理 - 图12

上下文切换

taskstart()函数的具体实现代码:
Go goroutine机理 - 图13

我们知道,在任务执行过程中发生任务切换只会因为以下原因之一:

  • 该任务的业务代码主要要求切换,即主动让出执行权;

  • 发生了IO,导致执行阻塞。

主动出让执行权通过主动调用taskyield()来完成。在下面的代码中,taskswitch()切换上下文以具体做到任务切换,taskready()函数将一个具体的设置为等待执行状态,tasksyield()则借助其他的函数完成执行权出让:
Go goroutine机理 - 图14
上面的代码做了这几件事情:

  • 将正在执行的任务放回到等待队列中,免得永远无法再切换回来;

  • 将该任务的状态设置为yield;

  • 进行任务切换

libtask库中的fd.c进行了基于轮询的异步IO封装,并在tcpproxy.c中示范了如何使用异步IO来达成自动出让执行权的效果。
Go goroutine机理 - 图15
Go goroutine机理 - 图16
当发生IO事件时,程序会先让其他处于yield状态的任务先执行,待清理掉这些可以执行的任务后,开始调用poll来监听所有处于IO阻塞状态的pollfd,一旦有某些pollfd成功读写,则将对应的任务切换为可调度状态。

通信机制

我们知道,channel是推荐的goroutine之间的通信方式。而实际上,“通信”这个术语并不太适用。从根本上来说,channel只是一个数据结构,可以被写入数据,也可以被读取数据。所谓的发送数据到channel,或者从channel读取数据,说白了就是对一个数据结构的操作,仅此而已。

下面我们就来看看channel的数据结构:
Go goroutine机理 - 图17
Go goroutine机理 - 图18

可以看到channel的基本组成如下:

  • 内存缓存,用于存放元素

  • 发送队列

  • 接受队列

从以下这个channel的创建函数可以看出,分配的内存缓存就紧跟在这个channel结构之后:
Go goroutine机理 - 图19


Go goroutine机理 - 图20