深度解密Go语言之 scheduler

2020-07-09 00:00:00 执行 线程 运行 调度 指令

好久不见,你还好吗?距离上一篇文章已经过去了一个多月了,迟迟未更新文章,我也很着急啊。

跟大家汇报一下,这段时间我在看 proc.go 的源码,其实就是调度器的源码。代码有几千行之多,不像以往的 map,channel 等等。想把这些代码都看明白,是一个庞大的工程。到今天为止,我也不敢说我都看明白了。

要深挖下去的话,会无穷无尽,所以阶段性的探索就到这里。接下来就把这段时间的探索分享出来。

其实,今天这篇文章仅仅算是一个引子,接下来会连续发布十篇系列文章。目录如下:



而这个系列的文章主要是受公众号“go 语言核心编程技术”的启发,它有一个 Go 调度器的系列教程,写得非常赞,强烈推荐大家去看,后面会经常引用到它的文章。


开始我们今天的正题。

一个月前,《Go 语言编程》作者柴树杉老师在 CSDN 上发表了一篇《Go 语言十年而立,Go2 蓄势待发》,视角十分宏大。我们既要低头看路,有时也要抬头看天,这篇文章就属于“抬头”看天类的,推荐阅读。

文章中提到了本写 Go 的小说《胡文 Go》。我找来看了下,嬉笑怒骂,还挺有意思的。书中有这样一句话:

在 Go 语言里,go func 是并发的单元,chan 是协调并发单元的机制,panic 和 recover 是出错处理的机制,而 defer 是神来之笔,大大简化了出错的管理。

Goroutines 在同一个用户空间里同时独立执行 functions,channels 则用于 goroutines 间的通信和同步访问控制。

上一篇文章里我们讲了 channel,并且提到,goroutine 和 channel 是 Go 并发编程的两大基石,那这篇文章就聚焦到 goroutine,以及调度 goroutine 的 go scheduler。

前置知识

os scheduler

从操作系统角度看,我们写的程序终都会转换成一系列的机器指令,机器只要按顺序执行完所有的指令就算完成了任务。完成“按顺序执行指令”任务的实体就是线程,也就是说,线程是 CPU 调度的实体,线程是真正在 CPU 上执行指令的实体。

每个程序启动的时候,都会创建一个初始进程,并且启动一个线程。而线程可以去创建更多的线程,这些线程可以独立地执行,CPU 在这一层进行调度,而非进程。

OS scheduler 保证如果有可以执行的线程时,就不会让 CPU 闲着。并且它还要保证,所有可执行的线程都看起来在同时执行。另外,OS scheduler 在保证高优先级的线程执行机会大于低优先级线程的同时,不能让低优先级的线程始终得不到执行的机会。OS scheduler 还需要做到迅速决策,以降低延时。

线程切换

OS scheduler 调度线程的依据就是它的状态,线程有三种状态(简化模型):Waiting, Runnable or Executing

线程能做的事一般分为两种:计算型、IO 型。

计算型主要是占用 CPU 资源,一直在做计算任务,例如对一个大数做质数分解。这种类型的任务不会让线程跳到 Waiting 状态。

IO 型则是要获取外界资源,例如通过网络、系统调用等方式。内存同步访问控制原语:mutexes 也可以看作这种类型。共同特点是需要等待外界资源就绪。IO 型的任务会让线程跳到 Waiting 状态。

线程切换就是操作系统用一个处于 Runnable 的线程将 CPU 上正在运行的处于 Executing 状态的线程换下来的过程。新上场的线程会变成 Executing 状态,而下场的线程则可能变成 Waiting 或 Runnable 状态。正在做计算型任务的线程,会变成 Runnable 状态;正在做 IO 型任务的线程,则会变成 Waiting 状态。

因此,计算密集型任务和 IO 密集型任务对线程切换的“态度”是不一样的。由于计算型密集型任务一直都有任务要做,或者说它一直有指令要执行,线程切换的过程会让它停掉当前的任务,损失非常大。

相反,专注于 IO 密集型的任务的线程,如果它因为某个操作而跳到 Waiting 状态,那么把它从 CPU 上换下,对它而言是没有影响的。而且,新换上来的线程可以继续利用 CPU 完成任务。从整个操作系统来看,“工作进度”是往前的。

记住,对于 OS scheduler 来说,重要的是不要让一个 CPU 核心闲着,尽量让每个 CPU 核心都有任务可做。

If you have a program that is focused on IO-Bound work, then context switches are going to be an advantage. Once a Thread moves into a Waiting state, another Thread in a Runnable state is there to take its place. This allows the core to always be doing work. This is one of the most important aspects of scheduling. Don’t allow a core to go idle if there is work (Threads in a Runnable state) to be done.

函数调用过程分析

要想理解 Go scheduler 的底层原理,对于函数调用过程的理解是必不可少的。它涉及到函数参数的传递,CPU 的指令跳转,函数返回值的传递等等。这需要对汇编语言有一定的了解,因为只有汇编语言才能进行像寄存器赋值这样的底层操作。之前的一些文章里也有说明,这里再来复习一遍。

函数栈帧的空间主要由函数参数和返回值、局部变量和被调用其它函数的参数和返回值空间组成。

宏观看一下,Go 语言中函数调用的规范,引用曹大博客里的一张图:



Go plan9 汇编通过栈传递函数参数和返回值。

调用子函数时,先将参数在栈顶准备好,再执行 CALL 指令。CALL 指令会将 IP 寄存器的值压栈,这个值就是函数调用完成后即将执行的下一条指令。

然后,就会进入被调用者的栈帧。首先会将 caller BP 压栈,这表示栈基址,也就是栈底。栈顶和栈基址定义函数的栈帧。

CALL 指令类似 PUSH IP 和 JMP somefunc 两个指令的组合,首先将当前的 IP 指令寄存器的值压入栈中,然后通过 JMP 指令将要调用函数的地址写入到 IP 寄存器实现跳转。
而 RET 指令则是和 CALL 相反的操作,基本和 POP IP 指令等价,也就是将执行 CALL 指令时保存在 SP 中的返回地址重新载入到 IP 寄存器,实现函数的返回。
首先是调用函数前准备的输入参数和返回值空间。然后 CALL 指令将首先触发返回地址入栈操作。在进入到被调用函数内之后,汇编器自动插入了 BP 寄存器相关的指令,因此 BP 寄存器和返回地址是紧挨着的。再下面就是当前函数的局部变量的空间,包含再次调用其它函数需要准备的调用参数空间。被调用的函数执行 RET 返回指令时,先从栈恢复 BP 和 SP 寄存器,接着取出的返回地址跳转到对应的指令执行。

上面两段描述来自《Go 语言编程》一书的汇编语言章节,说得很好,再次推荐阅读。

goroutine 是怎么工作的

什么是 goroutine

Goroutine 可以看作对 thread 加的一层抽象,它更轻量级,可以单独执行。因为有了这层抽象,Gopher 不会直接面对 thread,我们只会看到代码里满天飞的 goroutine。操作系统却相反,管你什么 goroutine,我才没空理会。我安心地执行线程就可以了,线程才是我调度的基本单位。

goroutine 和 thread 的区别

谈到 goroutine,绕不开的一个话题是:它和 thread 有什么区别?

参考资料【How Goroutines Work】告诉我们可以从三个角度区别:内存消耗、创建与销毀、切换。

  • 内存占用

创建一个 goroutine 的栈内存消耗为 2 KB,实际运行过程中,如果栈空间不够用,会自动进行扩容。创建一个 thread 则需要消耗 1 MB 栈内存,而且还需要一个被称为 “a guard page” 的区域用于和其他 thread 的栈空间进行隔离。

对于一个用 Go 构建的 HTTP Server 而言,对到来的每个请求,创建一个 goroutine 用来处理是非常轻松的一件事。而如果用一个使用线程作为并发原语的语言构建的服务,例如 Java 来说,每个请求对应一个线程则太浪费资源了,很快就会出 OOM 错误(OutOfMermoryError)。

  • 创建和销毀

Thread 创建和销毀都会有巨大的消耗,因为要和操作系统打交道,是内核级的,通常解决的办法就是线程池。而 goroutine 因为是由 Go runtime 负责管理的,创建和销毁的消耗非常小,是用户级。

  • 切换

当 threads 切换时,需要保存各种寄存器,以便将来恢复:

16 general purpose registers, PC (Program Counter), SP (Stack Pointer), segment registers, 16 XMM registers, FP coprocessor state, 16 AVX registers, all MSRs etc.

而 goroutines 切换只需保存三个寄存器:Program Counter, Stack Pointer and BP。

一般而言,线程切换会消耗 1000-1500 纳秒,一个纳秒平均可以执行 12-18 条指令。所以由于线程切换,执行指令的条数会减少 12000-18000。

Goroutine 的切换约为 200 ns,相当于 2400-3600 条指令。

因此,goroutines 切换成本比 threads 要小得多。

M:N 模型

我们都知道,Go runtime 会负责 goroutine 的生老病死,从创建到销毁,都一手包办。Runtime 会在程序启动的时候,创建 M 个线程(CPU 执行调度的单位),之后创建的 N 个 goroutine 都会依附在这 M 个线程上执行。这就是 M:N 模型:



在同一时刻,一个线程上只能跑一个 goroutine。当 goroutine 发生阻塞(例如上篇文章提到的向一个 channel 发送数据,被阻塞)时,runtime 会把当前 goroutine 调度走,让其他 goroutine 来执行。目的就是不让一个线程闲着,榨干 CPU 的每一滴油水。

什么是 scheduler

Go 程序的执行由两层组成:Go Program,Runtime,即用户程序和运行时。它们之间通过函数调用来实现内存管理、channel 通信、goroutines 创建等功能。用户程序进行的系统调用都会被 Runtime 拦截,以此来帮助它进行调度以及垃圾回收相关的工作。

一个展现了全景式的关系如下图:



为什么要 scheduler

Go scheduler 可以说是 Go 运行时的一个重要的部分了。Runtime 维护所有的 goroutines,并通过 scheduler 来进行调度。Goroutines 和 threads 是独立的,但是 goroutines 要依赖 threads 才能执行。

Go 程序执行的高效和 scheduler 的调度是分不开的。

scheduler 底层原理

实际上在操作系统看来,所有的程序都是在执行多线程。将 goroutines 调度到线程上执行,仅仅是 runtime 层面的一个概念,在操作系统之上的层面。

有三个基础的结构体来实现 goroutines 的调度。g,m,p。

g 代表一个 goroutine,它包含:表示 goroutine 栈的一些字段,指示当前 goroutine 的状态,指示当前运行到的指令地址,也就是 PC 值。

m 表示内核线程,包含正在运行的 goroutine 等字段。

p 代表一个虚拟的 Processor,它维护一个处于 Runnable 状态的 g 队列,m 需要获得 p 才能运行 g

当然还有一个核心的结构体:sched,它总览全局。

Runtime 起始时会启动一些 G:垃圾回收的 G,执行调度的 G,运行用户代码的 G;并且会创建一个 M 用来开始 G 的运行。随着时间的推移,更多的 G 会被创建出来,更多的 M 也会被创建出来。

当然,在 Go 的早期版本,并没有 p 这个结构体,m 必须从一个全局的队列里获取要运行的 g,因此需要获取一个全局的锁,当并发量大的时候,锁就成了瓶颈。后来在大神 Dmitry Vyokov 的实现里,加上了 p 结构体。每个 p 自己维护一个处于 Runnable 状态的 g 的队列,解决了原来的全局锁问题。

Go scheduler 的目标:

For scheduling goroutines onto kernel threads.



Go scheduler 的核心思想是:

  1. reuse threads;
  2. 限制同时运行(不包含阻塞)的线程数为 N,N 等于 CPU 的核心数目;
  3. 线程私有的 runqueues,并且可以从其他线程 stealing goroutine 来运行,线程阻塞后,可以将 runqueues 传递给其他线程。

为什么需要 P 这个组件,直接把 runqueues 放到 M 不行吗?

You might wonder now, why have contexts at all? Can't we just put the runqueues on the threads and get rid of contexts? Not really. The reason we have contexts is so that we can hand them off to other threads if the running thread needs to block for some reason.
An example of when we need to block, is when we call into a syscall. Since a thread cannot both be executing code and be blocked on a syscall, we need to hand off the context so it can keep scheduling.

翻译一下,当一个线程阻塞的时候,将和它绑定的 P 上的 goroutines 转移到其他线程。

Go scheduler 会启动一个后台线程 sysmon,用来检测长时间(超过 10 ms)运行的 goroutine,将其调度到 global runqueues。这是一个全局的 runqueue,优先级比较低,以示惩罚。



总览

通常讲到 Go scheduler 都会提到 GPM 模型,我们来一个个地看。

下图是我使用的 mac 的硬件信息,只有 2 个核。



但是配上 CPU 的超线程,1 个核可以变成 2 个,所以当我在 mac 上运行下面的程序时,会打印出 4。

func main() {
    // NumCPU 返回当前进程可以用到的逻辑核心数
    fmt.Println(runtime.NumCPU())
}

相关文章