10.1 重温调度器

调度器负责以有效的方式在可用资源上分配要完成的工作量。这节,我们将检查 Go 调度器的操作方式比上一章更深入些。您已经知道了 Go 工作使用 m:n 调度器(或M:N 调度器)来调度 goroutines——它比系统线程(使用系统线程)更轻量。然而先上我们来复习一些必要理论和一些有用的术语定义。

Go 使用 fork-join 并发模型。这个模型的 fork 部分代表一个程序在任何时间点创建的子分支。类似地,Go 并发模型的 join 部分是子分支将要接受的位置并加入它的父级。除此之外,收集 goroutines 结果的 sync.Wait() 和 channles 都是连接点,而任何新 goroutine 都会创建子分支。

fork-join 模型的 fork 阶段和 C 语言中的fork(2) 调用完全是两回事

公平调度策略是相当直接的,并有一个简单的实现,它在可用的处理器间均匀地共享所有负载。首先,这可能看起来是完美的策略,因为当它保持所有的处理器被均等占用时不必考虑太多事情。事实证明情况并非如此,因为大多数分布式任务通常依赖于其他任务。因此,最终一些处理器使用不足或者说一些处理器使用过剩。

在 Go 中 goroutine 是一个任务,而调用 goroutine 之后的所有内容是一个延续。在 Go 调度器使用的 work stealing 策略 中,一个未充分利用的(逻辑)处理器会从其他处理搜寻额外的工作。当它找到了这些工作,它就会从其他处理器偷取它们,这就是 work stealing 策略名称当由来。还有 Go 队列和窃取延续的 work-stealing 算法。一个间歇接合点,如其名所示,是一个执行线程在接合处间歇的点并开始寻找其他工作去做。尽管所有的任务窃取和延续窃取都有间歇接合点,但延续部分发生的更频繁;因此,Go 算法与延续部分工作而不是任务。

延续窃取的主要缺点是它需要来自编程语言的编译器的额外工作。幸运地,Go 提供了额外的帮助,因此在它的 work-stealing 策略中使用 延续窃取

延续窃取的一个好处是当您尽使用函数或具有多个 goroutine 的单个线程时,您可用获得相同的结果。这是完全合理的,因为这这两种情况下,在任何给定的时间都只执行一件事。

现在,让我们回到 Go 中使用的 m:n 调度算法。严格讲,在任何时候,您都有 m 个 goroutines 被执行并计划运行在 n 个系统线程上,使用最多 GOMAXPROCS 个逻辑处理器。稍后您将了解 GOMAXPROCS

Go 调度器使用三种主要实体工作:系统线程(M)与使用的操作系统有关,goroutines(G)和 逻辑处理器(P)。Go 程序能使用的处理器数量由 GOMAXPROCS 环境变量定义——在任何给定时间里有最多 GOMAXPROCS 个处理器。

下图说明了这点:

Go 调度器如何工作

这个插图告诉我们有俩个不同类型的队列:一个全局队列和一个关联到每个逻辑处理器的本地队列。全局队列里的 Goroutines 为了执行会被分配到逻辑处理器的队列中。因此,Go 调度器为了避免正在执行的 goroutines 只在每个逻辑处理器的本地队列中,需要检查全局队列。然而,全局队列不是总在被检查,意思是它没有比本地队列更有优势。另外,每个逻辑处理器有多个线程,并且窃取发生在可用逻辑处理器的本地队列之间。最后,注意当需要时, Go 调度器被允许创建跟多的系统线程。然而,系统线程相当昂贵,就是说处理系统线程太多会使您的 Go 应用变慢。

注意在程序中为了性能使用更多的 goroutines 不是万能方法,因为除了各种调用 sync.Add()sync.Wait(),和 sync.Done() 外,更多的 goroutines 由于需要 Go 调度器做额外工作会使您的程序变慢。

Go 调度器,作为大多数 Go 组件总是在进化,就是说负责 Go 调度器的人不断努力改进它的性能,通过对它的工作方式做微小改变。然而这个核心原理保持一致

所有这些细节有用吗?我想是的!为了使用 goroutines 写代码您需要知道所有这些吗?当然不!然而,当奇怪的事情发生时,或者您对 Go 调度器是如果工作的好奇,知道场景背后发生了什么一定能帮到您。这当然能使您成为更好的开发者!