基本思路:
- p的本地队列与锁优化,详解cas无锁取g的实现
- work stealing(任务窃取)机制:详解该机制的实现,如何提高cpu利用率的
- hand off(切换移交)机制:详解该机制的实现,如何避免线程阻塞导致资源限制的
- 协作式抢占:详解gmp模型的调度时机
- 资源复用:详解m和g的复用机制,如何减少m和g的创建开销和上下文切换开销
1. 程序的启动
启动阶段由 runtime·rt0_go
(核心启动函数) 汇编函数调用,调用顺序为:
runtime·osinit
:获取系统信息(如CPU核心数)runtime·schedinit
:初始化调度器。runtime·newproc
:创建主Goroutine(执行runtime.main
)。runtime·mstart
:启动调度循环。
具体作用在 Go 调度器(二) 章中讲过,不再赘述;
m0 和 g0 是什么:
m0:一个程序会启动多个m,第一个启动的叫m0
g0:每个m创建后都会创建一个g0,用于执行 runtime 下的调度工作
m0的启动是在runtime·rt0_go
(核心启动函数) 汇编函数函数中执行的(同时也会初始化一个g0和m0绑定),然后调用mstart->mstart1->schedule启动调度循环;
非m0的启动首先从 startm 方法开始启动,然后从空闲的p链表中获取一个p与m绑定,最后 schedule 启动调度循环;
2. cas无锁化机制的实现
...
3. work stealing
当p的本地队列为空时,m不会空转,而是从其他p的本地队列中取出带运行的g提供给m执行,最大化提高cpu利用率。
3.1 核心源码分析
work stealing是发生在调度循环中的,所以需要分析下调度器是如何进行调度循环的;GMP源码中主要由schedule函数去处理调度器的调度循环,进入到这个方法中里永远不再返回,请看源码分析:
go
func schedule() {
mp := getg().m // 获取当前线程(m)的运行时结构体指针
if mp.locks != 0 { // 检查持有锁的状态(防止持有锁时触发调度导致死锁)
throw("schedule: holding locks")
}
if mp.lockedg != 0 { // 处理锁定的 Goroutine
stoplockedm()
execute(mp.lockedg.ptr(), false)
}
if mp.incgo { // 禁止在 CGO 调用上下文中调度
throw("schedule: in cgo")
}
// 上面是在做进入循环调度前的检查
// 下面开始进入循环调度的入口, 进入后不再返回
top:
pp := mp.p.ptr() // 获取当前m绑定的p(逻辑处理器)
pp.preempt = false // 重置 p 的抢占标志位(表示开始新一轮调度)
// 自旋状态(mp.spinning=true)表示 M 正在跨 P 窃取任务
// 若本地队列(pp.runnext 或 pp.runq)非空却仍自旋,属逻辑错误
if mp.spinning && (pp.runnext != 0 || pp.runqhead != pp.runqtail) {
throw("schedule: spinning with local work")
}
//------------------------------------------------------------核心---------------------------------------------------------
// 循环调度获取可运行的g
// 返回值:
// gp:目标 Goroutine
// inheritTime:是否继承剩余时间片(避免过度抢占)
// tryWakeP:是否需要唤醒空闲 p(当窃取到阻塞任务时)
gp, inheritTime, tryWakeP := findRunnable()
//-------------------------------------------------------------------------------------------------------------------------
...
// 清除 m 的自旋标记并更新全局计数器
if mp.spinning {
resetspinning()
}
...
// 当 findRunnable 发现阻塞任务时, 唤醒新线程处理
if tryWakeP {
wakep()
}
if gp.lockedm != 0 {
startlockedm(gp)
goto top
}
// 切换到目标 Goroutine 执行用户代码
execute(gp, inheritTime)
}
findRunnable
源码解析,Work-Stealing
的核心机制在这里实现:
go
/*
作用: 为当前m找到可运行的 goroutine
主要分为四步:
1.
*/
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
mp := getg().m
top:
pp := mp.p.ptr()
...
// 当前p先判断每处理61个任务就去全局队列中获取g, 确保调度的公平
// 在"sched.runqsize/gomaxprocs + 1"、"max"、"len(_p_.runq))/2"三个数字中取最小的数字作为获取的G数量
if pp.schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp := globrunqget(pp, 1) // 此处是获取1个g
unlock(&sched.lock)
if gp != nil {
return gp, false, false
}
}
// 从p的本地队列中获取
if gp, inheritTime := runqget(pp); gp != nil {
return gp, inheritTime, false
}
// 从全局队列获取(此处在"sched.runqsize/gomaxprocs + 1"、"len(_p_.runq))/2"两个数字中取最小的数字作为获取的G数量)
if sched.runqsize != 0 {
lock(&sched.lock)
gp := globrunqget(pp, 0)
unlock(&sched.lock)
if gp != nil {
return gp, false, false
}
}
// 从epoll里取
if netpollinited() && netpollAnyWaiters() && sched.lastpoll.Load() != 0 {
if list, delta := netpoll(0); !list.empty() { // non-blocking
gp := list.pop()
injectglist(&list)
netpollAdjustWaiters(delta)
trace := traceAcquire()
casgstatus(gp, _Gwaiting, _Grunnable)
if trace.ok() {
trace.GoUnpark(gp, 0)
traceRelease(trace)
}
return gp, false, false
}
}
// work stealing机制的核心代码
if mp.spinning || 2*sched.nmspinning.Load() < gomaxprocs-sched.npidle.Load() {
if !mp.spinning {
mp.becomeSpinning()
}
gp, inheritTime, tnow, w, newWork := stealWork(now)
if gp != nil {
return gp, inheritTime, false
}
if newWork {
goto top
}
now = tnow
if w != 0 && (pollUntil == 0 || w < pollUntil) {
pollUntil = w
}
}
...
// 没找到就阻塞, 等待唤醒再次执行循环调度
stopm()
goto top
}
work stealing
核心源码解析:
go
// 参数:now int64:当前时间戳(纳秒),用于计时器检查
// 返回值:
// gp *g:窃取到的 Goroutine
// inheritTime bool:是否继承剩余时间片(本函数固定返回 false)
// rnow/pollUntil int64:更新时间戳和下一个计时器到期时间
// newWork bool:是否发现新任务(如 GC 任务或触发的计时器)
func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) {
pp := getg().m.p.ptr()
ranTimer := false
const stealTries = 4
for i := 0; i < stealTries; i++ {
stealTimersOrRunNextG := i == stealTries-1 // 仅最后一次循环 (i=3) 处理计时器和 runnext 队列,避免高频操作
for enum := stealOrder.start(cheaprand()); !enum.done(); enum.next() { // heaprand() 生成随机起始点,避免全局竞争热点
if sched.gcwaiting.Load() {
return nil, false, now, pollUntil, true
}
p2 := allp[enum.position()]
if pp == p2 { // 跳过自身
continue
}
// 最后一次循环还没找到可运行的g, 则从随机到的p中取出一个可运行的g(保底)
if stealTimersOrRunNextG && timerpMask.read(enum.position()) {
tnow, w, ran := p2.timers.check(now) // 检查目标p的计时器
now = tnow
if w != 0 && (pollUntil == 0 || w < pollUntil) {
pollUntil = w
}
// 计时器已触发, 从p的本地队列中获取可执行的g
if ran {
if gp, inheritTime := runqget(pp); gp != nil {
return gp, inheritTime, now, pollUntil, ranTimer
}
ranTimer = true
}
}
if !idlepMask.read(enum.position()) { // 目标p非空闲
if gp := runqsteal(pp, p2, stealTimersOrRunNextG); gp != nil { // 尝试窃取目标p本地队列中一半的g
return gp, false, now, pollUntil, ranTimer
}
}
}
}
return nil, false, now, pollUntil, ranTimer
}
// 参数:pp:当前 P(执行窃取的 P)、p2:目标 P(被窃取的 P)、stealRunNextG:是否窃取 p2.runnext(最高优先级任务)
// 返回值:返回窃取的 其中一个 Goroutine 或 nil(失败时)
func runqsteal(pp, p2 *p, stealRunNextG bool) *g {
t := pp.runqtail // 当前 P 本地队列的 尾部指针(环形缓冲区)(用于后续追加窃取的 Goroutine)
// 窃取p2一半g的核心逻辑:
// 从p2本地队列获取一半(len(p2.runq)/2), 然后从 pp.runq[t] 开始追加, 返回实际窃取g的数量
n := runqgrab(p2, &pp.runq, t, stealRunNextG)
// 窃取失败
if n == 0 {
return nil
}
// 返回窃取的最后一个g
n--
gp := pp.runq[(t+n)%uint32(len(pp.runq))].ptr()
if n == 0 {
return gp
}
...
return gp
}
3.2 work stealing 的调度时机
work stealing 的时机就是调findRunnable的时机,调findRunnable就是调schedule的时机,所以找到所有会调度schedule的时机即可:(应该是以下四个)
-
execute
正常执行完一个g,让出cpu,开启下一轮schedule调度execute -> gogo -> 用户代码 -> mcall -> goexist0 -> schedule
-
gopark
主动让出cpu (chan阻塞、锁阻塞、sleep等)gopark -> mcall -> park_m -> schedule
park_m会将当前g的状态转为waiting,然后接绑和当前m的关系,然后重新开启调度循环为这个m找可运行的g
-
通过
sysmon
抢占让出 cpu其实就是 hand off 机制
-
有系统调用
Syscall
则让出 cpu进入系统调用前先保存执行现场,然后切换到_Gsyscall 状态,最后标记抢占,等待被抢占走;
系统调用退出时,切到 G0 下把G状态切回来,如果有可执行的P则直接执行,如果没有则放到全局队列里,等待调度(schedule );
3.3 为何高效
分析这个机制为什么会使得调度高效,例:如何提高cpu利用率的
...
4. hand off
每个g最多占用CPU 10ms,通过信号强制抢占,防止长任务饿死其他G。
Go 的调度是非抢占式的,要想实现G不被长时间,就只能主动触发抢占,所以只能启动一个监控任务,方法是sysmon ,该方法处于无限循环在整个声明周期中都监控着,retake方法每次都会对所有的p遍历检查超过10ms的还在运行的g,然后标记这些g为"抢占" gp.stackguard0 = stackPreempt
,等在 newstack 新创建栈空间的时候检测是否有抢占标记(也就是 gp.stackguard0是否等于 stackPreempt),如果有则通过 goschedImpl 方法再次进入到熟悉的 schedule 调度循环。
5. 调度流程图
go func 的大致调度流程图:
