前言
GoLang有一个特性,就是原生支持协程,协程是一种用户态的线程,它的调度和切换是在用户态去做的,也就是由Go的调度器来实现,但是最终的执行工作还是由内核线程去完成,也就是一批协程通过相应进程的lwp与一批内核线程进程映射。
Go调度器组成
Go程序通过调度器来调度Goroutine在内核级线程上执行,但是并不直接绑定os线程M-Machine运行,而是由Goroutine Scheduler中的 P-processor作获取内核线程资源的【中介】。
Go的调度器通常被称为G-M-P模型
G
G:Goroutine,每个Gotoutine对应一个G结构体,G存储Goroutine的栈,指令指针,还有一些对于调用goroutines很重要的其它信息,比如阻塞它的任何channel,以及任务函数,可重用(函数实体)G需要保存到P才能被调度执行,简单的说,G就是一个协程,里面包含了相应的栈,寄存器,执行的函数指令等,可以看成是一个任务体。
type g struct {
stack stack // 描述了真实的栈内存,包括上下界
m *m // 当前的m
sched gobuf // goroutine切换时,用于保存g的上下文
param unsafe.Pointer // 用于传递参数,睡眠时其他goroutine可以设置param,唤醒时该goroutine可以获取
atomicstatus uint32
stackLock uint32
goid int64 // goroutine的ID
waitsince int64 // g被阻塞的大体时间
lockedm *m // G被锁定只在这个m上运行
}
Goroutine的栈在发展过程中出现了两种处理策略,详细可看:https://zhuanlan.zhihu.com/p/352537561
其中最主要的当然是sched了,保存了goroutine的上下文。goroutine切换的时候不同于线程有OS来负责这部分数据,而是由一个gobuf对象来保存,这样能够更加轻量级,再来看看gobuf的结构:
type gobuf struct {
sp uintptr
pc uintptr
g guintptr
ctxt unsafe.Pointer
ret sys.Uintreg
lr uintptr
bp uintptr // for GOEXPERIMENT=framepointer
}
其实就是保存了当前的栈指针,计数器,当然还有g自身,这里记录自身g的指针是为了能快速的访问到goroutine中的信息。
M
M:Machine,os内核线程抽象,所有M是有线程栈的。如果不对该线程栈提供内存的话,系统会给该线程栈提供内存(不同操作系统提供的线程栈大小不同)。当指定了线程栈,则M.stack→G.stack,M的PC寄存器指向G提供的函数,然后去执行
type m struct {
g0 *g // 带有调度栈的goroutine
gsignal *g // 处理信号的goroutine
tls [6]uintptr // thread-local storage
mstartfn func()
curg *g // 当前运行的goroutine
caughtsig guintptr
p puintptr // 关联p和执行的go代码
nextp puintptr
id int32
mallocing int32 // 状态
spinning bool // m是否out of work
blocked bool // m是否被阻塞
inwb bool // m是否在执行写屏蔽
printlock int8
incgo bool // m在执行cgo吗
fastrand uint32
ncgocall uint64 // cgo调用的总数
ncgo int32 // 当前cgo调用的数目
park note
alllink *m // 用于链接allm
schedlink muintptr
mcache *mcache // 当前m的内存缓存
lockedg *g // 锁定g在当前m上执行,而不会切换到其他m
createstack [32]uintptr // thread创建的栈
}
结构体M中有两个G是需要关注一下的,一个是curg,代表结构体M当前绑定的结构体G。另一个是g0,是带有调度栈的goroutine,这是一个比较特殊的goroutine。普通的goroutine的栈是在堆上分配的可增长的栈,而g0的栈是M对应的线程的栈。所有调度相关的代码,会先切换到该goroutine的栈中再执行。也就是说线程的栈也是用的是g实现,而不是使用的OS的。
P
代表一个处理器,每一个运行的M都必须绑定一个P,就像线程必须在么一个CPU核上执行一样,由P来调度G在M上的运行,P的个数就是GOMAXPROCS(最大256),启动时固定的,一般不修改;M的个数和P的个数不一定一样多(会有休眠的M或者不需要太多的M)(M最大10000);每一个P保存着本地G任务队列,也有一个全局G任务队列。P的数据结构:
type p struct {
lock mutex
id int32
status uint32 // 状态,可以为pidle/prunning/...
link puintptr
schedtick uint32 // 每调度一次加1
syscalltick uint32 // 每一次系统调用加1
sysmontick sysmontick
m muintptr // 回链到关联的m
mcache *mcache
racectx uintptr
goidcache uint64 // goroutine的ID的缓存
goidcacheend uint64
// 可运行的goroutine的队列
runqhead uint32
runqtail uint32
runq [256]guintptr
runnext guintptr // 下一个运行的g
sudogcache []*sudog
sudogbuf [128]*sudog
palloc persistentAlloc // per-P to avoid mutex
pad [sys.CacheLineSize]byte
其中P的状态有Pidle, Prunning, Psyscall, Pgcstop, Pdead;在其内部队列runqhead里面有可运行的goroutine,P优先从内部获取执行的g,这样能够提高效率。
除此之外,还有一个数据结构需要在这里提及,就是schedt,可以看做是一个全局的调度者:
type schedt struct {
goidgen uint64
lastpoll uint64
lock mutex
midle muintptr // idle状态的m
nmidle int32 // idle状态的m个数
nmidlelocked int32 // lockde状态的m个数
mcount int32 // 创建的m的总数
maxmcount int32 // m允许的最大个数
ngsys uint32 // 系统中goroutine的数目,会自动更新
pidle puintptr // idle的p
npidle uint32
nmspinning uint32
// 全局的可运行的g队列
runqhead guintptr
runqtail guintptr
runqsize int32
// dead的G的全局缓存
gflock mutex
gfreeStack *g
gfreeNoStack *g
ngfree int32
// sudog的缓存中心
sudoglock mutex
sudogcache *sudog
}
大多数需要的信息都已放在了结构体M、G和P中,schedt结构体只是一个壳。可以看到,其中有M的idle队列,P的idle队列,以及一个全局的就绪的G队列。schedt结构体中的Lock是非常必须的,如果M或P等做一些非局部的操作,它们一般需要先锁住调度器。
GMP模型简介
本地队列:存放等待运⾏的G,一个本地队列存放的G数量一般不超过256个,优先将新创建的G放在P的本地队 列中,如果满了会放在全局队列中。
全局队列:存放等待运行的G,读写要加锁,所以拿取效率在多线程竞争的情况下相比于本地队列来说要低。
调度器的设计策略
复用线程
避免频繁的创建、销毁线程,⽽是对线程的复⽤
work stealing机制
当本线程⽆可运⾏的G时,尝试 从其他线程绑定的P偷取G,⽽不 是销毁线程。
全局的Goroutine队列,当Work Stealing失败,M可以从这个队列获取G任务。
hand off机制
当本线程因为G进⾏系统调⽤阻 塞时,线程释放绑定的P,把P转 移给其他空闲的线程执⾏。
利⽤并⾏
GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运⾏。
抢占
在coroutine中要等待⼀个协程主动让出CPU才执⾏下⼀个协程,在Go 中,⼀个goroutine最多占⽤CPU 10ms,防⽌其他goroutine被饿死.
“go func()” 经历了什么过程
-
我们通过 go func()来创建⼀个goroutine;
-
有两个存储G的队列,⼀个是局部调度器P的本地队列、⼀个是全局G队列。新创建的G会先 保存在P的本地队列中,如果P的本地队列已经满了就会保存在全局的队列中;
-
G只能运⾏在M中,⼀个M必须持有⼀个P,M与P是1:1的关系。M会从P的本地队列弹出⼀个可执 ⾏状态的G来执⾏,如果P的本地队列为空,就会想其他的MP组合偷取⼀个可执⾏的G来执⾏;
-
⼀个M调度G执⾏的过程是⼀个循环机制;
-
当M执⾏某⼀个G时候如果发⽣了syscall或则其余阻塞操作,M会阻塞,如果当前有⼀些G在执⾏, runtime会把这个线程M从P中摘除(detach),然后再创建⼀个新的操作系统的线程(如果有空闲的线程可 ⽤就复⽤空闲线程)来服务于这个P;
-
当M系统调⽤结束时候,会尝试获取⼀个空闲的P执⾏,并把G放⼊到这个P的本地队列。如果获取不到P,那么这个线程M变成休眠状态, 加⼊到空闲线程中,然后这个G会被放⼊全局队列中。
调度器的⽣命周期
M0
M0是启动程序后的编号为0的主线程,这个M对应的实例会在全局变量runtime.m0中,不需要在heap 上分配,M0负责执⾏初始化操作和启动第⼀个G, 在之后M0就和其他的M⼀样了。
G0
GOMAXPROCSg0
看下面的例子,
package main
import "fmt"
func main() {
fmt.Println("Hello world")
}
上面代码的流程图:
GOMAXPROCSruntime.mainmain.mainruntime.maindeferpanicruntime.exit
注意点
局部性原则
P拥有G1,M1获取P后开始运⾏G1,G1使⽤go func()创建了G2,为了局部性G2优先加⼊到P1的本地队列。
在创建G时,运⾏的G会尝试唤醒其他空闲的P和M组合去执⾏
假定G2唤醒了M2,M2绑定了P2,并运⾏G0,但P2本地队列没有G,M2此时为⾃旋线程(没有G但为运⾏ 状态的线程,不断寻找G)。
M2尝试从全局队列(简称“GQ”)取⼀批G放到P2的本地队列(函数:findrunnable())。M2从全局队列取 的G数量符合下⾯的公式: n = min(len(GQ)/GOMAXPROCS + 1, len(GQ/2))
这里看起来会和之前的先从别的P的本地队列偷再从全局队列偷有冲突。其实是没冲突的,情况1是当某个M把自己本地队列的G执行完了,那么先去别的M的本地队列取偷,情况2是从休眠中被唤醒的M,处于自旋状态,先从全局偷。
G发生阻塞的系统调用后
G8创建了G9, G8进⾏了阻塞的系统调⽤,M2和P2⽴即解绑,P2会执⾏以下判断:如果P2本地队列有G、全局队列有G 或有空闲的M,P2都会⽴⻢唤醒1个M和它绑定,否则P2则会加⼊到空闲P列表,等待M来获取可⽤的p(这里我觉得和上面历程的第5点有冲突,不应该让p空闲,而是会立刻创建一个m,然后让p去偷g执行,这样减少了p的空闲时间)。 本场景中,P2本地队列有G9,可以和其他空闲的线程M5绑定。
G发生非阻塞的系统调用后
M2和P2会解绑,但M2会记住P2,然后G8和M2进⼊系统调⽤状态。当G8和M2退出系统调⽤时,会尝试获 取P2,如果⽆法获取,则获取空闲的P,如果依然没有,G8会被记为可运⾏状态,并加⼊到全局队列,M2因为没 有P的绑定⽽变成休眠状态(⻓时间休眠等待GC回收销毁)