前言
线程数过多,意味着操作系统会不断地切换线程,频繁的上下文切换消耗性能
Golang的调度模型就是GMP模型,它可以提供一种机制,可以在线程中自己实现调度,上下文切换更加轻量,从而达到了线程数少,但是并发数并不少的效果。
描述:runtime准备好G、M、P,然后M绑定P,M从本地队列或全局队列中获取G,切换至G的执行栈上执行G的任务函数,执行完成后返回M,重复此过程。
官方注释:Goroutine 调度器的工作就是把“ready-to-run”的goroutine分发到线程中。
1. 基本概念
1.1 G(goroutine)
- Go协程:每个关键字go 都会创建一个 goroutine 它存储了 goroutine 执行的 stack 信息、goroutine 状态以及goroutine 的任务函数等。
- 在G的眼中,只有P,对于G而言 P就是CPU
1.2 M(machine)
- 工作线程
- M是真正调度系统的执行者,他会优先从关联的P的本地队列中直接获取可运行的G,如果本地队列中没有的话,再到调度器持有的全局队列中领取一些,或者是从其他的P的队列中偷取,M运行G,G执行之后,M会从P中获取下一个可执行的G
1.3 P(processor)
- Processor 处理器,它包括了运行 goroutine 的资源
- 它用于处理M和G的关系,如果线程M想要执行G,必须先获取P,P中还包含了可运行的G队列
- P的个数在程序启动时确定,默认为CPU核心数,但是也可以通过runtime.GOMAXPROCS() 设置
M必须拥有P才可以执行G中的代码,P含有一个包含多个G的队列,P可以调度G交由M执行。其关系如下图所示
图中M是交给操作系统调度的线程,M持有一个P,P将G调度进M中执行。P同时还维护着一个包含G的队列(图中灰色部分),可以按照一定的策略将不能的G调度进M中执行。
P的个数在程序启动时决定,默认情况下等同于CPU的核数,由于M必须持有一个P才可以运行Go代码,所以同时运行的M个数,也即线程数一般等同于CPU的个数,以达到尽可能的使用CPU而又不至于产生过多的线程切换开销。
2. 调度策略
2.1 队列轮转
每个P维护着一个包含G的队列,不考虑G进入系统调用或IO操作的情况下,P周期性的将G调度到M中执行,执行一小段时间,将上下文保存下来,然后将G放到队列尾部,然后从队列中重新取出一个G进行调度。
除了每个P维护的G队列以外,还有一个全局的队列,每个P会周期性的查看全局队列中是否有G待运行并将期调度到M中执行,全局队列中G的来源,主要有从系统调用中恢复的G。之所以P会周期性的查看全局队列,也是为了防止全局队列中的G被饿死。
2.2 系统调用
P的个数默认等于CPU核数,每个M必须持有一个P才可以执行G,一般情况下M的个数会略大于P的个数,这多出来的M将会在G产生系统调用时发挥作用。类似线程池,Go也提供一个M的池子,需要时从池子中获取,用完放回池子,不够用时就再创建一个。
当M运行的某个G产生系统调用时,如下图所示:
如图所示,当G0即将进入系统调用时,M0将释放P,进而某个空闲的M1获取P,继续执行P队列中剩下的G。而M0由于陷入系统调用而进被阻塞,M1接替M0的工作,只要P不空闲,就可以保证充分利用CPU。
M1的来源有可能是M的缓存池,也可能是新建的。当G0系统调用结束后,根据M0是否能获取到P,将会将G0做不同的处理:
- 如果有空闲的P,则获取一个P,继续执行G0。
- 如果没有空闲的P,则将G0放入全局队列,等待被其他的P调度。然后M0将进入缓存池睡眠。
2.3 工作量窃取
多个P中维护的G队列有可能是不均衡的,比如下图:
竖线左侧中右边的P已经将G全部执行完,然后去查询全局队列,全局队列中也没有G,而另一个M中除了正在运行的G外,队列中还有3个G待运行。此时,空闲的P会将其他P中的G偷取一部分过来,一般每次偷取一半。偷取完如右图所示。
3. 数据结构
3.1 g
type g struct {
// ...
m *m
// ...
sched gobuf
// ...
}
type gobuf struct {
sp uintptr
pc uintptr
ret uintptr
bp uintptr // for framepointer-enabled architectures
}
- m:在 p 的代理,负责执行当前 g 的 m;
- sched.sp:保存 CPU 的 rsp 寄存器的值,指向函数调用栈栈顶;
- sched.pc:保存 CPU 的 rip 寄存器的值,指向程序下一条执行指令的地址;
- sched.ret:保存系统调用的返回值;
- sched.bp:保存 CPU 的 rbp 寄存器的值,存储函数栈帧的起始位置.
状态
const(
_Gidle = itoa // 0 为协程开始创建时的状态,此时尚未初始化完成;
_Grunnable // 1 协程在待执行队列中,等待被执行;
_Grunning // 2 协程正在执行,同一时刻一个 p 中只有一个 g 处于此状态;
_Gsyscall // 3 协程正在执行系统调用;
_Gwaiting // 4 协程处于挂起态,需要等待被唤醒. gc、channel 通信或者锁操作时经常会进入这种状态;
_Gdead // 6 协程刚初始化完成或者已经被销毁,会处于此状态;
_Gcopystack // 8 协程正在栈扩容流程中;
_Gpreempted // 9 协程被抢占后的状态.
)
3.2 m
type m struct {
g0 *g // goroutine with scheduling stack
// ...
tls [tlsSlots]uintptr // thread-local storage (for x86 extern register)
// ...
}
- g0:一类特殊的调度协程,不用于执行用户函数,负责执行 g 之间的切换调度. 与 m 的关系为 1:1;
- tls:thread-local storage,线程本地存储,存储内容只对当前线程可见. 线程本地存储的是 m.tls 的地址,m.tls[0] 存储的是当前运行的 g,因此线程可以通过 g 找到当前的 m、p、g0 等信息.
3.3 p
type p struct {
// ...
runqhead uint32
runqtail uint32
runq [256]guintptr
runnext guintptr
// ...
}
- runq:本地 goroutine 队列,最大长度为 256.
- runqhead:队列头部;
- runqtail:队列尾部;
- runnext:下一个可执行的 goroutine.
3.4 schedt
type schedt struct {
// ...
lock mutex
// ...
runq gQueue
runqsize int32
// ...
}
sched 是全局 goroutine 队列的封装:
- lock:一把操作全局队列时使用的锁;
- runq:全局 goroutine 队列;
- runqsize:全局 goroutine 队列的容量.
4. 调度流程
4.1 两种g的切换
goroutine 的类型可分为两类:
- 负责调度普通 g 的 g0,执行固定的调度流程,与 m 的关系为一对一;
- 负责执行用户函数的普通 g.
m 通过 p 调度执行的 goroutine 永远在普通 g 和 g0 之间进行切换,当 g0 找到可执行的 g 时,会调用 gogo 方法,调度 g 执行用户定义的任务;当 g 需要主动让渡或被动调度时,会触发 mcall 方法,将执行权重新交还给 g0.
4.2 调度类型
通常,调度指的是由 g0 按照特定策略找到下一个可执行 g 的过程. 而本小节谈及的调度类型是广义上的“调度”,指的是调度器 p 实现从执行一个 g 切换到另一个 g 的过程.
这种广义“调度”可分为几种类型:
- 主动调度
一种用户主动执行让渡的方式,主要方式是,用户在执行代码中调用了 runtime.Gosched 方法,此时当前 g 会当让出执行权,主动进行队列等待下次被调度执行. - 被动调度
因当前不满足某种执行条件,g 可能会陷入阻塞态无法被调度,直到关注的条件达成后,g才从阻塞中被唤醒,重新进入可执行队列等待被调度.
常见的被动调度触发方式为因 channel 操作或互斥锁操作陷入阻塞等操作,底层会走进 gopark 方法. - 正常调度
g 中的执行任务已完成,g0 会将当前 g 置为死亡状态,发起新一轮调度. - 抢占调度:
倘若 g 执行系统调用超过指定的时长,且全局的 p 资源比较紧缺,此时将 p 和 g 解绑,抢占出来用于其他 g 的调度. 等 g 完成系统调用后,会重新进入可执行队列中等待被调度.
值得一提的是,前 3 种调度方式都由 m 下的 g0 完成,唯独抢占调度不同.
因为发起系统调用时需要打破用户态的边界进入内核态,此时 m 也会因系统调用而陷入僵直,无法主动完成抢占调度的行为.
因此,在 Golang 进程会有一个全局监控协程 monitor g 的存在,这个 g 会越过 p 直接与一个 m 进行绑定,不断轮询对所有 p 的执行状况进行监控. 倘若发现满足抢占调度的条件,则会从第三方的角度出手干预,主动发起该动作.
4.3 宏观调度流程 - 以 g0 -> g -> g0 的一轮循环为例进行串联;
- g0 执行 schedule() 函数,寻找到用于执行的 g;
- g0 执行 execute() 方法,更新当前 g、p 的状态信息,并调用 gogo() 方法,将执行权交给 g;
- g 因主动让渡( gosche_m() )、被动调度( park_m() )、正常结束( goexit0() )等原因,调用 m_call 函数,执行权重新回到 g0 手中;
- g0 执行 schedule() 函数,开启新一轮循环.
4.4 schedule函数
调度流程的主干方法是位于 runtime/proc.go 中的 schedule 函数,此时的执行权位于 g0 手中:
func schedule() {
// ...
gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available
// ...
execute(gp, inheritTime)
}
- 寻找到下一个执行的 goroutine;
- 执行该 goroutine.
4.5 findRunnable函数
调度流程中,一个非常核心的步骤,就是为 m 寻找到下一个执行的 g,这部分内容位于 runtime/proc.go 的 findRunnable 方法中:
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
_g_ := getg()
top:
_p_ := _g_.m.p.ptr()
// ...
if _p_.schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp = globrunqget(_p_, 1)
unlock(&sched.lock)
if gp != nil {
return gp, false, false
}
}
// ...
if gp, inheritTime := runqget(_p_); gp != nil {
return gp, inheritTime, false
}
// ...
if sched.runqsize != 0 {
lock(&sched.lock)
gp := globrunqget(_p_, 0)
unlock(&sched.lock)
if gp != nil {
return gp, false, false
}
}
if netpollinited() && atomic.Load(&netpollWaiters) > 0 && atomic.Load64(&sched.lastpoll) != 0 {
if list := netpoll(0); !list.empty() { // non-blocking
gp := list.pop()
injectglist(&list)
casgstatus(gp, _Gwaiting, _Grunnable)
return gp, false, false
}
}
// ...
procs := uint32(gomaxprocs)
if _g_.m.spinning || 2*atomic.Load(&sched.nmspinning) < procs-atomic.Load(&sched.npidle) {
if !_g_.m.spinning {
_g_.m.spinning = true
atomic.Xadd(&sched.nmspinning, 1)
}
gp, inheritTime, tnow, w, newWork := stealWork(now)
now = tnow
if gp != nil {
// Successfully stole.
return gp, inheritTime, false
}
if newWork {
// There may be new timer or GC work; restart to
// discover.
goto top
}
if w != 0 && (pollUntil == 0 || w < pollUntil) {
// Earlier timer to wait for.
pollUntil = w
}
}
- p 每执行 61 次调度,会从全局队列中获取一个 goroutine 进行执行,并将一个全局队列中的 goroutine 填充到当前 p 的本地队列中
if _p_.schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp = globrunqget(_p_, 1)
unlock(&sched.lock)
if gp != nil {
return gp, false, false
}
}
除了获取一个 g 用于执行外,还会额外将一个 g 从全局队列转移到 p 的本地队列,让全局队列中的 g 也得到更充分的执行机会.
func globrunqget(_p_ *p, max int32) *g {
if sched.runqsize == 0 {
return nil
}
n := sched.runqsize/gomaxprocs + 1
if n > sched.runqsize {
n = sched.runqsize
}
if max > 0 && n > max {
n = max
}
if n > int32(len(_p_.runq))/2 {
n = int32(len(_p_.runq)) / 2
}
sched.runqsize -= n
gp := sched.runq.pop()
n--
for ; n > 0; n-- {
gp1 := sched.runq.pop()
runqput(_p_, gp1, false)
}
return gp
}
- 尝试从 p 本地队列中获取一个可执行的 goroutine,核心逻辑位于 runqget 方法中:
func runqget(_p_ *p) (gp *g, inheritTime bool) {
if next != 0 && _p_.runnext.cas(next, 0) {
return next.ptr(), true
}
for {
h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with other consumers
t := _p_.runqtail
if t == h {
return nil, false
}
gp := _p_.runq[h%uint32(len(_p_.runq))].ptr()
if atomic.CasRel(&_p_.runqhead, h, h+1) { // cas-release, commits consume
return gp, false
}
...
}
- 倘若当前 p 的 runnext 非空,直接获取即可
- 加锁从 p 的本地队列中获取 g.
需要注意,虽然本地队列是属于 p 独有的,但是由于 work-stealing 机制的存在,其他 p 可能会前来执行窃取动作,因此操作仍需加锁.
但是,由于窃取动作发生的频率不会太高,因此当前 p 取得锁的成功率是很高的,因此可以说p 的本地队列是接近于无锁化,但没有达到真正意义的无锁. - 倘若本地队列为空,直接终止并返回;
- 倘若本地队列存在 g,则取得队首的 g,解锁并返回.
- 倘若本地队列没有可执行的 g,会从全局队列中获取:加锁,尝试并从全局队列中取队首的元素.
- 倘若本地队列和全局队列都没有 g,则会获取准备就绪的网络协程:
需要注意的是,刚获取网络协程时,g 的状态是处于 waiting 的,因此需要先更新为 runnable 状态. - work-stealing: 从其他 p 中偷取 g.
偷取操作至多会遍历全局的 p 队列 4 次,过程中只要找到可窃取的 p 则会立即返回.
为保证窃取行为的公平性,遍历的起点是随机的.
4.6 execute函数
当 g0 为 m 寻找到可执行的 g 之后,接下来就开始执行 g. 这部分内容位于 runtime/proc.go 的 execute 方法中:
func execute(gp *g, inheritTime bool) {
_g_ := getg()
_g_.m.curg = gp
gp.m = _g_.m
casgstatus(gp, _Grunnable, _Grunning)
gp.waitsince = 0
gp.preempt = false
gp.stackguard0 = gp.stack.lo + _StackGuard
if !inheritTime {
_g_.m.p.ptr().schedtick++
}
gogo(&gp.sched)
- 更新 g 的状态信息,建立 g 与 m 之间的绑定关系;
- 更新 p 的总调度次数;
- 调用 gogo 方法,执行 goroutine 中的任务.
- List item