从runtime源码彻底搞懂Go调度器GMP模型

admin 2026-07-05 05:56:26 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 文章基于Go源码解析GMP调度机制,指出G为任务、M为线程、P为执行工位。G优先入本地队列以减少锁竞争,M按本地、全局、netpoll及偷取顺序获取任务。性能排查切忌盲目归咎高协程数,需区分runnable与waiting状态,建议用runtime/trace精准定位瓶颈。 综合评分: 94 文章分类: 其他


cover_image

从 runtime 源码彻底搞懂 Go 调度器 GMP 模型

原创

go go

Go语言教程

2026年7月1日 13:13 陕西

在小说阅读器读本章

去阅读

go func(){} 这一行,看着轻,其实 runtime 后面马上就开始搬东西了。

别急着背 G、M、P 那三句话。真看源码,先从 runtime/proc.go 开始。文件开头已经把话说得很直:调度器的活,就是把 ready-to-run 的 goroutine 分发到 worker thread 上;G 是 goroutine,M 是 worker thread,P 是执行 Go 代码必须拿到的资源。M 没有 P,可以阻塞在 syscall 里,但不能跑用户态 Go 代码。

我第一次看 GMP,卡住的地方不是概念,是这个问题:

为什么有了线程 M,还非得多一个 P?

后来看到 runtime/HACKING 里那段解释就顺了。P 里面放的是执行 Go 代码需要的资源,比如调度器状态、内存分配状态,而且 P 的数量就是 GOMAXPROCS。调度器要做的,是把一个 G、一个 M、一个 P 凑在一起。M 进 syscall 了,P 还能交出去,让别的 M 接着跑 Go 代码。

这地方别想得太玄。

G 是活。 M 是干活的线程。 P 是工位。

没有工位,线程站着也干不了 Go 代码。

看一段小代码,先造一点调度痕迹:

package main

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

func main() {
 runtime.GOMAXPROCS(2)

 var wg sync.WaitGroup
&nbsp;for&nbsp;i :=&nbsp;0; i <&nbsp;8; i++ {
&nbsp; jobID := i
&nbsp; wg.Add(1)

&nbsp;&nbsp;go&nbsp;func()&nbsp;{
&nbsp; &nbsp;defer&nbsp;wg.Done()

&nbsp; &nbsp;begin := time.Now()
&nbsp; &nbsp;sum :=&nbsp;0
&nbsp; &nbsp;for&nbsp;n :=&nbsp;0; n <&nbsp;20_000_000; n++ {
&nbsp; &nbsp; sum += n ^ jobID
&nbsp; &nbsp;}

&nbsp; &nbsp;fmt.Printf("job=%d cost=%s sum=%d\n", jobID, time.Since(begin), sum)
&nbsp; }()
&nbsp;}

&nbsp;wg.Wait()
}

这里开了 8 个 G,但 GOMAXPROCS(2) 只有 2 个 P。也就是说,同一时刻真正能跑 Go 代码的,最多就两个“工位”。剩下的 G,不是没线程,是还没排到 P。

再往源码里看,go 语句会被编译器变成对 runtime.newproc 的调用。newproc 里会创建新的 G,然后把它丢进当前 P 的运行队列,最后 wakep() 试着唤醒一个 P 去干活。这个链路在 proc.go 里很清楚:newproc -> newproc1 -> runqput -> wakep

这里我一般重点看 runqput

它不是傻乎乎全塞一个全局队列。它会优先把 G 放到当前 P 的本地队列;如果 next=true,还会尝试放到 pp.runnext,让这个 G 更快被执行;本地队列满了,才会走慢路径,把一批任务甩到全局队列。

这就是 Go 调度器快的一个点:少抢全局锁,尽量让每个 P 自己消化自己的活。

那 M 怎么拿 G?

看 schedule()

源码注释写得很硬:一轮调度,就是找一个可运行的 goroutine,然后执行它。schedule() 里最关键的一句是:

gp, inheritTime, tryWakeP := findRunnable()

找到之后再 execute(gp, inheritTime)execute 会把 G 状态从 _Grunnable 切到 _Grunning,绑定到当前 M 上,然后跳到 G 的执行现场。

真正脏活在 findRunnable()

它的注释比很多文章都靠谱:尝试从本地队列、全局队列、网络轮询器里拿 G,还会从其他 P 那里偷任务。

所以调度顺序大概是这种味道:

先看自己 P 本地有没有活; 没有,再看全局队列; 还没有,看 netpoll 有没有网络事件醒来的 G; 再没有,就去别的 P 队列里偷一半; 实在没活,M 该停就停,别空转烧 CPU。

偷任务这一步很关键。假设 P0 队列爆了,P1 空着,P1 对应的 M 不会在那里装死,它会去 P0 那边偷一批 G 回来跑。这个设计很工程,不浪漫,但很顶用。

再写个更接近线上排查的例子。很多人一看 goroutine 数高,就说“调度器不行”。我一般先打两个数:

package&nbsp;main

import&nbsp;(
&nbsp;"fmt"
&nbsp;"runtime"
&nbsp;"time"
)

func&nbsp;main()&nbsp;{
&nbsp;for&nbsp;i :=&nbsp;0; i <&nbsp;5000; i++ {
&nbsp;&nbsp;go&nbsp;func(id&nbsp;int)&nbsp;{
&nbsp; &nbsp;time.Sleep(5&nbsp;* time.Second)
&nbsp; &nbsp;if&nbsp;id ==&nbsp;-1&nbsp;{
&nbsp; &nbsp; fmt.Println("never")
&nbsp; &nbsp;}
&nbsp; }(i)
&nbsp;}

&nbsp;ticker := time.NewTicker(time.Second)
&nbsp;defer&nbsp;ticker.Stop()

&nbsp;for&nbsp;range&nbsp;ticker.C {
&nbsp; fmt.Printf("goroutines=%d gomaxprocs=%d\n",
&nbsp; &nbsp;runtime.NumGoroutine(),
&nbsp; &nbsp;runtime.GOMAXPROCS(0),
&nbsp; )
&nbsp;}
}

这段代码 goroutine 很多,但大部分在 sleep,没怎么抢 P。线上如果看到 goroutine 数高,别急着扩机器,先分清楚它们是在 runnable,还是 waiting。前者才真会挤调度器,后者更多要查锁、channel、IO、定时器。

要看得更实一点,用 trace:

package&nbsp;main

import&nbsp;(
&nbsp;"os"
&nbsp;"runtime/trace"
&nbsp;"sync"
)

func&nbsp;main()&nbsp;{
&nbsp;f, err := os.Create("gmp.trace")
&nbsp;if&nbsp;err !=&nbsp;nil&nbsp;{
&nbsp;&nbsp;panic(err)
&nbsp;}
&nbsp;defer&nbsp;f.Close()

&nbsp;if&nbsp;err := trace.Start(f); err !=&nbsp;nil&nbsp;{
&nbsp;&nbsp;panic(err)
&nbsp;}
&nbsp;defer&nbsp;trace.Stop()

&nbsp;var&nbsp;wg sync.WaitGroup
&nbsp;for&nbsp;i :=&nbsp;0; i <&nbsp;16; i++ {
&nbsp; wg.Add(1)
&nbsp;&nbsp;go&nbsp;func(seed&nbsp;int)&nbsp;{
&nbsp; &nbsp;defer&nbsp;wg.Done()
&nbsp; &nbsp;x := seed
&nbsp; &nbsp;for&nbsp;n :=&nbsp;0; n <&nbsp;30_000_000; n++ {
&nbsp; &nbsp; x = x*1664525&nbsp;+&nbsp;1013904223
&nbsp; &nbsp;}
&nbsp; }(i)
&nbsp;}
&nbsp;wg.Wait()
}

跑完:

go run main.go
go tool trace gmp.trace

Go 官方也建议用 execution trace 看调度动作,里面能看到 goroutine 调度、P 空转、GC 时间等信息,排延迟问题比只看日志靠谱。

最后把 GMP 串一下。

go 语句创建 G,先放到 P 的本地队列。本地队列放不下,就挪一部分到全局队列。M 必须拿到 P 才能跑 Go 代码。schedule() 不停找可运行的 G,findRunnable() 找不到就偷,偷不到就看 netpoll,再没活就让 M 停下来。G 被选中后,execute() 把它切成 running,真正开始跑。

这套东西看着绕,本质不是为了炫技,是为了几个很现实的目标:少创建线程,少抢全局锁,尽量利用 CPU,IO 或 syscall 阻塞时别把 P 一起拖死。

所以排 Go 程序性能问题时,我不太信“goroutine 多所以慢”这种粗暴判断。先看 runnable 堆不堆,再看 P 有没有空,再看 syscalls、netpoll、GC 有没有抢时间。GMP 不是背出来的,是你顺着 newproc、runqput、schedule、findRunnable、execute 这几根线摸一遍,才真能用上。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:Go语言教程 go go《从 runtime 源码彻底搞懂 Go 调度器 GMP 模型》

    评论:0   参与:  0