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
相关推荐
Java小白笔记1 小时前
Java 实现阿里云 OSS 文件上传链路:普通上传、秒传、分片与断点续传
java·开发语言·数据库·spring·阿里云
传奇开心果编程1 小时前
【Rust入门知识点学与练】第9课:Vec 动态数组
开发语言·学习·rust
卢锡荣1 小时前
单芯掌控多口互联|乐得瑞 LDR6020 PD3.1 多通道 Type‑C 控制 SOC 芯片
c语言·开发语言
2333!!!!!1 小时前
rocket新手一些常见问题
java·开发语言
yume_sibai1 小时前
02-Rust 所有权与借用深入解析(底层原理 + 借用检查器 + 生命周期 + 内部可变性)
开发语言·后端·rust
xxwxx__1 小时前
深入理解 C++ STL:stack、queue 与 deque 从使用到底层实现全解析
开发语言·c++·算法
Highcharts.js1 小时前
甘特图纵向任务、横向时间显示图表可视化开发|Highcharts甘特图示例
开发语言·前端·javascript·可视化·甘特图·数据可视化·highcharts
郝学胜-神的一滴1 小时前
C++11 工程级应用 08:Lambda表达式与Tuple元组
开发语言·jvm·c++·python·程序人生·开源
吴声子夜歌1 小时前
ApacheCommons——commons-lang3(Java 基础语言增强)(二)
java·开发语言·apache