Go 调度器(三):GMP高效调度的核心机制

基本思路:

  • p的本地队列与锁优化,详解cas无锁取g的实现
  • work stealing(任务窃取)机制:详解该机制的实现,如何提高cpu利用率的
  • hand off(切换移交)机制:详解该机制的实现,如何避免线程阻塞导致资源限制的
  • 协作式抢占:详解gmp模型的调度时机
  • 资源复用:详解m和g的复用机制,如何减少m和g的创建开销和上下文切换开销

1. 程序的启动

启动阶段由 runtime·rt0_go(核心启动函数) 汇编函数调用,调用顺序为:

  1. runtime·osinit:获取系统信息(如CPU核心数)
  2. runtime·schedinit:初始化调度器。
  3. runtime·newproc :创建主Goroutine(执行 runtime.main)。
  4. 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的时机即可:(应该是以下四个)

  1. execute 正常执行完一个g,让出cpu,开启下一轮schedule调度

    execute -> gogo -> 用户代码 -> mcall -> goexist0 -> schedule

  2. gopark主动让出cpu (chan阻塞、锁阻塞、sleep等)

    gopark -> mcall -> park_m -> schedule

    park_m会将当前g的状态转为waiting,然后接绑和当前m的关系,然后重新开启调度循环为这个m找可运行的g

  3. 通过 sysmon 抢占让出 cpu

    其实就是 hand off 机制

  4. 有系统调用 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 的大致调度流程图:

相关推荐
JavaGuide5 分钟前
感谢数字马力收留,再也不想面试了!!
java·后端
37手游后端团队20 分钟前
Eino大模型应用开发框架深入浅出
人工智能·后端
要开心吖ZSH27 分钟前
Spring Cloud LoadBalancer 详解
后端·spring·spring cloud
泉城老铁43 分钟前
Spring Boot + EasyPOI 实现 Excel 和 Word 导出 PDF 详细教程
java·后端·架构
LovelyAqaurius43 分钟前
了解Redis Hash类型
后端
JuiceFS1 小时前
合合信息:基于 JuiceFS 构建统一存储,支撑 PB 级 AI 训练
运维·后端
调试人生的显微镜1 小时前
iOS 文件深度调试实战 查看用户文件 App 沙盒 系统文件与日志全指南
后端
aiopencode1 小时前
没有 Mac,如何上架 iOS App?跨平台团队的全流程实践指南
后端
天天摸鱼的java工程师1 小时前
如何防止重复提交订单?
java·后端·面试
星星电灯猴1 小时前
Charles 抓不到包怎么办?详解原因与多维解决方案
后端