乐趣区

Go语言高阶:调度器系列(1)起源

如果把语言比喻为武侠小说中的武功,如果只是会用,也就是达到四五层,如果用的熟练也就六七层,如果能见招拆招也得八九层,如果你出神入化,立于不败之地十层。
如果你想真正掌握一门语言的,怎么也得八层以上,需要你深入了解这门语言方方面面的细节。
希望以后对 Go 语言的掌握能有八九层,怎么能不懂调度器!?
Google、百度、微信搜索了许多 Go 语言调度的文章,这些文章上来就讲调度器是什么样的,它由哪些组成,它的运作原理,搞的我只能从这些零散的文章中形成调度器的“概貌”,这是我想要的结果,但这还不够。
学习不仅要知其然,还要知其所以然。
学习之前,先学知识点的历史,再学知识,这样你就明白,为什么它是当下这个样子。
所以,我打算写一个 goroutine 调度器的系列文章,从历史背景讲起,循序渐进,希望大家能对 goroutine 调度器有一个全面的认识。
这篇文章介绍调度器相关的历史背景,请慢慢翻阅。
远古时代

上面这个大家伙是 ENIAC,它诞生在宾夕法尼亚大学,是世界第一台真正的通用计算机,和现代的计算机相比,它是相当的“笨重”,它的计算能力,跟现代人手普及的智能手机相比,简直是一个天上一个地下,ENIAC 在地下,智能手机在天上。
它上面没有操作系统,更别提进程、线程和协程了。
进程时代

后来,现代化的计算机有了操作系统,每个程序都是一个进程,但是操作系统在一段时间只能运行一个进程,直到这个进程运行完,才能运行下一个进程,这个时期可以成为单进程时代——串行时代。
和 ENIAC 相比,单进程是有了几万倍的提度,但依然是太慢了,比如进程要读数据阻塞了,CPU 就在哪浪费着,伟大的程序员们就想了,不能浪费啊,怎么才能充分的利用 CPU 呢?
后来操作系统就具有了最早的并发能力:多进程并发,当一个进程阻塞的时候,切换到另外等待执行的进程,这样就能尽量把 CPU 利用起来,CPU 就不浪费了。
线程时代

多进程真实个好东西,有了对进程的调度能力之后,伟大的程序员又发现,进程拥有太多资源,在创建、切换和销毁的时候,都会占用很长的时间,CPU 虽然利用起来了,但 CPU 有很大的一部分都被用来进行进程调度了,怎么才能提高 CPU 的利用率呢?
大家希望能有一种轻量级的进程,调度不怎么花时间,这样 CPU 就有更多的时间用在执行任务上。
后来,操作系统支持了线程,线程在进程里面,线程运行所需要资源比进程少多了,跟进程比起来,切换简直是“不算事”。
一个进程可以有多个线程,CPU 在执行调度的时候切换的是线程,如果下一个线程也是当前进程的,就只有线程切换,“很快”就能完成,如果下一个线程不是当前的进程,就需要切换进程,这就得费点时间了。
这个时代,CPU 的调度切换的是进程和线程。多线程看起来很美好,但实际多线程编程却像一坨屎,一是由于线程的设计本身有点复杂,而是由于需要考虑很多底层细节,比如锁和冲突检测。
协程

多进程、多线程已经提高了系统的并发能力,但是在当今互联网高并发场景下,为每个任务都创建一个线程是不现实的,因为会消耗大量的内存(每个线程的内存占用级别为 MB),线程多了之后调度也会消耗大量的 CPU。伟大的程序员们有开始想了,如何才能充分利用 CPU、内存等资源的情况下,实现更高的并发?
既然线程的资源占用、调度在高并发的情况下,依然是比较大的,是否有一种东西,更加轻量?
你可能知道:线程分为内核态线程和用户态线程,用户态线程需要绑定内核态线程,CPU 并不能感知用户态线程的存在,它只知道它在运行 1 个线程,这个线程实际是内核态线程。
用户态线程实际有个名字叫协程(co-routine),为了容易区分,我们使用协程指用户态线程,使用线程指内核态线程。
协程跟线程是有区别的,线程由 CPU 调度是抢占式的,协程由用户态调度是协作式的,一个协程让出 CPU 后,才执行下一个协程。
协程和线程有 3 种映射关系:

N:1,N 个协程绑定 1 个线程,优点就是协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速。但也有很大的缺点,1 个进程的所有协程都绑定在 1 个线程上,一是某个程序用不了硬件的多核加速能力,二是一旦某协程阻塞,造成线程阻塞,本进程的其他协程都无法执行了,根本就没有并发的能力了。
1:1,1 个协程绑定 1 个线程,这种最容易实现。协程的调度都由 CPU 完成了,不存在 N:1 缺点,但有一个缺点是协程的创建、删除和切换的代价都由 CPU 完成,有点略显昂贵了。
M:N,M 个协程绑定 1 个线程,是 N:1 和 1:1 类型的结合,克服了以上 2 种模型的缺点,但实现起来最为复杂。

协程是个好东西,不少语言支持了协程,比如:Lua、Erlang、Java(C++ 即将支持),就算语言不支持,也有库支持协程,比如 C 语言的 coroutine(风云大牛作品)、Kotlin 的 kotlinx.coroutines、Python 的 gevent。
goroutine
Go 语言的诞生就是为了支持高并发,有 2 个支持高并发的模型:CSP 和 Actor。鉴于 Occam 和 Erlang 都选用了 CSP(来自 Go FAQ),并且效果不错,Go 也选了 CSP,但与前两者不同的是,Go 把 channel 作为头等公民。
就像前面说的多线程编程太不友好了,Go 为了提供更容易使用的并发方法,使用了 goroutine 和 channel。goroutine 来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被 runtime 调度,转移到其他可运行的线程上。最关键的是,程序员看不到这些底层的细节,这就降低了编程的难度,提供了更容易的并发。
Go 中,协程被称为 goroutine(Rob Pike 说 goroutine 不是协程,因为他们并不完全相同),它非常轻量,一个 goroutine 只占几 KB,并且这几 KB 就足够 goroutine 运行完,这就能在有限的内存空间内支持大量 goroutine,支持了更多的并发。虽然一个 goroutine 的栈只占几 KB,但实际是可伸缩的,如果需要更多内容,runtime 会自动为 goroutine 分配。
Go 语言的老调度器
终于来到了 Go 语言的调度器环节。
调度器的任务是在用户态完成 goroutine 的调度,而调度器的实现好坏,对并发实际有很大的影响,并且 Go 的调度器就是 M:N 类型的,实现起来也是最复杂。
现在的 Go 语言调度器是 2012 年重新设计的(设计方案),在这之前的调度器称为老调度器,老调度器的实现不太好,存在性能问题,所以用了 4 年左右就被替换掉了,老调度器大概是下面这个样子:

最下面是操作系统,中间是 runtime,runtime 在 Go 中很重要,许多程序运行时的工作都由 runtime 完成,调度器就是 runtime 的一部分,虚线圈出来的为调度器,它有两个重要组成:

M,代表线程,它要运行 goroutine。
Global G Queue,是全局 goroutine 队列,所有的 goroutine 都保存在这个队列中,goroutine 用 G 进行代表。

M 想要执行、放回 G 都必须访问全局 G 队列,并且 M 有多个,即多线程访问同一资源需要加锁进行保证互斥 / 同步,所以全局 G 队列是有互斥锁进行保护的。
老调度器有 4 个缺点:

创建、销毁、调度 G 都需要每个 M 获取锁,这就形成了激烈的锁竞争。
M 转移 G 会造成延迟和额外的系统负载。比如当 G 中包含创建新协程的时候,M 创建了 G’,为了继续执行 G,需要把 G’交给 M’执行,也造成了很差的局部性,因为 G’和 G 是相关的,最好放在 M 上执行,而不是其他 M ’。
M 中的 mcache 是用来存放小对象的,mcache 和栈都和 M 关联造成了大量的内存开销和差的局部性。
系统调用导致频繁的线程阻塞和取消阻塞操作增加了系统开销。

Go 语言的新调度器
面对以上老调度的问题,Go 设计了新的调度器,设计文稿:https://golang.org/s/go11sched
新调度器引入了:

P:Processor,它包含了运行 goroutine 的资源,如果线程想运行 goroutine,必须先获取 P,P 中还包含了可运行的 G 队列。
work stealing:当 M 绑定的 P 没有可运行的 G 时,它可以从其他运行的 M’那里偷取 G。

现在,调度器中 3 个重要的缩写你都接触到了,所有文章都用这几个缩写,请牢记:

G: goroutine

M: 工作线程

P: 处理器,它包含了运行 Go 代码的资源,M 必须和一个 P 关联才能运行 G。

这篇文章的目的不是介绍调度器的实现,而是调度器的一些理念,帮助你后面更好理解调度器的实现,所以我们回归到调度器设计思想上。

调度器的有两大思想:
复用线程:协程本身就是运行在一组线程之上,不需要频繁的创建、销毁线程,而是对线程的复用。在调度器中复用线程还有 2 个体现:1)work stealing,当本线程无可运行的 G 时,尝试从其他线程绑定的 P 偷取 G,而不是销毁线程。2)hand off,当本线程因为 G 进行系统调用阻塞时,线程释放绑定的 P,把 P 转移给其他空闲的线程执行。
利用并行:GOMAXPROCS 设置 P 的数量,当 GOMAXPROCS 大于 1 时,就最多有 GOMAXPROCS 个线程处于运行状态,这些线程可能分布在多个 CPU 核上同时运行,使得并发利用并行。另外,GOMAXPROCS 也限制了并发的程度,比如 GOMAXPROCS = 核数 /2,则最多利用了一半的 CPU 核进行并行。
调度器的两小策略:
抢占:在 coroutine 中要等待一个协程主动让出 CPU 才执行下一个协程,在 Go 中,一个 goroutine 最多占用 CPU 10ms,防止其他 goroutine 被饿死,这就是 goroutine 不同于 coroutine 的一个地方。
全局 G 队列:在新的调度器中依然有全局 G 队列,但功能已经被弱化了,当 M 执行 work stealing 从其他 P 偷不到 G 时,它可以从全局 G 队列获取 G。
上面提到并行了,关于并发和并行再说一下:Go 创始人 Rob Pike 一直在强调 go 是并发,不是并行,因为 Go 做的是在一段时间内完成几十万、甚至几百万的工作,而不是同一时间同时在做大量的工作。并发可以利用并行提高效率,调度器是有并行设计的。
并行依赖多核技术,每个核上在某个时间只能执行一个线程,当我们的 CPU 有 8 个核时,我们能同时执行 8 个线程,这就是并行。

结束语
这篇文章的主要目的是为后面介绍 Go 语言调度器做铺垫,由远及近的方式简要介绍了多进程、多线程、协程、并发和并行有关的“史料”,希望你了解为什么 Go 采用了 goroutine,又为何调度器如此重要。
如果你等不急了,想了解 Go 调度器相关的原理,看下这些文章:

设计方案:https://golang.org/s/go11sched

代码中关于调度器的描述:https://golang.org/src/runtim…

引用最多的调度器文章:https://morsmachine.dk/go-sch…

kavya 的 PPT,目前看到的讲调度最好的 PPT:https://speakerdeck.com/kavya…

work stealing 论文:http://supertech.csail.mit.ed…

分析调度器的论文(就问你 6 不 6,还有论文研究):http://www.cs.columbia.edu/~a…

声明:关于老调度器的资料已经完全搜不到,根据新版调度器设计方案的描述,想象着写了老调度器这一章,可能存在错误。
参考资料

https://en.wikipedia.org/wiki…
https://en.wikipedia.org/wiki…
https://en.wikipedia.org/wiki…
https://golang.org/doc/faq#go…
https://golang.org/s/go11sched
https://golang.org/src/runtim…

如果这篇文章对你有帮助,请点个赞 / 喜欢,感谢。
本文作者:大彬

如果喜欢本文,随意转载,但请保留此原文链接:http://lessisbetter.site/2019/03/10/golang-scheduler-1-history

退出移动版