Golang协程调度原理( G、M、P)
前序
正确地认识 G , M , P 三者的关系,能够对协程的调度机制有更深入的理解! 本文将会完整介绍完 go 协程的调度机制,包含:
- 调度对象的主要组成
- 各对象的关系 与 分工
- gorutine 协程是如何被执行的
- 内核线程 sysmon 对 gorutine 的管理
- gorutine 协程中断挂起 与 恢复
- GOMAXPROCS 如何影响 go 的并发性能
调度器的三个基本对象:
Golang 简称 Go,Go 的协程(goroutine)
和我们常见的线程(Thread)
一样,拥有其调度器。
- G (Goroutine),代表协程,也就是每次代码中使用
go 关键词
时候会创建的一个对象 - M (Work Thread),工作线程,一个M代表了一个内核线程,等同于系统线程,
- P (Processor),代表一个
处理器
,用来管理和执行goroutine,一个P代表了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 空闲链表
三者关系:G需要绑定在M上才能运行,M需要绑定P才能运行。
局部G队列与全局G队列的关系
- 全局G任务队列会和各个本地G任务队列按照一定的策略互相交换。没错,就是
协程任务
交换 - G任务的执行顺序是,先从本地队列找,本地没有则从
全局队列
找 - 转移
- 局部与全局,全局G个数 / P个数
- 局部与局部,一次性转移一半
Gorutine从入队到执行
- 当我们创建一个G对象,就是
gorutine
,它会加入到本地队列或者全局队列 - 如果还有空闲的P,则创建一个M 绑定该 P ,注意!这里,P 此前必须还没绑定过M 的,否则不满足空闲的条件。细节点:
- 先找到一个空闲的P,如果没有则直接返回
- P 个数不会占用超过自己设定的cpu个数
- P 在被 M 绑定后,就会初始化自己的 G 队列,此时是一个
空队列
- 注意这里的
一个点
!- 无论在哪个 M 中创建了一个 G,只要 P 有空闲的,就会引起新 M 的创建
- 不需考虑当前所在 M 中所绑的 P 的 G 队列是否已满
- 新创建的 M 所绑的 P 的初始化队列会从其他 G 队列中取任务过来
- 这里留下第一个问题: 如果一个G任务执行时间太长,它就会一直占用 M 线程,由于队列的G任务是顺序执行的,其它G任务就会阻塞,如何避免该情况发生? --①
- M 会启动一个
底层线程
,循环执行
能找到的 G 任务。这里的寻找的 G 从下面几方面找:G任务的执行顺序是,先从本地队列找,本地没有则从全局队列找- 当前 M 所绑的 P 队列中找
- 去别的 P 的队列中找
- 去全局 G 队列中找
- G任务的执行顺序是,先从本地队列找,本地没有则从全局队列找
- 程序启动的时候,首先跑的是主线程,然后这个主线程会绑定第一个 P
- 入口 main 函数,其实是作为一个 goroutine 来执行
解答问题-①
协程的切换时间片是10ms,也就是说 goroutine 最多执行10ms就会被 M 切换到下一个 G。这个过程,又被称为 中断,挂起
原理:
go程序启动时会首先创建一个特殊的内核线程 sysmon
,用来监控和管理,其内部是一个循环:
- 记录所有 P 的 G 任务的
计数 schedtick
,schedtick会在每执行一个G任务后递增 - 如果检查到
schedtick
一直没有递增,说明这个 P 一直在执行同一个 G 任务,如果超过10ms,就在这个G任务的栈信息里面加一个 tag 标记 - 然后这个 G 任务在执行的时候,如果遇到非内联函数调用,就会检查一次这个标记,然后中断自己,把自己加到队列末尾,执行下一个G
- 如果没有遇到
非内联函数
调用的话,那就会一直执行这个G任务,直到它自己结束;如果是个死循环,并且 GOMAXPROCS=1 的话。那么一直只会只有一个 P 与一个 M,且队列中的其他 G 不会被执行!
--------
线程分为内核态线程和用户态线程,用户态线程需要绑定内核态线程,CPU并不能感知用户态线程的存在,它只知道它在运行1个线程,这个线程实际是内核态线程。
用户态线程实际有个名字叫协程(co-routine),为了容易区分,我们使用协程指用户态线程,使用线程指内核态线程。
协程跟线程是有区别的,线程由CPU调度是抢占式的,协程由用户态调度是协作式的,一个协程让出CPU后,才执行下一个协程。
调度器的有两大思想:
复用线程:协程本身就是运行在一组线程之上,不需要频繁的创建、销毁线程,而是对线程的复用。在调度器中复用线程还有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。
全局G队列:在新的调度器中依然有全局G队列,但功能已经被弱化了,当M执行work stealing从其他P偷不到G时,它可以从全局G队列获取G。
摘自:https://cloud.tencent.com/developer/article/1442315