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
相关推荐
damoluomu3 天前
挑 PHP CMS 之前,先学会看它的协议
php
小羊没烦恼!3 天前
初探性能优化——2个月到4小时的性能提升
java·开发语言·windows·算法·c#
伞伞悦读3 天前
【第38期】Python 模块与包详解:import、from、模块搜索路径、包结构和 __init__
开发语言·python
ttwuai3 天前
Go开源后台管理系统推荐:怎么按技术栈和边界比较4个官方仓库?
golang·gin
C语言小火车3 天前
C/C++ 为什么需要编译器?
开发语言·c++
codeejun3 天前
每日一Go·MySQL-5、锁机制全解析
云原生·golang
霍霍的袁3 天前
【C++】map 和 set 的使用 | 从用法到底层
开发语言·c++·学习·visual studio
孙启超3 天前
【AI开发之Rust】第 11 课:智能指针与内部可变性
开发语言·后端·rust
此生决int3 天前
深入理解C++系列(20)——C++11(下)
开发语言·c++
Achou.Wang3 天前
k8s中nginx worker process自动设置
后端·golang