专栏 Go 语言实战 文章详情
大彬 1.5k 发布于 Go 语言实战 关注专栏
1 天前发布
golang scheduler goroutine concurrency 并发
369 次阅读 . 读完需要 22 分钟
16
如果把语言比喻为武侠小说中的武功, 如果只是会用, 也就是达到四五层, 如果用的熟练也就六七层, 如果能见招拆招也得八九层, 如果你出神入化, 立于不败之地十层.
如果你想真正掌握一门语言的, 怎么也得八层以上, 需要你深入了解这门语言方方面面的细节.
希望以后对 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), 为了容易区分, 我们使用协程指用户态线程, 使用线程指内核态线程.
User-level threads, Application-level threads, Green threads 都指一样的东西, 就是不受 OS 感知的线程, 如果你 Google coroutine 相关的资料, 会看到它指的就是用户态线程, 在 Green threads 的维基百科 https://en.wikipedia.org/wiki/Green_threads 里, 看 Green threads 的实现列表, 你会看到好很多 coroutine 实现, 比如 Java,Lua,Go,Erlang,Common Lisp,Haskell,Rust,PHP,Stackless Python, 所以, 我认为用户态线程就是协程.
协程跟线程是有区别的, 线程由 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 https://golang.org/doc/faq (来自 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 年重新设计的 (设计方案 https://golang.org/s/go11sched ), 在这之前的调度器称为老调度器, 老调度器的实现不太好, 存在性能问题, 所以用了 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/runtime/proc.go
引用最多的调度器文章: https://morsmachine.dk/go-scheduler
kavya 的 PPT, 目前看到的讲调度最好的 PPT:
work stealing 论文: http://supertech.csail.mit.edu/papers/steal.pdf
分析调度器的论文 (就问你 6 不 6, 还有论文研究):
声明: 关于老调度器的资料已经完全搜不到, 根据新版调度器设计方案的描述, 想象着写了老调度器这一章, 可能存在错误.
参考资料
- https://en.wikipedia.org/wiki/Computer#History
- https://golang.org/doc/faq#goroutines
- https://golang.org/s/go11sched
- https://golang.org/src/runtime/proc.go
如果这篇文章对你有帮助, 请点个赞 / 喜欢, 感谢.
来源: https://segmentfault.com/a/1190000018451422