在线时间:8:00-16:00
迪恩网络APP
随时随地掌握行业动态
扫描二维码
关注迪恩网络微信公众号
原创翻译文章,转载请注明出处:服务器非业余研究-sunface
简介 Go1.1更新中最重要的特性之一就是由Dmitry Vyukov开发的全新的调度器。新的调度器能极大的提高Go并行程序的性能并且不需要对程序进行修改,因此我认为应该写一篇文章为大家介绍下新版的调度器特性。 这篇文章所写的绝大部分内容都可以在original design doc找到 ——这是篇很有技术含量而且很通俗易懂的文章,你所需要的知识都可以从这篇文章中获取,而且由于插入了配图,因此相对来说更清晰易读。
Go运行时系统需要用调度器来做什么? 首先我们需要了解下为什么需要新的调度器?为什么在操作系统能对线程进行调度的情况下还要创建一个用户空间的调度器? 对现存的Unix进程模型来说,POSIX线程API是一种逻辑上扩展,线程在许多控制方式上和进程是一样的。线程拥有自己的信号掩码,并且可以被绑定到CPU,也可以被放入linux Cgroups并查询这些线程使用了哪些资源。这些线程的特性对于使用goroutine的Go程序来说并不怎么需要,而且当你拥有超过100000线程的时候,你会发现非常难以控制如此庞大的系统。 另一个问题是操作系统在基于Go模型的时候往往并不能做出正确的调度。比如,Go的垃圾回收器(GC)在回收的时候需要所有的线程都停止并且要求所有线程的内存都保持一致的状态,所以GC会等待一个正在运行的线程,直到该线程执行到某个内存会达到一致状态的点。 当你在某个时间点有许多线程需要被调度的时候,有可能会发生这种情况:你要等待这些线程都达到内存一致的状态,因为只有在Go调度器知道了线程的内存达到一致状态后才会继续执行调度。这样会导致当程序停下来进行GC的时候,我们只有等待,等待那些在某个CPU上还继续活跃的线程。 接下来谁会是我们的主角呢? 有三种线程模型是较为常用的 : 第一种是N:1, 多个用户空间线程会运行在同一个OS线程上,这种模型上下文切换很迅速但是并不能利用多核系统的优势; 第二种是1:1模型,一个线程对应一个OS线程,这种模型下可以利用机器的所有CPU核心,但是上下文切换比较慢,因为要经过系统层的切换。 Go通过使用M:N的映射方式来利用前两种模型的优点。它会在任意数量的OS线程上调度任意数量的goroutines,这样既能获得快速的上下文切换也能利用系统中的所有CPU核心,但是也有不利的地方:会增加调度器调度的复杂度。 Go使用了三种实体模型来实现调度:
三角形代表了OS线程,它是由OS来管理执行的线程并且工作模式很像标准的POSIX线程,在运行时代码中,用M来代表机器(machine)。 圆形代表一个goroutine,包含了栈、指令指针和调度该goroutine所需的其他重要信息,比如可能阻塞该goroutine的任何channel,在运行时代码中,用G来代表goroutine. 矩形代表调度过程的上下文(context),可以看作是一个在单独线程上运行Go代码的调度器的本地化版本(localized version),它是让我们从N:1调度器映射变为M:N调度器映射的重要组成部分,在运行时代码中,用P代表处理器。
这里有两个线程(M),每一个都拥有一个上下文(P),每一个都运行着一个gorotine(G )。为了能运行gorotines,一个线程必须拥有一个上下文. 系统上下文的数量是在启动的时候被设置为环境变量GOMAXPROCS或者runtime.GOMAXPROCS(),事实上上下文的数量是固定的,所以在任何时候都只有GOMAXPROCS个Go代码段在运行,比如4核心的PC会在4个线程上运行着4段Go代码。 灰色的goroutine虽然没有在运行,但是做好了被调度的准备。它们被排列在runqueues列表中。无论在何时,某个goroutine执行了go语句后都会在runqueue的末端添加该goroutine。一旦一个goroutine能被调度时,运行这个goroutine的上下文就会把该goroutine弹出runqueue,设置栈和指令指针后就开始运行这个goroutine。 为了减少互斥和竞争(mutex contention),每个上下文都有自己本地的runqueue。而早期版本的调度器仅仅维护着一个使用互斥量(mutex)保护的全局runqueue,这种情况下线程会经常因为等待互斥量解锁而被阻塞,如果你想在32核心的机器上使用多个goroutines来压榨机器性能,可能会发现因为早期版本调度器的原因,性能反而变得很糟糕。 只要上下文有goroutine需要运行,调度器就会持续稳定的进行调度,然而有些场景可能会改变这种稳定调度的状况。
你准备调用我们的哪位主角呢? 你可能会想为什么我们需要上下文这东东?为什么我们不能抛开上下文然后把runqueues放在线程上呢?其实我们使用上下文的原因是:即使正在运行的线程阻塞了,也可以切换到其他线程继续处理。其中一个例子就是当我们执行系统调用的时候,线程就得进入阻塞,因为线程无法在被系统调用阻塞的同时还继续运行代码,所以需要通过上下文切换来继续调度运行代码。
这里我们能看到其中一个线程放弃了它的上下文,这样其他线程就可以运行在这个上下文中,调度器会保证有足够的线程能运行所有的上下文。为了能处理这次系统调用,可能会创建或者从线程缓存(thread cache)中获取上图中的M1。执行系统调用的那个线程会继续持有那个执行了系统调用的goroutine,因为这个线程虽然被OS阻塞了,但是技术上来说还是在运行的,因此不会goroutine不会被切换出去。 当系统调用返回的时候,为了继续运行返回的goroutine,当前线程必须尝试获取上下文。通常的模式是从其它线程中的一个窃取上下文,如果不能窃取的话,当前线程会将goroutine放入全局(global)runqueue中,然后将自己放入线程缓存中并进入休眠。 当上下文执行完本地runqueue后会从全局runqueue获取goroutines,上下文也会周期性的检查全局runqueue,否则全局runqueue中的goroutines永远都不会被运行。 上面这种处理系统调用的方法就是Go程序甚至能在GOMAXPROCS被设置为1的时运行在多个线程上的原因,因此运行时(runtime)使用goroutines来调用系统调用而不是使用线程。
如何窃取? 另外一种能改变调度器持续稳定调度的情况是:某个上下文的runqueque中再没有要被调度的goroutine。这种改变当各个runqueque之间分配的工作不平衡的时候也会发生,这个会导致在某个上下文runqueue空了后,该上下文就会自己结束,甚至会发生在系统中还有工作等待运行的时候。因此为了保持Go代码的运行,上下文可以从全局runqueque中获取goroutines,但是如果全局runqueque中没有goroutines的话,该上下文不得不从其他上下文runqueue中窃取goroutines,大约窃取另一个上下文runqueue中一般的内容。这种方法可以保证每个上下文都有工作可以做,也可以保证所有的线程都尽最大的能力努力工作。
何去何从 调度器还有很多细节:cgo线程,LockOSThread()函数,集成了网络轮询(network poller)。这些已经超出了此文章的范围,但是仍然很值得学习。在Go的运行时库中还有很多很多有趣的事物等待着我们去探索开发,以后我也会继续写一些相关的文章进行介绍。 原文作者 Daniel Morsing |
请发表评论