【协作式原创】查漏补缺之Go调度器设计
主要回答的问题
- 进程,线程,协程区别
- 虚拟内存
- 局部性原理
- Go 1.0调度器的4个缺点
- Go 1.1调度器如何应对这4个缺点
- GMP模型
- 本地队列与全局队列的负载均衡
- 查看调度器日志
- GMP的状态转换
进程,线程,协程
TODO 虚拟内存
局部性原理
计算机组成原理,CPU缓存和局部性原理
老调度器(Go 1.0)有4个缺点
https://golang.org/s/go11sched
http://lessisbetter.site/2019/03/10/golang-scheduler-1-history/
What's wrong with current implementation:
1. Single global mutex (Sched.Lock) and centralized state. The mutex protects all goroutine-related operations (creation, completion, rescheduling, etc).
创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争
。
解释:
从源码中可以看到很多schedlock()的调用。比如,G状态的修改需要防止并发修改。
https://sourcegraph.com/github.com/golang/go@release-branch.go1/-/blob/src/pkg/runtime/proc.c#L471
https://sourcegraph.com/github.com/golang/go@release-branch.go1/-/blob/src/pkg/runtime/proc.c#L252
优化思路:
很自然的就能想到,把锁的粒度细化来提高减少锁竞争,也就是把全局队列拆除局部队列。
具体实施:
具体实施的时候会有一下几个问题。
- 这些局部队列放在哪儿呢?我们引入了一个P用来放局部队列。
- 应该拆分为几个局部队列呢? 与CPU核数一致。因为最多能有n个M同时执行,这样既能做到最大的并行度,又使得局部队列之间没有竞争。
顺着这个思路,我们发现工作窃取也是很合理的,比如P1执行完本地队列,没必要闲着,可以去其他P的本地队列里偷任务来执行,这样可以更好的在可用的处理器之间分配任务。
2. Goroutine (G) hand-off (G.nextg). Worker threads (M's) frequently hand-off runnable goroutines between each other, this may lead to increased latencies and additional overheads. Every M must be able to execute any runnable G, in particular the M that just created the G.
M转移G会造成延迟和额外的系统负载
。// 参考附录1的图示来讲解
比如当G中包含创建新协程的时候,M创建了G’,为了继续执行G,需要把G’交给M’执行,也造成了很差的局部性
,因为G’和G是相关的,最好放在M上执行,而不是其他M’。
3. Per-M memory cache (M.mcache). Memory cache and other caches (stack alloc) are associated with all M's, while they need to be associated only with M's running Go code (an M blocked inside of syscall does not need mcache). A ratio between M's running Go code and all M's can be as high as 1:100. This leads to excessive resource consumption (each MCache can suck up up to 2M) and poor data locality.
M中的mcache是预先分配用来存放小对象的内存。
当M阻塞时,mcache内存是没有用到的。而M阻塞与M运行的比例大概是100:1,所以大部分M的mcache都是浪费的,我们可以考虑剥夺阻塞M的mcache给其他M用。// 附录1图片
解释:mcache其实就是预先分配的一点内存用于存放小对象。 // 附录2
1. mcache:每个运行期的goroutine都会绑定的一个mcache(具体来讲是绑定的GMP并发模型中的P,所以可以无锁分配mspan,后续还会说到),mcache会分配goroutine运行中所需要的内存空间(即mspan)。
2. 为了避免多个goroutine申请内存时不断的加锁,`为每个goroutine分配了span内存块的缓存`,这个缓存即是mcache,每个goroutine都会绑定的一个mcache,各个goroutine申请内存时不存在锁竞争的情况。
3. 我们再来看看mcache具体的结构:mcache中的span链表分为两组,一组是包含指针类型的对象,另一组是不包含指针类型的对象。为什么分开呢?
4. 对于 <=32k的对象,将直接通过mcache分配。
4. Aggressive thread blocking/unblocking. In presence of syscalls worker threads are frequently blocked and unblocked. This adds a lot of overhead.
系统调用导致频繁的线程阻塞和取消阻塞操作增加了系统开销。
解释:
GM时,GM的状态基本是一致的,G阻塞一定会导致内核线程M阻塞。M阻塞和唤醒就会造成系统开销。
优化思路:
很自然可以想到,G阻塞时,想办法让M可以被继续使用。
部分解决:
引入P解决了异步系统调用情况下的 阻塞线程和取消阻塞线程 的系统开销。但是没有解决同步系统调用情况下阻塞M的问题。// 附录3
更具体的阻塞场景:Go调度器如何处理goroutine阻塞的情况? // TODO: 这里需要去了解channel的机制。
新调度器
Q: 为什么还要保留全局队列。// 类似地方政府和中央政府,大家有事都找中央政府,协调不过来。
A1: 简短回答. 提供本地队列到全局队列的负载均衡。
A2: 详细回答.
- 如果只有本地队列,就需要加锁访问本地队列,带来额外开销。
因为本地队列空时,P空闲需要去窃取其他P的本地队列,本地队列满时,需要往其他P送G。这两种情况都会造成多个P访问一个本地队列的情况,所以需要加锁访问。 - 如果我们保留全局队列,又有本地队列,那么任意P的本地队列是只有P自己才能访问的,所以不需要加锁。(创建,删除,修改状态都没有并发问题)
全局队列仍然需要加锁访问,但是访问的次数远小于访问本地队列的次数。本地队列空了就去全局队列拿,满了往全局队列放。空了满了是少数情况,大部分正常执行无锁访问本地队列的。
负载均衡
- 空的时候和满的时候。借助全局队列进行负载均衡。
- 为了防止全局队列被饥饿,"为了公平,每调用 schedule 函数 61 次就要从全局可运行 goroutine 队列中获取"
https://mp.weixin.qq.com/s/pRCWOKlI48soY0deOWoSEw
GPM的状态转换 // TODO
查看调度器日志
- Q: go启动的线程数量
可以使用go tool 打印调度器的日志。
http://lessisbetter.site/2019/03/26/golang-scheduler-2-macro-view/
https://colobu.com/2016/04/19/Scheduler-Tracing-In-Go/
Scheduler Tracing In Go
其他
我们在做系统改进时,每当引入一种解决方案时,除了关注其优点,还需要关注新方案引入了哪些新问题,在解决旧问题和引入新问题之间找到平衡点。