【协作式原创】查漏补缺之Goroutine的调度

注: 只讲了调度器,不涉及任何GC相关知识

预备知识

回顾一下Go调度器的发展历程

验证真抢占式调度是哪个版本引入的

Go Version Manager用于切换不同版本的Go
这里说到1.14之前都是请求抢占,1.14才是真抢占

Scheduler的宏观组成

参考附录11

  • Go调度器的主要职责就是将G公平合理的安排到多个M上去执行

共同梳理的几个问题

1. N:1 1:1 M:N 模型及其优缺点

  • 参考附录9
  • 参考附录14
    用户线程与KSE是1对1关系(1:1)。大部分编程语言的线程库(如linux的pthread,Java的java.lang.Thread,C++11的std::thread等等)都是对操作系统的线程(内核级线程)的一层封装,创建出来的每个线程与一个不同的KSE静态关联,因此其调度完全由OS调度器来做。

2. GPM模型

参考附录1

G: 提供要执行的用户代码。
M: 对应一个内核线程(LWP(tgid!=pid)),提供CPU资源。每个M必需绑定一个P才能运行G。M的数量比P多,一般不会多太多,最多10000个M(源码可看到) // 参考附录2图片
P: 提供执行G的其他资源,比如(本地可运行G队列,内存分配用到的缓存mcache)。

  • G的创建
    当通过go关键字创建一个新的goroutine的时候,它会优先被放入P的本地队列。

  • G的存放位置
    Go调度器工作时会维护两种用来保存G的任务队列:一种是一个Global任务队列,一种是每个P维护的Local任务队列。

  • G的选取和work-stealing
    当M执行完了当前P的Local队列里的所有G后,P也不会就这么在那躺尸啥都不干,它会先尝试从Global队列寻找G来执行,如果Global队列为空,它会随机挑选另外一个P,从它的队列里中拿走一半的G到自己的队列中执行。
    为了公平,调度器每调度 61 次的时候,都会尝试从全局队列里取出待运行的 goroutine 来运行。// 参考附录4(搜索61)

3. 怎么调度的?

TODO: 考虑回答 G出生,工作窃取,G阻塞 这3种情况的调度器处理?

4. Q: 大量goroutine时的调度开销

其他问题

抢占相关的问题 // 参考附录7-场景12

Q: 哪个版本实现了抢占调度?
Go调度在go1.12实现了抢占,应该更精确的称为请求式抢占?

Q: Go的抢占和操作系统的抢占有何不同?
请求抢占和强制抢占

Q: 什么情况会发送抢占请求。
A: 单个操作执行过久和运行时间过长。具体的:
抢占请求需要满足2个条件中的1个:1)G进行系统调用超过20us,2)G运行超过10ms。

Q: 谁来发送抢占请求
A: 启动的时候会启动一个单独的线程sysmon,它负责所有的监控工作,其中1项就是抢占,发现满足抢占条件的G时,就发出抢占请求。

Q: 说一下抢占调度
A: Go调度在go1.12实现了抢占,应该更精确的称为请求式抢占,那是因为go调度器的抢占和OS的线程抢占比起来很柔和,不暴力,不会说线程时间片到了,或者更高优先级的任务到了,执行抢占调度。go的抢占调度柔和到只给goroutine发送1个抢占请求,至于goroutine何时停下来,那就管不到了。抢占请求需要满足2个条件中的1个:1)G进行系统调用超过20us,2)G运行超过10ms。调度器在启动的时候会启动一个单独的线程sysmon,它负责所有的监控工作,其中1项就是抢占,发现满足抢占条件的G时,就发出抢占请求。

常见问题列表 TODO

参考附录1

12个问题 // 附录8

1. 为什么需要Go调度器?

我们需要内核调度器调度进程和内核线程,也就需要Go调度器调度协程(用户线程)。

  • 内核线程和用户线程(协程)
    协程的优点: 在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速。协程切换3个寄存器,内核线程需要切换几个寄存器和其他东西。//附录10

2. Go调度器与系统调度器有什么区别和关系/联系?

3. G、P、M是什么,三者的关系是什么?

4. P有默认几个?

所有的P都在程序启动时创建(也就是默认),并保存在数组中,最多有GOMAXPROCS个。

5. M同时能绑定几个P?

应该是1个

6. M怎么获得G?

参考附录11("Scheduler的宏观组成"的解释部分),附录12(源码的3个函数)

7. M没有G怎么办?

参考附录7-场景9
A: 自旋
为什么自旋? 我们希望当有新goroutine创建时,立刻能有m运行它,如果销毁再新建就增加了时延,降低了效率。
没有G的M都自旋吗? 当然也考虑了过多的自旋线程是浪费CPU,所以系统中最多有GOMAXPROCS个自旋的线程,多余的没事做线程会让他们休眠。

8. 为什么需要全局G队列?

和本地

9. Go调度器中的负载均衡的2种方式是什么?

10. work stealing是什么?什么原理?

done

11. 系统调用对G、P、M有什么影响?

done

12.Go调度器抢占是什么样的?一定能抢占成功吗?

done

Go抢占调度问题三连

  1. 我有个问题啊?go的抢占调度是让goroutine自己退出嘛
  2. 调度器会向这个goroutine发指令?
  3. 这个怎么做的?// 参考附录7(场景12)
  • 抢占的时机: 在函数调用前扩展栈时顺便查看自己的抢占flag,决定是否继续执行,还是让出自己。// PS. 也就是说,如果G没有函数调用,那么就会一直执行。

golang高并发模型

  • Q: G的调度时机
    参考附录13

Q: 死循环的问题是那个版本中处理的,如何处理的? // TODO

其他可能有用的

设计问题: 为什么GM要引入P

参考附录6论文.
A1: 官方解释,感觉很抽象,不好理解。
One of Vyukov’s plans is to create a layer of abstraction. He proposes to include another struct, P, to simulate processors.
An M would still represent an OS thread, and a G would still portray a goroutine.
There are exactly GOMAXPROCS P’s, and a P would be another required resource for an M in order for that M to execute Go code.

A2: 比起为了增加中间层而引入P,我觉得下面的理由更合理的解释了为什么引入P。
为了减小全局锁的影响,所以需要把全局队列拆分,拆分之后需要存放到一个地方,这个时候P就出现了。

出处 https://www.bilibili.com/video/av64926593 的 9分30秒左右

A3: 我的理解
我觉得第二种理解其实更符合我们的认知:
如果原来是一把大锁,锁定了全局协程队列这样一个共享资源,导致了很多激烈的锁竞争。
如果我们要优化,很自然的就能想到,把锁的粒度细化来提高减少锁竞争,也就是把全局队列拆除局部队列。具体实施的时候会有一下几个问题。

  1. 这些局部队列放在哪儿呢?我们引入了一个P用来放局部队列。
  2. 应该拆分为几个局部队列呢? n核就拆分为n个局部队列,因为最多能有n个任务同时执行,这样既能做到最大的并行度,有使得局部队列之间没有竞争。
    顺着这个思路,我们发现工作窃取也是很合理的,比如P1执行完本地队列,没必要闲着,可以去其他P的本地队列里偷任务来执行,这样可以更好的在可用的处理器之间分配任务。

A4: 其实就是借鉴linux2.4-linux2.6内核调度器优化的过程
参考: 关于中的Scalable>

参考资料

  1. GPM调度
  2. 深度解密Go语言之 scheduler
  3. 新官上任 —— Go sheduler 开始调度循环(五)
    深度解密调度器源码系列
  4. GO SCHEDULER: MS, PS & GS
    =>
  5. Analysis of the Go runtime scheduler
  6. Go调度器系列(3)图解调度原理
  7. Go调度器系列(4)源码阅读与探索
  8. Go调度器系列(1)起源
  9. 简单理解 Goroutine 是如何工作的
  10. Go调度器系列(2)宏观看调度器
  11. 锲而不舍 —— M 是怎样找工作的?(八)
  12. 看这2小节: goroutine调度时机和同步异步的系统调用
  13. 线程的实现模型主要有3个,分别是:用户级线程模型、内核级线程模型和两级线程模型

为什么进程需要阻塞态
三足鼎立 —— GPM 到底是什么?(一)
go语言调度器源代码情景分析

posted @ 2020-02-29 22:51  sicnu-yudidi  阅读(322)  评论(0编辑  收藏  举报