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 的大致调度流程图:

相关推荐
金銀銅鐵8 小时前
[Java] 用 GUI 展示 EnumSet 增减元素时的内部变化(上)
java·后端·ai编程
念何架构之路9 小时前
go-grpc设计思想
开发语言·后端·golang
system_error_x9 小时前
SpringBoot 集成 Thymeleaf 简洁入门
java·spring boot·后端
步行cgn11 小时前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc
码事漫谈12 小时前
AI 读你的提示词,其实很没耐心
后端
用户83562907805113 小时前
如何使用 Python 格式化 Excel 单元格中的长文本
后端·python
我命由我1234513 小时前
Python 开发 - Python 的 pathlib
java·开发语言·后端·python·学习·java-ee·学习方法
IT_陈寒13 小时前
Java性能优化完全指南
前端·人工智能·后端
电子IT笔记13 小时前
对接群晖NAS,上传和删除踩坑记录
前端·后端
Mk5987813 小时前
P4:把 Agent 的记忆搬进 Redis —— AgentScope-Java 工程化实战
后端