Goroutines是怎么工作的,深入浅出 Go 协程

一、什么是Goroutines

Goroutines 是用户空间线程。从概念上讲,它类似于由 OS 管理的内核线程,但完全由 Go 运行时管理。比内核线程更轻巧,更便宜。调度程序将其复用到内核线程上:初始 goroutine堆栈 = 2KB;默认线程堆栈 = 8KB,状态跟踪开销。更快的创建,销毁,上下文切换:goroutine 开关=〜ns,线程开关=〜aμs。

Goroutine 可以看作对 thread 加一层抽象,它更轻量级,可以单独执行。因为有了这层抽象,Gopher 不会直接面对 thread。对于操作系统不管你抽象什么,线程才是我调度的基本单位。

二、scheduler 底层原理

在了解 Go 运行时的 scheduler 之前,需要先了解为什么需要它,因为我们可能会想,OS 内核不是已经有一个线程 scheduler 了嘛?

熟悉 POSIX thread API 的人都知道,POSIX 在很大程度上对现有 Unix 进程模型逻辑进行扩展,因此,线程获得了许多与进程相同的控件。线程具有自己的信号掩码,可以分配给 CPU affinity,可以放入 cgroups 中,并可以查询它们使用的资源。但是很多特征对于 Go 程序来说都是累赘。 尤其是 context 上下文切换的耗时。

另一个原因是 Go 的垃圾回收需要所有的 goroutine 停止,内存必须处于一致状态。垃圾回收的时间点是不确定的,如果依靠 OS 自身的 scheduler 来调度,那么会有大量的线程需要停止工作。

单独的开发一个 Go 调度器,可以知道在什么时候内存状态是一致的,也就是说,当开始垃圾回收时,运行时只需要为当前正在 CPU 核上运行的那个线程等待即可,而不是等待所有的线程。为此,Go 采用了类 coroutine 的概念来解决这些问题,Go 将之称为goroutine。

goroutine 占用的资源非常小,goroutine 调度的切换也不用陷入操作系统内核层完成,代价很低。因此,一个 Go 程序中可以创建成千上万个并发的 goroutine。所有的 Go 代码都在 goroutine 中执行,哪怕是 go 的 runtime 也不例外。将这些 goroutines 按照一定算法放到 CPU 上执行的程序就称为 go scheduler。

三、线程模型

线程的三种实现方式:用户级线程,内核级线程和混合型线程

1、用户级线程,多对一(N : 1):多个用户线程的一般从属于单个进程并且多线程的调度是由用户自己的线程库来完成,线程的创建、销毁以及多线程之间的协调等操作都是由用户自己的线程库来负责而无须借助系统调用来实现。一个进程中所有创建的线程都只和同一个 KSE 在运行时动态绑定,也就是说,操作系统只知道用户进程而对其中的线程是无感知的,内核的所有调度都是基于用户进程。许多语言实现的协程库基本上都属于这种方式(比如 python 的 gevent)。由于线程调度是在用户层面完成的,也就是相较于内核调度不需要让 CPU 在用户态和内核态之间切换,这种实现方式相比内核级线程可以做的很轻量级,对系统资源的消耗会小很多,因此可以创建的线程数量与上下文切换所花费的代价也会小得多。但该模型有个缺点:并不能做到真正意义上的并发。

2、内核级线程,一对一(1 : 1):每一个用户线程绑定一个实际的内核线程,而线程的调度则完全交付给操作系统内核去做,应用程序对线程的创建、终止以及同步都基于内核提供的系统调用来完成,大部分编程语言的线程库(Java 的 java.lang.Thread、C++11 的 std::thread 等等)都是对操作系统的线程(内核级线程)的一层封装,创建出来的每个线程与一个独立的 KSE 静态绑定,因此其调度完全由操作系统内核调度器去做,也就是说,一个进程里创建出来的多个线程每一个都绑定一个 KSE。这种模型的优势和劣势同样明显:优势是实现简单,直接借助操作系统内核的线程以及调度器,所以 CPU 可以快速切换调度线程,于是多个线程可以同时运行,因此相较于用户级线程模型它真正做到了并行处理;但它的劣势是,由于直接借助了操作系统内核来创建、销毁和以及多个线程之间的上下文切换和调度,因此资源成本大幅上涨,且对性能影响很大。

3、混合型线程,多对多(M : N):首先,区别于用户级线程模型,混合线程模型中的一个进程可以与多个内核线程 KSE 关联,也就是说一个进程内的多个线程可以分别绑定一个自己的 KSE,这点和内核级线程模型相似;其次,又区别于内核级线程模型,它的进程里的线程并不与 KSE 唯一绑定,而是可以多个用户线程映射到同一个 KSE,当某个 KSE 因为其绑定的线程的阻塞操作被内核调度出 CPU 时,其关联的进程中其余用户线程可以重新与其他 KSE 绑定运行。因为这种模型的高度复杂性,操作系统内核开发者一般不会使用,所以更多时候是作为第三方库的形式出现,而 Go 语言中的 runtime 调度器就是采用的这种实现方案,实现了 Goroutine 与 KSE 之间的动态关联。

Go runtime 负责 goroutine 生命周期,从创建到销毁。runtime 会在程序启动的时候,创建 M 个线程,之后创建的 N 个 goroutine 都会依附在这 M 个线程上执行,这就是 M : N 模型。

要理解协程的实现,首先需要了解 go 中的三个非常重要的概念, 它们分别是 G, MP,这三项是协程最主要的组成部分。

相关代码目录:

  • $GOROOT/src/runtime/asm_amd64.s
  • $GOROOT/src/runtime/proc.go
  • $GOROOT/src/runtime/runtime2.go

G 代表一个 goroutine 对象,每次 go 调用的时候,都会创建一个 G 对象,它包括栈、指令指针以及对于调用 goroutines 很重要的其它信息,比如阻塞它的任何 channel,其主要数据结构:

其中最主要的当然是 sched 了,保存了 goroutine 的上下文。goroutine 切换的时候不同于线程有 OS 来负责这部分数据,而是由一个 gobuf 对象来保存,这样能够更加轻量级,再来看看 gobuf 的结构:

其实就是保存了当前的栈指针,计数器,当然还有 g 自身,这里记录自身 g 的指针是为了能快速的访问到 goroutine 中的信息。

M 代表一个线程,每次创建一个 M 的时候,都会有一个底层线程创建;所有的 G 任务,最终还是在 M 上执行,其主要数据结构:

结构体 M 中有两个 G 是需要关注一下的,一个是 curg,代表结构体 M 当前绑定的结构体 G。另一个是g0,是带有调度栈的 goroutine,这是一个比较特殊的 goroutine。普通的 goroutine 的栈是在堆上分配的可增长的栈,而 g0 的栈是 M 对应的线程的栈。所有调度相关的代码,会先切换到该 goroutine 的栈中再执行。也就是说线程的栈也是用的 g 实现,而不是使用的 OS 的。

P 代表一个处理器,每一个运行的 M 都必须绑定一个 P,就像线程必须在么一个 CPU 核上执行一样,由 P 来调度 G 在 M 上的运行,P 的个数就是 GOMAXPROCS (最大256),启动时固定的,一般不修改;M 的个数和 P 的个数不一定一样多 (会有休眠的 M 或者不需要太多的 M) (最大10000);每一个 P 保存着本地 G 任务队列,也有一个全局 G 任务队列。

其数据结构:

其中 P 的状态有 Pidle / Prunning / Psyscall / Pgcstop / Pdead;在其内部队列 runqhead 里面有可运行的 goroutine,P 优先从内部获取执行的 g,这样能够提高效率。

除此之外,还有一个数据结构需要在这里提及,就是 schedt,可以看做是一个全局的调度者:

大多数需要的信息都已放在了结构体 M、G 和 P 中,schedt 结构体只是一个壳。可以看到,其中有 M 的 idle 队列,P 的 idle 队列,以及一个全局的就绪的 G 队列。schedt 结构体中的 Lock 是非常必须的,如果 M 或 P 等做一些非局部的操作,它们一般需要先锁住调度器。

总结

相比大多数并行设计模型,Go 比较优势的设计就是 P 上下文这个概念的出现,如果只有 G 和 M 的对应关系,那么当 G 阻塞在 IO 上的时候,M 是没有实际在工作的,这样造成了资源的浪费,没有了 P,那么所有 G 的列表都放在全局,这样导致临界区太大,对多核调度造成极大影响。

而 goroutine 使用上面的特点,感觉既可以用来做密集的多核计算,又可以做高并发的 IO 应用,做 IO 应用的时候,写起来感觉和对程序员最友好的同步阻塞一样,而实际上由于 runtime 的调度,底层是以同步非阻塞的方式在运行(即 IO 多路复用)。

所以说保护现场的抢占式调度和 G 被阻塞后传递给其他 m 调用的核心思想,使得 goroutine 的产生。