用户空间线程和内核空间线程之间的映射关系有:N:1,1:1和M:N

N:1是说,多个(N)用户线程始终在一个内核线程上跑,context上下文切换确实很快,但是无法真正的利用多核。
1:1是说,一个用户线程就只在一个内核线程上跑,这时可以利用多核,但是上下文switch很慢。
M:N是说, 多个goroutine在多个内核线程上跑,这个看似可以集齐上面两者的优势,但是无疑增加了调度的难度。

调度器的三个基本对象:

Golang 简称 Go,Go 的协程(goroutine) 和我们常见的线程(Thread)一样,拥有其调度器。

  • G (Goroutine),代表协程,也就是每次代码中使用 go 关键词时候会创建的一个对象,,它有自己的栈,instruction pointer和其他信息(正在等待的channel等等),用于调度。
  • M (Work Thread),工作线程
  • P (Processor),代表一个处理器,又称上下文,可以把它看做一个局部的调度器,使go代码在一个线程上跑,它是实现从N:1到N:M映射的关键。


G-M-P三者的关系与特点:

  • 每一个运行的 M 都必须绑定一个 P,线程M 创建后会去检查并执行G (goroutine)对象
  • 每一个 P 保存着一个协程G 的队列
  • 除了每个 P 自身保存的 G 的队列外,调度器还拥有一个全局的 G 队列
  • M 从队列中提取 G,并执行
  • P 的个数就是GOMAXPROCS(最大256),启动时固定的,一般不修改
  • M 的个数和 P 的个数不一定一样多(会有休眠的M 或 P不绑定M )(最大10000)
  • P 是用一个全局数组(255)来保存的,并且维护着一个全局的 P 空闲链表

image.png
golang MPG模型 - 图2
调用system call陷入内核没有返回之前,为保证调度的并发性,golang 调度器在进入系统调用之前从线程池拿一个线程或者新建一个线程,当前P交给新的线程M1执行。
image.png
G0返回之后,需要找一个可用的P继续运行,如果没有则将其放在全局队列等待调度。M0待G0返回后退出或放回线程池。

工作流窃取

在P队列上的goroutine全部调度完了之后,对应的M首先会尝试从global runqueue中获取goroutine进行调度。如果golbal runqueue中没有goroutine,当前M会从别的M对应P的local runqueue中抢一半的goroutine放入自己的P中进行调度。

局部G队列与全局G队列的关系

  • 全局G任务队列会和各个本地G任务队列按照一定的策略互相交换。没错,就是协程任务交换
  • G任务的执行顺序是,先从本地队列找,本地没有则从全局队列找
  • 转移:局部与全局,全局G个数 / P个数局部与局部,一次性转移一半
  • Contexts们也会周期性的检查全局G队列,否则全局G队列上的goroutine永远无法执行。

Gorutine从入队到执行

  1. 当我们创建一个G对象,就是goroutine,它会加入到本地队列或者全局队列
  2. 如果还有空闲的P,则创建一个M 绑定该 P ,注意!这里,P 此前必须还没绑定过M 的,否则不满足空闲的条件。细节点:
    1. 先找到一个空闲的P,如果没有则直接返回
    2. P 个数不会占用超过自己设定的cpu个数
    3. P 在被 M 绑定后,就会初始化自己的 G 队列,此时是一个空队列
    4. 注意这里!
      • 无论在哪个 M 中创建了一个 G,只要 P 有空闲的,就会引起新 M 的创建
      • 不需考虑当前所在 M 中所绑的 P 的 G 队列是否已满
      • 新创建的 M 所绑的 P 的初始化队列会从其他 G 队列中取任务过来
  3. M 会启动一个底层线程,循环执行能找到的 G 任务。这里的寻找的 G 从下面几方面找:
    • 当前 M 所绑的 P 队列中找
    • 去别的 P 的队列中找
    • 去全局 G 队列中找
  4. G任务的执行顺序是,先从本地队列找,本地没有则从全局队列找
  5. 程序启动的时候,首先跑的是主线程,然后这个主线程会绑定第一个 P
  6. 入口 main 函数,其实是作为一个 goroutine 来执行

    如果一个G任务执行时间太长,它就会一直占用 M 线程,由于队列的G任务是顺序执行的,其它G任务就会阻塞,如何避免该情况发生?

    协程的切换时间片是10ms,也就是说 goroutine 最多执行10ms就会被 M 切换到下一个 G。这个过程,又被称为中断、挂起
    原理:
    go程序启动时会首先创建一个特殊的内核线程sysmon ,用来监控和管理,其内部是一个循环:

  7. 记录所有 P 的 G 任务的技术schedtick,schedtick会在每执行一个G任务后递增

  8. 如果检查到schedtick一直没有递增,说明这个 P 一直在执行同一个 G 任务,如果超过10ms,就在这个G任务的栈信息里面加一个 tag 标记
  9. 然后这个 G 任务在执行的时候,如果遇到非内联函数调用,就会检查一次这个标记,然后中断自己,把自己加到队列末尾,执行下一个G
  10. 如果没有遇到非内联函数调用的话,那就会一直执行这个G任务,直到它自己结束;如果是个死循环,并且 GOMAXPROCS=1 的话。那么一直只会只有一个 P 与一个 M,且队列中的其他 G 不会被执行!

例子,下面的这段代码,hello world不会被输出

  1. func main(){
  2. runtime.GOMAXPROCS(1)
  3. go func(){
  4. fmt.Println("hello world")
  5. // panic("hello world") // 强制观察输出
  6. }()
  7. go func(){
  8. for {
  9. // fmt.Println("aaa") // 非内联函数,这行注释打开,将导致 hello world 的输出
  10. }
  11. }()
  12. select {}
  13. }


中断后的恢复

  1. 中断的时候将寄存器里的栈信息,保存到自己的 G 对象里面
  2. 当再次轮到自己执行时,将自己保存的栈信息复制到寄存器里面,这样就接着上次之后运行

image.png
image.png
image.png