GMP 调度器:Go 并发的心脏是如何跳动的

GMP 调度器:Go 并发的心脏是如何跳动的

一个 go func() 就能启动一个并发执行流,单机百万 goroutine 不是传说。支撑这一切的,是 Go 运行时的 GMP 调度器。今天把它的骨架拆开,并用代码亲手验证几个关键行为。

从 GM 到 GMP:多出来的 P 解决了什么

Go 早期是 GM 模型:一个全局 goroutine 队列 + 一把全局锁,所有线程抢锁取任务。问题一目了然------锁竞争、缓存局部性差、一个系统调用阻塞拖累全局。

2012 年 Dmitry Vyukov 的设计文档引入了 P(Processor),调度拆成三级:

角色 本质 职责
G Goroutine 执行流(栈 + PC + 寄存器现场) 待执行的任务,初始栈 8KB
M Machine OS 线程的封装 干活的手,必须绑定 P 才能执行 G
P Processor 调度上下文 持有本地运行队列,数量 = GOMAXPROCS

一句话记住三者的关系:P 的数量决定并行度(同一时刻最多 GOMAXPROCS 个 G 在 CPU 上跑),M 可以比 P 多(应对系统调用),G 几乎不限量。

队列结构:本地 + 全局的双层设计

  • LRQ(本地运行队列):每个 P 一个容量 256 的环形队列。新建 G 优先放当前 P 的 LRQ------无锁操作,极快;
  • GRQ(全局运行队列):LRQ 满了,一半 G(含新的)转移到 GRQ;LRQ 空了也从 GRQ 批量补充。

本地优先的设计把绝大部分调度操作变成无锁的,这是高并发下调度器不成为瓶颈的关键。

调度循环:M 找活的优先级

M 绑定 P 后进入调度循环,按这个顺序找下一个 G:

  1. 每 61 次调度检查一次 GRQ(防全局队列饥饿);
  2. 取 LRQ 队首;
  3. LRQ 空 → 从 GRQ 批量转移;
  4. 还没活 → 问 netpoll(网络轮询器)要就绪的网络 G;
  5. 再没活 → Work Stealing :随机挑一个 P,偷走它一半 的 G。

动手验证:调度器行为实测

go 复制代码
package main

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

func main() {
	// 1. GMP: G(goroutine) / M(OS 线程) / P(调度上下文, 数量 = GOMAXPROCS)
	fmt.Printf("1. NumCPU = %d, GOMAXPROCS = %d, Goroutines = %d\n",
		runtime.NumCPU(), runtime.GOMAXPROCS(0), runtime.NumGoroutine())

	// 2. P 决定并行度: GOMAXPROCS=1 时多任务只能交替执行
	old := runtime.GOMAXPROCS(1)
	var counter int64
	var wg sync.WaitGroup
	for i := 0; i < 2; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := 0; j < 5; j++ {
				atomic.AddInt64(&counter, 1)
				time.Sleep(2 * time.Millisecond)
			}
		}()
	}
	wg.Wait()
	runtime.GOMAXPROCS(old)
	fmt.Printf("2. GOMAXPROCS=1 下 2 任务交替完成, counter = %d\n", atomic.LoadInt64(&counter))

	// 3. Go 1.14+ 信号异步抢占: 纯计算死循环也会被让出
	runtime.GOMAXPROCS(1)
	done := make(chan struct{})
	go func() {
		x := 0
		for i := 0; i < 3e7; i++ {
			x += i
		}
		_ = x
		close(done)
	}()
	select {
	case <-done:
		fmt.Println("3. GOMAXPROCS=1 下死循环 goroutine 完成 -> 异步抢占生效")
	case <-time.After(3 * time.Second):
		fmt.Println("3. 超时, 抢占失败(不应出现)")
	}
	runtime.GOMAXPROCS(old)

	// 4. Work Stealing: 大量任务被多 P 分摊, 总耗时远低于串行
	var wg2 sync.WaitGroup
	const tasks = 200
	t0 := time.Now()
	for i := 0; i < tasks; i++ {
		wg2.Add(1)
		go func() {
			defer wg2.Done()
			time.Sleep(time.Millisecond)
		}()
	}
	wg2.Wait()
	fmt.Printf("4. %d 个 1ms 任务总耗时 %v (串行约 %dms)\n",
		tasks, time.Since(t0).Round(time.Millisecond), tasks)

	// 5. M 被复用: goroutine 数可远超 OS 线程数
	fmt.Printf("5. 任务结束后 Goroutines 回落到 %d\n", runtime.NumGoroutine())
}

运行输出:

text 复制代码
1. NumCPU = 16, GOMAXPROCS = 16, Goroutines = 1
2. GOMAXPROCS=1 下 2 任务交替完成, counter = 10
3. GOMAXPROCS=1 下死循环 goroutine 完成 -> 异步抢占生效
4. 200 个 1ms 任务总耗时 1ms (串行约 200ms)
5. 任务结束后 Goroutines 回落到 1

逐条解读

第 1 行 :16 核机器上 GOMAXPROCS = 16,这就是 P 的数量------16 个并行配额。

第 2 行 :把 P 压到 1,两个任务只能交替推进(并发但不并行),各自完成 5 次计数,合计 10。

第 3 行 :本次最有分量的验证。GOMAXPROCS=1 时跑一个不含任何函数调用、不含 channel 操作 的纯计算循环------在 Go 1.14 之前,这会饿死整个程序(唯一的 P 被永久霸占)。现在它能跑完并让出,靠的就是基于 SIGURG 信号的异步抢占:sysmon 发现 G 运行超 10ms,直接给运行它的 M 发信号,在信号处理里把 G 挂起。

第 4 行 :200 个 1ms 任务,串行要 200ms,实际只用了约 1ms------任务被铺到 16 个 P 上,空闲的 P 通过 Work Stealing 保持满载。偷取的细节值得记住:随机选受害者、一次偷一半------随机避免竞争热点,偷一半摊薄同步成本。

第 5 行 :200 个 goroutine 生灭之后,数量回落到 1。G 是轻量任务,M 是数量少得多的线程,M 被反复复用------这就是 goroutine 比线程便宜的直接证据。

阻塞时调度器怎么办

场景 调度器动作
channel / mutex 阻塞 G 挂起(gopark),P 原地调度下一个 G
网络 I/O G 注册到 netpoll 后挂起,P 不受影响------用户态非阻塞
系统调用(文件 I/O 等) M 进 syscall 前与 P 解绑,P 被其他 M 接管;M 返回时抢不到 P 就把 G 丢回 GRQ 自己休眠
定时器到期 G 被丢回运行队列等待调度

这张表藏着 Go 高并发 I/O 的全部秘密:网络 I/O 被 netpoll(epoll/kqueue 的封装)在用户态异步化。goroutine 看起来在同步阻塞地 read,实际上 OS 线程从没被拖住------写同步的代码,拿异步的性能。

sysmon:没有 P 的巡场者

sysmon 是一个特殊的 M,不绑定 P、永久循环,负责:

  • 发现长跑 G(>10ms)→ 发起抢占;
  • retake 陷入 syscall 太久的 P;
  • netpoll 收割、到点强制 GC、定时器检查。

它是调度器的保安,保证没有任何 G/M/P 能长期脱管。

小结

认知 一句话
三要素 G=任务,M=线程,P=并行度配额+本地队列(256)
找活顺序 定期 GRQ → LRQ → GRQ 批量 → netpoll → 偷一半
均衡 Work Stealing 随机偷一半,spinning 控制空转
抢占 Go 1.14+ SIGURG 异步信号抢占,死循环不再饿死
阻塞 syscall 时 M/P 解绑;网络 I/O 走 netpoll 用户态异步
监控 sysmon 无 P 巡场:抢占、retake、netpoll、GC
相关推荐
apcipot_rain3 小时前
C++转Java日记(04)
java·开发语言·c++
云和数据.ChenGuang4 小时前
LangGraph 异步 Node 的设计与写法实践
开发语言·人工智能·python·机器学习·php
蜗牛一步步往上爬7 小时前
第十六章 C++语言在深度学习中的应用
开发语言·c++·深度学习
张张123y9 小时前
DSH 插件调试避坑:client.js 设置面板、热重载与高频报错排查表
开发语言·javascript·人工智能·开源·ecmascript·后端开发·ai agent
冻柠檬飞冰走茶12 小时前
字母移动游戏题解:贪心求最长可保留连续子串(附 C 实现)
c语言·开发语言·数据结构·算法·动态规划
FRing0312 小时前
vue2干货总结7(自定义指令、插槽、单页应用程序、路由的使用(VueRouter))
开发语言·前端·vue.js·vue2
雪落漂泊13 小时前
C++算法 二分查找篇(上)
开发语言·c++·算法
波力海苔夹心脆67514 小时前
C# WPF 资源字典详解:ResourceDictionary、MergedDictionaries 与自定义控件案例
开发语言·c#·wpf·入门教程·resource·资源字典
不会算法的笨小孩14 小时前
异常的使用以及会出现的问题
开发语言·c++·c++11·异常
大白玉米14 小时前
MoxPlayer 弹幕播放器源码智能解析线路管理系统自研内核
php