Scheduling In Go系列文章
本文主要针对Go语言中的调度。
目录
Part I - OS Scheduler
Part II - Go Scheduler
Part III - Concurrency
Part II - Go Scheduler
这是一个由三部分组成的系列文章的第二篇,将提供对Go中调度器背后的机制和语义的理解。本文的重点是Go调度程序。
Introduction
在调度系列的第一节,我解释了操作系统调度器的一些方面,我认为这些方面对于理解和学习Go语言调度器的语义是重要的。在这篇文章中,我将在语义层次上介绍Go语言调度器是如何工作的并且集中于高层次行为。Go调度器是一个复杂的系统,一些小的机械细节是不重要的。重要的是要有一个好的模型来描述事物的工作和行为。这将有助于你更好地做出工程决定。
Your Program Starts
当你的Go程序启动,它将在定义在主机的每个虚拟核上分配一个逻辑处理器(P),如果你有一个每个物理核有多个硬件线程(Hyper-Threading)的处理器,每个硬件线程都将作为一个虚拟核呈现给Go程序。为了更好地理解这一点,请看Figure 1 中我的MacBookPro的系统报告。
你可以看到,我有一个有四个物理核的处理器。这个报告中没有展示我每个物理核的硬件线程数目。而Intel Core i7有超线程,这意味着每个物理核有两个硬件线程。这将向Go程序报告8个虚拟内核可用于并行执行OS线程。
为了对此进行测试,可以看一下下面的程序:
Listing 1
package main
import (
"fmt"
"runtime"
)
func main() {
// NumCPU returns the number of logical
// CPUs usable by the current process.
fmt.Println(runtime.NumCPU())
}
当我在我本地机器运行上面的程序时,NumCPU()函数将返回结果值8。任何我在我本地机器上运行的Go程序都将被分配8个处理器。
每个P将被分配一个OS线程(M),M代表机器。这个线程仍然是由OS管理的,如上一篇文章中描述的一样,OS仍然负责分配线程在核上运行。这意味着当我在本地运行Go程序时,我有8个线程可以用来执行我的工作,而每个线程又与一个P相关联。
每个Go程序会有一个初始的Goroutine(G),这是执行Go程序的路径。Goroutine本质上就是 Coroutine,由于这是在Go中,所以使用字母"G"替代"C",我们就得到了一个单词Goroutine。你可以认为Goroutine是应用程序层级的线程并且它在很多方面和OS线程相似。就像OS线程在内核中上下文切换进入和离开一样,Goroutine在M中上下文切换进入和离开。
最后一个难题是运行队列。Go调度器中有两个不同的运行队列:全局运行队列(GRQ)和局部运行队列(LRQ)。每个P都会被分配一个LRQ用来管理分配在P的上下文中运行的Goroutine。这些Goroutine轮流被上下文切换进入和离开分配给P的M。GRQ用于尚未分配到P的Goroutine。有一个将Goroutine从GRQ移动到LRQ的过程,我们稍后将讨论。
Figure 2提供了所有这些组件的图像。
Cooperating Scheduler
正如我们在第一篇中讲的那样,OS调度器是抢占式的。本质上这意味着在任何时间你都无法预测调度器将要做什么。内核正在做决定并且任何事情都是不确定的。运行在OS顶部的应用程序无法控制内核调度会做什么除非采用同步原件比如原子指令和锁调用。
Go调度器是Go运行时的一部分,Go运行时构建在应用程序中。这意味着Go调度器运行在内核之上的用户空间中。Go调度器的当前实现不是抢占式调度器,而是协作式调度器。作为一个协作调度器意味着调度器需要良好定义的用户空间事件,它应该发生在代码安全的点上,从而做出调度决定。
Go协作式调度器很妙的是它看上去和感觉起来都像是抢占式的。你无法预测Go调度器接下来将要做什么。这是因为这个协作式调度器不是依赖于开发者之手做出决策,而是由Go运行时做出决策。将Go调度器想象成抢占式调度器是非常重要的,因为这个调度器也是不确定的,这没什么大不了的。
Goroutine States
和线程一样,Goroutine也有相同的三个高级状态。这些命令指定Go调度器在任何给定Goroutine中所扮演的角色。Goroutine可以处于三种状态之一:等待、可运行或执行。
等待(Waiting):这意味着Goroutine停下来并且等待某个东西以继续执行。这可以是因为等待操作系统(系统调用)或者同步调用(原子和锁操作)。这种类型是造成性能差的根本原因。
可运行(Runnable):这意味着Goroutine想要获得在M上的时间片从而运行分配给它的指令,如果你有很多等待获得时间片的Goroutine,那么Goroutine将等待更长时间来获得时间片。当然,每个给定的Goroutine独立获得的时间片的数量会被缩减当更多的Goroutine竞争时间片时。这种类型的调度延时也会成为性能差的原因。
执行(Executing):这意味着Goroutine已经被放置在M上并且正在执行它的指令。与应用程序相关的工作正在完成。这是每个人都想要的。
Context Switching
Go调度器需要良好定义的用户空间事件,这发生在代码中的安全点,用来进行上下文切换。这些事件和安全点在函数调用中表现出来。对于健康的Go调度器来说函数调用是非常关键的。当前(Go1.11以及更低版本),如果你没有使用函数调用运行任何紧的循环,你将会在调度器和垃圾回收中产生延时。在合理的时间范围内调用函数是非常重要的。
注意:Go1.12的接受了一个建议,在Go调度器中应用非合作抢占技术,以允许对紧循环进行抢占。
在你的Go程序中,有四种事件允许调度器做出调度决策。这并不代表在这些事件其中之一一定会发生调度。但是这代表有调度器机会做出调度。
- 使用关键词go
- 垃圾回收
- 系统调用
- 同步和编排
使用关键词go
关键词go是用来创建Go协程(Goroutines)。一旦创建新的Goroutine,这给了调度器机会做出调度决策。
垃圾回收
尽管GC使用自己的Go协程集合,但是那些Go协程也需要在M上的时间片来运行。这会导致GC产生大量的调度混乱。然而,调度器对于Goroutine正在做什么非常敏捷明智,它将利用这种智能来做出明智的决策。明智决策之一就是会上下文切换掉一个在GC过程中想要调用堆的,用不会调用对的来替代。当GC在运行时,大量的调度决策也在产生。
系统调用
如果Goroutine做出一个系统调用,而这个系统调用会阻塞M,有时调度器会捕捉到上下文切换了Goroutine离开了M从而切换一个新的Goroutine到同一个M上去。但是,有时一个新的M需要继续执行在P中排队的Goroutine。在下一章节我们会详细解释这是如何工作的。
同步和编排
如果一个原子,锁或者管道操作将会导致Goroutine阻塞,调度器会上下文切换一个新的Goroutine来运行。一旦被阻塞的Goroutine可以再次被运行,它将重新排队并且最终会上下文切换回一个M上运行。
Asynchronous System Calls
当你运行的操作系统环境可以异步地处理系统调用,也被称作网络轮询,(network poller)能够更高效的处理系统调用。在各自的OS上,通过使用kqueue (MacOS), epoll (Linux) 或者 iocp (Windows)已经被完成了。
如今我们使用的大多数操作系统都可以异步的处理基于网络的系统调用。这就是网络轮询器名字的由来,尽管它的主要作用是是处理网络操作。网络系统通过使用网络轮询器,调度器可以在发生那些系统调用时防止Goroutine阻塞M。这有助于M可以继续用来运行P的LRQ上的其他Goroutine并且无需创建新的M,有助于减少OS的调度负载。
可以通过下面的例子来理解这是如何进行的。
Figure 3表示了我们基本的调度图,Goroutine-1在M上执行,在LRQ中有3个Goroutine在等待获得它们在M上的时间片。网络轮询器是空闲无事可做的。
在Figure 4中,Goroutine-1想要进行网络系统调用,因此Goroutine-1被移到网络轮询器上并且异步网络系统请求被处理。一旦Goroutine-1被移到网络轮询器上,M就处于空闲状态可以继续执行LRQ上不同的Goroutine。在这个例子中,Goroutine-2被上下文切换到M上。
在Figure 5中,异步网络系统调用由网络轮询器完成,Goroutine-1被移回到P的LRQ中。一旦Goroutine-1可以在M上进行上下文切换,它负责的Go相关代码就可以再次执行。这里最大的优势是,要执行网络系统调用,不需要额外的Ms。网络轮询器有一个操作系统线程,它负责一直处理一个高效的事件循环。
Synchronous System Calls
当Goroutine想要进行一个不能异步完成的系统调用时会发生什么?在这种情况下,不能使用网络轮询器并且进行调用系统Goroutine将会阻塞M。这是不幸的然而却没有办法阻止这种事情的发生。系统调用不能异步的一个例子是基于文件的系统调用。如果你在使用CGO,会有一些其他情况,在这些情况下调用C函数也会阻塞M。
注意:Windows OS没用能力异步进行基于文件的系统调用。技术上当在Windows上运行时,可以使用网络轮询器。
让我们来看看同步系统调用(如文件I/O)会导致M阻塞。
Figure 6再一次展示了我们的基础调度图,但是这一次Goroutine 1将要执行阻塞M1的同步系统调用。
在Figure 7中,调度器能够识别Goroutine-1已经使得M将要被阻塞,这时候,调度程序在阻塞Goroutine-1仍然连接M1的情况下从P分离M1。然后,调度程序引入一个新的M2服务于P。这时,Goroutine-2可以被从LRQ中选中并且上下文切换到M2。如果由于先前的交换M已经存在,这个过程将比必须创建一个新的M快一些。
在Figure 8中,由Goroutuin-1引起的阻塞系统调用完成,此时,Goroutine-1可以被放回LRQ中,由P继续负责。如果这种情况需要再次发生,M1将放在一旁以备将来使用。
Work Stealing
调度程序的另一个方面是它是一个工作窃取调度程序。这有助于在几个领域保持调度高效。举个例子,你最不想看到的事情就是M被移动到等待状态,因为一旦那种情况发生,OS会通过上下文切换使M离开内核。这意味着P的任何工作都无法被处理,即使有着可运行状态的Goroutine,直到M通过上下文切换回内核。工作窃取也有助于平衡经过P的所有Goroutine从而使得工作能够更好的被分发和更高效的被执行。
让我们浏览一个例子。
在Figure 9中,我们有一个多线程的有两个P的Go程序,每个P都服务于4个Goroutine,还有一个单独的Goroutine在GRQ。如果其中某个P快速服务完了它上面所有的Goroutine会发生什么呢?
在Figure 10中,P1没有Goroutine可以继续执行了。但是在P2的LRQ和GRQ中都存在Goroutine处于可运行状态。这时P1就需要窃取工作了,窃取工作(stealing work)规则如下。
Listing 2
runtime.schedule() {
// only 1/61 of the time, check the global runnable queue for a G.
// if not found, check the local queue.
// if not found,
// try to steal from other Ps.
// if not, check the global runnable queue.
// if not found, poll network.
}
所以基于Listing 2中的规则,P1需要从P2的LRQ中检查Goroutine并且取走它找到的Goroutine数目的一半。
在Figure 11中,P2的一半的Goroutine被P1取走,P1现在可以继续执行Goroutine了。
如果P2完成了它所有的Goroutine并且P1的LRQ中没有Goroutine了怎么办呢?
在Figure 12中,P2完成了它所有的Goroutine并且需要进行窃取。首先,它会检查P1的LRQ中的Goroutine,但是那里已经没有可以窃取的Goroutine。接下来,它会继续检查GRQ。在这里它找到了Goroutine-9。
在Figure 13中,P2从GRQ中窃取Goroutine-9并且开始执行。工作窃取非常棒的是它使得所有的M保持忙碌不空闲。在内部工作窃取被认为是旋转M。这种旋转有其他的收益,这一点JBD在她的 work-stealing博客文章中解释的很好。
Practical Example
有了这些机制和语义,我想向你展示所有这些是如何结合在一起的,从而允许Go调度器随着时间的推移执行更多的工作。设想一个用C编写的多线程应用程序,其中程序管理两个操作系统线程,这两个操作系统线程相互传递消息。
在Figure 14中,有两个线程在相互传递消息,Thread-1获得了内核1的上下文切换现在正在执行,这使得它可以向Thread-2发送消息。
注意:如何传递消息并不重要,重要的是在这个编排过程中线程的状态。
在Figure 15中,一旦Thread-1完成发送消息,它就需要等待回复。这会导致Thread-1上下文切换离开内核1并且移入等待状态。一旦Thread-2注意到这个消息,它就会转变为可执行状态。现在OS可以执行上下文切换使得Thread-2在一个内核上执行,这发生在内核2。接下来,Thread-2处理消息并向Thread-1发送回一个新的消息。
在Figure 16中,线程再一次上下文切换如同Thread-2接收到Thread-1的消息一样。现在Thread-2上下文切换从执行状态切换为等待状态,Thread-1从等待状态切换为可运行状态,最终回到执行状态,这使得Thread-1可以处理消息并发送回一个新的消息。
所有的这些上下文切换和状态切换都需要时间去执行,这将限制我们完成工作的速度。假设每个上下文切换潜在的会带来1000ns的延时,并且认为每ns硬件执行12个指令,大约有12k个指令在上下文切换期间不能执行。因为这些线程也会在内核之间进行来回移动,由于缓存行缺失带来额外延时的可能性也很高。
让我们使用同一个例子不过采用 Goroutines和Go调度器来代替。
在Figure 17中,编排中有两个Goroutine相互发送消息。G1上下文切换到了M1上,M1在内核1上运行,使得G1的工作可以被执行。这个工作内容就是从G1向G2发送消息。
在Figure 18中,G1完成了发送消息,现在它需要等待回复。这将使G1被上下文切换离开M1并且进入等待状态。一旦G2被告知发送给它的消息,它将进入可运行状态。这是Go调度器将执行一个上下文切换并且使得G2在M1上执行,M1仍然在内核1上。接下来,G2处理接收到的消息并且发送一个新的消息给G1。
在Figure 19中,当消息由G2发送G1接收,事情上下文切换再一次发生。现在G2由执行状态上下文切换到等待状态,而G1由等待状态上下文切换到可运行状态,最终切换为执行状态,这时它可以处理消息并且发送回一个新的消息。
表面上的情况似乎没有什么不同。无论使用线程还是Go协程,都会发生所有相同的上下文切换和状态更改。然而,使用线程和Goroutine之间有一个主要的区别,乍一看可能并不明显。
在使用Goroutine的例子中,同样的OS线程和内核一直被所有的处理所使用。这意味着从OS的角度来看,OS线程从未进入等待状态,一次也没有。这样做的结果就是,在使用线程进行上下文切换所丢失的那些指令在使用Goroutine时没有丢失。
本质上,Go语言在OS层次上将IO阻塞任务转变为CPU密集型任务。尽管所有的上下文切换发生在应用程序层级,但是我们没有损失在每个切换上下文时,像是使用线程时所损失的平均12k指令。这个调度器也同样提高了缓存行的效率和NUMA。这就是为什么我们不需要超过虚拟核数的线程数。在Go语言中,随着时间的推移,有可能完成更多的工作,因为Go调度器尝试使用更少的线程,在每个线程上执行更多的操作,这有助于减少OS和硬件上的负载。
Conclusion
Go调度器在设计中考虑到了操作系统和硬件工作的复杂性,这真的是非常棒。在操作系统级别,将IO/阻塞工作转换为CPU密集型工作的能力是我们随着时间推移利用更多CPU容量的一大优势。这就是为什么你不需要比拥有虚拟内核数目更多的操作系统线程。你可以合理地预期,每个虚拟核心只需要一个OS线程就可以完成所有工作(CPU和IO/阻塞绑定)。对于网络应用程序和其他不需要阻塞操作系统线程的系统调用的应用程序来说,这样做是可能的。
作为一个开发人员,你仍然需要根据你正在处理的工作种类,去了解你的应用程序正在做什么。你不可能创造无限数量的Goroutine,并期待惊人的表现。少即是多,但是理解了这些Go调度器语义,就可以做出更好的工程决策。在下一篇文章中,我将探索用保守的方式利用并发来获得更好性能的想法,同时仍然平衡你可能需要添加到代码中的复杂程度。
本文系翻译,有翻译不当的地方或者其他问题还请多多指教交流~
Scheduling In Go原文索引:
Scheduling In Go : Part I - OS Scheduler
Scheduling In Go : Part II - Go Scheduler
Scheduling In Go : Part III - Concurrency