文章总结: 文章基于Go源码解析GMP调度机制,指出G为任务、M为线程、P为执行工位。G优先入本地队列以减少锁竞争,M按本地、全局、netpoll及偷取顺序获取任务。性能排查切忌盲目归咎高协程数,需区分runnable与waiting状态,建议用runtime/trace精准定位瓶颈。 综合评分: 94 文章分类: 其他
从 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
for i := 0; i < 8; i++ {
jobID := i
wg.Add(1)
go func() {
defer wg.Done()
begin := time.Now()
sum := 0
for n := 0; n < 20_000_000; n++ {
sum += n ^ jobID
}
fmt.Printf("job=%d cost=%s sum=%d\n", jobID, time.Since(begin), sum)
}()
}
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 main
import (
"fmt"
"runtime"
"time"
)
func main() {
for i := 0; i < 5000; i++ {
go func(id int) {
time.Sleep(5 * time.Second)
if id == -1 {
fmt.Println("never")
}
}(i)
}
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for range ticker.C {
fmt.Printf("goroutines=%d gomaxprocs=%d\n",
runtime.NumGoroutine(),
runtime.GOMAXPROCS(0),
)
}
}
这段代码 goroutine 很多,但大部分在 sleep,没怎么抢 P。线上如果看到 goroutine 数高,别急着扩机器,先分清楚它们是在 runnable,还是 waiting。前者才真会挤调度器,后者更多要查锁、channel、IO、定时器。
要看得更实一点,用 trace:
package main
import (
"os"
"runtime/trace"
"sync"
)
func main() {
f, err := os.Create("gmp.trace")
if err != nil {
panic(err)
}
defer f.Close()
if err := trace.Start(f); err != nil {
panic(err)
}
defer trace.Stop()
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(1)
go func(seed int) {
defer wg.Done()
x := seed
for n := 0; n < 30_000_000; n++ {
x = x*1664525 + 1013904223
}
}(i)
}
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 模型》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论