原文在此

介绍

Go1.1中最重要的更新便是一个由Dmitry Vyukov设计的全新的调度器. 新的调度器在并行go编程的性能上有显著地提升, 不多说,我觉得我应该写一些关于它的东西.

这篇文章中的大部分内容在原始的设计文档已有描述. 那是一篇相当全面的文档,同样也十分专业.

你需要知道的关于新调度器的内容都在那篇文档中, 但是这篇博客有图片,这样更好理解.

Go runtime需要一个怎样的调度器

在分析新的调度器之前,我们需要理解为什么需要它? 为什么在有一个操作系统的线程调度器的基础上,需要一个用户态的调度器?

POSIX线程api是对现存的unix进程模型的逻辑扩展, 同样,线程控制和进程有很多相同之处. 线程有它自己的信号掩码、可以绑定到特定的cpu上、 可以被放入cgroups、可以查询它用了哪些资源. 所有这样控制方法增加了不必要的开销, 当然go程序也根本不会用到这些特性, 还有,当线程数迅速增加到100000时,开销也会急剧增加.

这里还有一个问题,根据go的模型,操作系统无法使调度器做出明智的决策. 例如,当go的垃圾回收触发时,需要停止所有的线程, 同时,所有的内存必须保持不变, 这将等待所有的运行的线程到达一个内存保持不变的状态.

当你有很多线程随机运行时,我们必须等待其中的大部分都到达一个一致的状态. Go调度器只能调度到一个它自己认为不变的状态. 这就意味着,当我们由于垃圾回收停止时,我们只需等待那些在cpu上运行着的线程.

我们的演员表

对于线程,一般来说我们有3种模型. 一个是N:1(多个用户态的线程在一个os线程上运行) 这种方式的优点是上下文切换很快,但是不能充分利用多核系统的优势. 另外一个是1:1(用户态线程和os线程一一对应) 这样方法充分的利用的多核系统的优势,但是由于必须陷入到os中造成的上下文切换很慢.

Go结合两者的优势(M:N调度器). 它调度一定数量的goroutines在一定数量的os线程上. 这样上下文切换很快,同时也利用了多核的优势. 这样模型最大的缺点就是它完全增加了调度器实现的复杂度.

为了实现任务的调度,go调度器用了3个主要的概念:

our-cast

三角形代表os线程.它是os负责管理的线程, 这和标准的POSIX线程很像.在runtime代码中,它被叫做M(代表机器).

圆形代表一个goroutine,它包括栈、指令指针和其他一些调度相关的重要信息, 比如它正阻塞在那个channel上. 在runtime代码中,它被叫做G.

正方形代表一个调度的上下文. 你可以把它看做一个局部化的调度器,它在单线程中执行Go代码. 这个是使我们从N:1转变到M:N一个重要的部分. 在runtime中,它被叫做P(处理器). 关于它,有必要多说一点.

in-motion

这里,我们看见2个线程(M),每个都有一个上下文(P), 每个都运行着一个goroutine(G). 为了运行goroutines,一个线程必须获得一个上下文环境.

上下文环境的数量一开始由GOMAXPROCS环境变量决定, 或者通过runtime函数GOMAXPROCS()设定. 通常情况下,在程序运行过程中,该值不会改变. 固定的上下文数量意味着在任何时候只有GOMAXPROCS个运行着的go程序. 我们可以利用它,根据不同的计算机去调节go程序的调用, 例如,在一个有4个core的pc上运行4个go程序.

被灰色标记的goroutines,表示还没有被运行,但是已经准备好被调度了. 他们被放在一个被称为运行队列的链表中. 当使用go关键字声明时,一个goroutine便被加入到运行队列的尾部. 一旦一个goroutine运行到一个调度点,上下文便从运行队列中取出一个goroutine, 设置好栈和指令指针,便开始运行新的goroutine.

为了减少互斥竞争,每个上下文环境有它自己的本地运行队列. 上一个版本的go调度器只有一个用互斥锁保护的全局运行队列. 很多线程常常等待锁的释放. 如果你想在一个32core的机器上充分发挥多核性能,这种损耗将非常影响性能.

只要所有的上下文都有goroutines执行,那么调度将保持这样稳定的状态. 但是,在一些场景下可能会改变这种稳定的状态.

你将执行系统调用

现在你可能会想,为什么要有上下文? 难道我们就不能直接运行队列绑定到线程上,从而废弃上下? 答案是不行,上下文存在的原因是, 当运行的线程由于一些原因需要阻塞时,我们可以将上下文转移到其他线程上.

例如,当我们调用一个系统调用,我们需要阻塞等待. 由于一个线程不能一边执行代码一边阻塞等待系统调用, 我们需要转移上下文保证持续的调度.

syscall

这里我们可以看出一个线程放弃他的上下文,这样另外的线程可以再用它. 调度器会保证有足够的线程运行所有的上下文. 在上述的图中M1也许是因为要处理这个系统调用而被创建, 或者它来自一个线程缓存. 处于系统调用的线程会和调用系统调用的goroutine绑定, 因为它还是在运行的,只不过是被阻塞在系统调用而已.

当系统调用返回,线程必须尝试获得一个上下文来继续执行goroutine. 通常的做法是从其他线程偷一个上下文. 如果不行,它便将goroutine放到一个全局的运行队列中, 然后将自己放入到线程缓存中并进入睡眠.

当本地的运行队列空了,上下文便会从全局队列中寻找. 上下文当然也会周期性的检查全局运行队列. 否则的话,全局队列中的goroutines会因为饥饿而长时间得不到执行.

这种处理系统调用的方式,就是即使GOMAXPROCS=1go程序也是多线程的原因. runtime使用调用系统调用的goroutine,而将线程保留了下来.

偷窃

另一个使稳定的系统状态发生变化的方法便是上下文执行完了被调度的goroutines. 这种情况出现在本地的运行队列分配不平衡的情况下, 这会导致一个上下文由于没有工作而饥饿,而系统中还有工作被执行. 为了持续运行go代码,一个上下文可以从全局运行队列中获取工作, 但是如果全局运行队列为空呢? 那么它将从其他的地方偷点工作来做.

steal

这里的其他的地方指的是其他的上下文. 当一个上下文没有工作可做时,它会尝试从别的一个上下文中偷一般的工作量来做. 这样的话就确保每个上下文总是有工作要做, 从而也就保证了所有的线程尽最大可能运行.

还有哪些

还有很多关于调度器更多的细节,例如cgo线程,LockOSThread()函数 以及和网络轮询器的集成.这些内容不再这篇博客的范围内, 但是仍然值得学习.也许以后,我会讲这些内容. 在go runtime库中有足够多的有趣的设计等待我们去发现.

PS: 自己写关于schedule代码的理解见这篇文章