Golang 调度循环:Go runtime 如何永不停歇地找人干活

调度循环:Go runtime 如何永不停歇地找人干活

GMP 把 goroutine、OS 线程、逻辑处理器这三层结构搭好,只是搭了个舞台。真正让这台戏能一直唱下去的,是 runtime.schedule() 为核心的调度循环。它就像一个永不下班的工头:每次线程 M 醒来,都要问自己三个问题------谁要干活?去哪儿找活?找不到了怎么办?

一、为什么需要调度循环

每个 M 一旦被创建(runtime.mstart),就会进入调度循环,直到线程退出。循环的职责可以概括为:

  1. 找一个可运行的 G(goroutine)。
  2. 切换到该 G 的用户栈执行。
  3. G 阻塞或完成后,再次回到循环找下一个 G。

如果没有这个循环,一个 goroutine 跑完,线程就无事可做了。正是这个循环,让少量 OS 线程能服务成千上万个 goroutine。

二、调度循环的入口

线程启动后会调用 runtime.mstart,最终进入 runtime.mstart1,再调用 runtime.schedule()。核心路径简化如下:

text 复制代码
mstart
  └── mstart1
        └── schedule()      // 找 G
              └── execute() // 绑定 G 到 M 并运行
                    └── gogo(&g.gobuf) // 切换到 g 的栈
                          └── 运行用户代码
                          └── goexit1()
                                └── mcall(goexit0)
                                      └── schedule() // 再次进入循环

goexit 在创建 goroutine 时被悄悄放到栈底,所以用户函数返回后,会自动触发 goexit,把 G 状态改为 dead,并回到调度循环。

三、schedule() 的找 G 优先级

runtime.schedule() 会按顺序从多个来源找可运行的 G:

来源 说明
GC 后台任务 如果有 mark worker 需要运行,优先执行
当前 P 的本地运行队列(LRQ) 最快,无锁访问
全局运行队列(GRQ) 每 61 次调度会从 GRQ 拿一批,防止本地队列饥饿
网络轮询器(netpoll) 把就绪的网络 goroutine 唤醒并加入 LRQ
Work Stealing 从其他 P 偷一半任务
自旋/休眠 实在找不到,M 进入自旋或休眠等待

这个顺序体现了 Go 调度器的设计哲学:能本地不全局,能异步不阻塞,能偷则偷

四、execute 与 gogo:真正切换到 G

找到 G 后,schedule() 调用 execute(gp, inheritTime)

  • 把 G 的状态从 _Grunnable 改为 _Grunning
  • 把 G 绑定到当前 M。
  • 调用 gogo(&gp.sched),从 g0 栈切换到 G 的用户栈。

g0 是每个 M 自带的系统 goroutine 栈,调度代码都在 g0 上跑。用户代码在 G 自己的栈上跑。切换时,保存当前寄存器到 g0.sched,恢复目标 G 的寄存器,跳转到 G 的程序计数器。

五、调度循环的终止

正常情况下,M 的调度循环不会终止,除非:

  • M 被显式要求退出(如 runtime.stopm)。
  • 程序调用 runtime.Goexit() 且没有更多任务。
  • 整个进程退出。

六、代码实践:观察调度循环的行为

下面这个程序用 GOMAXPROCS=1 强制只有一个 P,再用 GODEBUG=schedtrace=... 观察调度器输出,同时在代码里模拟 goroutine 切换。

go 复制代码
package main

import (
	"fmt"
	"runtime"
	"sync"
	"time"
)

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done()
	// 主动让出 CPU,模拟 G 被调度走
	for i := 0; i < 3; i++ {
		fmt.Printf("goroutine %d: step %d on %s\n", id, i, runtime.Version())
		runtime.Gosched()
	}
}

func main() {
	// 限制为单 P,便于观察调度循环在单个队列上的轮转
	runtime.GOMAXPROCS(1)

	var wg sync.WaitGroup
	for i := 0; i < 3; i++ {
		wg.Add(1)
		go worker(i, &wg)
	}

	// 主 goroutine 也主动让出,让子 goroutine 有机会上 CPU
	for i := 0; i < 5; i++ {
		runtime.Gosched()
		time.Sleep(10 * time.Millisecond)
	}

	wg.Wait()
	fmt.Println("all goroutines done")
}

运行结果(片段):

text 复制代码
goroutine 0: step 0 on go1.26...
goroutine 1: step 0 on go1.26...
goroutine 2: step 0 on go1.26...
goroutine 0: step 1 on go1.26...
goroutine 1: step 1 on go1.26...
goroutine 2: step 1 on go1.26...
all goroutines done

可以看到,三个 goroutine 在单 P 上交替执行。runtime.Gosched() 显式触发调度,schedule() 从 LRQ 取出下一个可运行的 G。

七、再看一个调度器 trace 输出

运行时可以加环境变量查看调度器事件:

bash 复制代码
GODEBUG=schedtrace=1000 go run main.go

输出类似:

text 复制代码
SCHED 0ms: gomaxprocs=1 idleprocs=0 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0]

字段含义:

字段 含义
gomaxprocs P 的总数
idleprocs 空闲的 P
threads M 的总数
spinningthreads 自旋的 M
idlethreads 休眠的 M
runqueue 全局队列长度
[0] 每个 P 的本地队列长度

八、逐条解读

  1. runtime.GOMAXPROCS(1) 把 P 固定为 1,所有 goroutine 都在同一个 LRQ 竞争。
  2. runtime.Gosched() 让当前 G 放弃 CPU,回到 _Grunnable,进入 LRQ 尾部。
  3. schedule() 被触发,从 LRQ 头部取出下一个 G 执行。
  4. 单 P 时不会出现 Work Stealing,因为没有其他 P 可偷。
  5. 主 goroutine 的 Sleep 会触发 M/P 解绑,让 netpoll/sysmon 有机会处理其他事件。
  6. sync.WaitGroup 确保主 goroutine 等待所有子 goroutine 完成,避免提前退出。

九、小结

概念 一句话总结
调度循环 M 创建后永不退出地找 G、执行 G、再找的循环
schedule() 按 GC → LRQ → GRQ → netpoll → Work Stealing 顺序找可运行 G
execute() 把 G 绑定到 M,调用 gogo 切换到用户栈
goexit 放在每个 G 栈底,函数返回后清理 G 并回到调度循环
g0 每个 M 的系统栈,调度代码在上面运行
相关推荐
明月_清风1 小时前
AI 时代,为什么架构师又开始谈"本体论"?
人工智能·后端·agent
深蓝AI2 小时前
AI 把写代码变快了,软件交付为什么没有同步变快
程序员
行百里er2 小时前
BeanPostProcessor:包装 Feign Client
spring boot·后端·监控
花生了什么事o2 小时前
自定义Spring Boot Starter全流程
java·spring boot·后端
云上小朱3 小时前
开发测试环境-Kubernetes离线部署指南
后端
遨翔在知识的海洋里3 小时前
nestjs(1)-模块的相互调用
后端
Profile排查笔记3 小时前
指纹浏览器推荐:用一套验收清单筛选 Profile、代理与自动化能力
前端·人工智能·后端·自动化
jsl_jsl_jsl3 小时前
JUC速记
后端