线程数过多,意味着操作系统会不断地切换线程,频繁的上下文切换就成了性能瓶颈。
Golang的调度模型是GMP模型,它提供一种机制,可以在线程中自己实现调度,上下文切换更轻量,从而达到了线程数少,而并发数并不少的效果。而线程中调度的就是Goroutine.
调度的机制用一句话描述就是:runtime准备好G,M,P,然后M绑定P,M从本地或者是全局队列中获取G,然后切换到G的执行栈上执行G上的任务函数,调用goexit做清理工作并回到M,如此反复
接下来我来分模块介绍一下Golang的GMP模型及创建流程
G(goroutine)
CPU
M(machine)
P(processor)
runtime.GOMAXPROCS()
设置P的个数M必须拥有P才可以执行G中的代码,P含有一个包含多个G的队列,P可以调度G交由M执行
g、m、p数据结构均在
runtime/runtime2.go
下
g 的关键字段
type g struct {
stack stack // 当前G的栈范围
stackguard0 uintptr // 判读当前G是否被抢占
preempt bool // 抢占信号
preemptStop bool // 抢占时将状态修改成 `_Gpreempted`
preemptShrink bool // 在同步安全点收缩栈
_panic *_panic // 最内侧的 panic 结构体
_defer *_defer // 最内侧的延迟函数结构体
m *m // 当前G占用的线程
sched gobuf // 调度相关数据的存储
atomicstatus uint32 // G的状态
}
m的关键字段
type m struct {
g0 *g // 调度栈 使用的G
curg *g // 当前在M上运行的G
p puintptr // 正在运行代码的P
nextp puintptr // 暂存的P
oldp puintptr // 之前使用的P
}
p的关键字段
type p struct {
m muintptr // 调度的M
runqhead uint32 // G队列头
runqtail uint32 // G队列尾
runq [256]guintptr // G队列
runnext guintptr // 下一个可运行的G
status int // 当前P的状态
}
状态取值:
在介绍GMP概念的时候说到:P的个数默认等于CPU核数,每个M必须持有一个P才可以执行G,一般情况下M的个数会略大于P的个数,这多出来的M将会在G产生系统调用时发挥作用。类似线程池,Go也提供一个M的池子,需要时从池子中获取,用完放回池子,不够用时就再创建一个。
复用线程:避免频繁的创建、销毁线程,而是对线程的复用。
当M没有可运行的 G 时,尝试从其他线程M绑定的 P 偷取一半的G过来,而不是销毁线程。
work stealing机制触发:当前M线程的P本地队列中没有可运行的G时 并且 全局队列G中也没有可运行的G时,则会执行workstealing机制.
即:本地队列 → \rightarrow →全局队列 → \rightarrow →窃取
当M阻塞时,M释放绑定的 P(MP分离),把 P 转移给其他空闲的线程执行。
在 coroutine 中要等待一个协程主动让出 CPU 才执行下一个协程,在 Go 中,一个 goroutine 执行的时间不能超过 10ms,防止其他 goroutine 被饿死。
用户态阻塞/唤醒
例如网络IO、阻塞式channel、sleep等场景(简单来说就是CPU这时候对于这个协程没有事情要做),对于这类阻塞会将G暂时挂起到某一临时等待队列中,待阻塞结束后重新寻找P放入。
系统调用阻塞
M 执行某一个 G 时,如果发生系统调用或则其余阻塞操作,M 会阻塞,如果当前有 G 在执行,runtime 会将这个 MP 进行分离,如果有空闲的M就用或者是从线程池中取,如果没有就创建一个新的M 来服务于这个 P;
当 M 系统调用结束时候,这个 G 会尝试获取一个空闲的 P 执行,并放入到这个 P 的本地队列。如果获取不到 P,那么这个线程 M 变成休眠状态, 加入到空闲线程中,然后这个 G 会被放入全局队列中
判定阻塞的原理:
go程序启动时会首先创建一个特殊的内核线程 sysmon,用来监控和管理,其内部是一个循环:
记录所有 P 的 G 任务的计数 schedtick,schedtick会在每执行一个G任务后递增
如果检查到 schedtick 一直没有递增,说明这个 P 一直在执行同一个 G 任务,如果超过10ms,就在这个G任务的栈信息里面加一个 tag 标记
然后这个 G 任务在执行的时候,如果遇到非内联函数调用,就会检查一次这个标记,然后中断自己,把自己加到队列末尾,执行下一个G
如果没有遇到非内联函数 调用的话,那就会一直执行这个G任务,直到它自己结束;如果是个死循环,并且 GOMAXPROCS=1 的话。那么一直只会只有一个 P 与一个 M,且队列中的其他 G 不会被执行!
go func(){}
P的数量会由启动时环境变量$GOMAXPROCS 或是runtime的方法 GOMAXPROCS()来设定
问题来自B战评论
整体的逻辑与单线程调度器没有太多区别,因为我们的程序中可能同时存在多个活跃线程,所以多线程调度器引入了 GOMAXPROCS 变量帮助我们灵活控制程序中的最大处理器数,即活跃线程数。
这个GOMAXPROCS 到底是 P的个数,还是M的个数呢?
答:GOMAXPROCS是限制P的个数,你可以理解成M是线程,P是M需要执行G的时候需要持有的局部资源,只有M持有P的时候才有局部资源可以执行G。注意,也存在M持有G但是不持有P的情况,这时候一般是由于M持有P执行G的时候陷入了长时间的系统调用,被系统监控sysmon发现后将P夺走,将P给另一个M用来继续执行其他G,被夺走P的M此时陷入系统调用,不使用CPU了,也不执行G。因此,总体上可以这么认为,M如果需要访问CPU资源,那么就需要持有P,同时有多少个CPU核心,那么就有多少个P,同时也就有多少个M可以使用CPU。
如果想要了解更多请看文档或源码:
G-P-M 模型的设计者的文档