Go 调度器与 G-M-P 模型

Go 运行时如何把大量 goroutine 多路复用到 OS 线程:G-M-P 教学模型、阻塞与网络轮询、GOMAXPROCS,以及规范不保证的实现边界。

#type / concept #status / growing #tech / dev #resource / go

[!info] 关联笔记

Go 调度器与 G-M-P 模型

这个概念为什么出现

用户代码写的是:

go doWork()

操作系统提供的是线程。若 10 万个 goroutine 真对应 10 万线程,栈与调度成本不可接受。Go 运行时实现了用户态调度器,把大量 goroutine 多路复用到较少的 OS 线程上。

理解调度器是为了:

  • 解释“goroutine 很便宜,但仍可能卡住线程”
  • 理解 GOMAXPROCS、系统调用阻塞、网络轮询的大致位置
  • 读懂 pprof 里的 runtime. 栈与调度延迟现象
  • 避免把调度细节当成同步原语

设计背景可读:Scalable Go Scheduler Design Doc(Go 1.1 时代的经典文档,模型此后有演化)。源码阅读指引见 Go 源码树中的 src/runtime/HACKING.md(随 SDK 版本)。

[!abstract] 一句话理解 运行时用 G(goroutine)、M(OS 线程)、P(逻辑处理器/调度上下文)组织多路复用:P 持有可运行 G 的队列,M 必须绑定 P 才能执行 Go 代码——这是当前实现模型,不是语言规范条文。

最小可观察示例

先把示例放进可验证实验场景,再看代码:

场景:理解 GOMAXPROCS 与「CPU 忙 vs 阻塞」的调度成本

压测时把 GOMAXPROCS=1 与默认多核对比,纯计算任务墙钟时间差一截;
换成 Sleep/channel 等待后差异又变小。工程师要关心:G 很多 ≠ 真·并行很多;P 的数量约束可同时执行 Go 代码的能力。
本实验只建立直觉——G-M-P 是实现模型,不是语言规范同步原语

package main

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

func main() {
	// 先看当前逻辑处理器与 CPU 数(理解“能并行执行多少 Go 代码”的上限直觉)
	fmt.Println("GOMAXPROCS =", runtime.GOMAXPROCS(0))
	fmt.Println("NumCPU     =", runtime.NumCPU())

	var wg sync.WaitGroup
	for i := 0; i < 4; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			// CPU 忙等:持续占用 P,更吃 GOMAXPROCS
			// 业务类比:纯计算压缩、哈希、JSON 大对象编解码
			end := time.Now().Add(50 * time.Millisecond)
			for time.Now().Before(end) {
			}
			fmt.Println("done", id)
		}(i)
	}
	wg.Wait()
}

建议观察:

GOMAXPROCS=1 go run .
GOMAXPROCS=4 go run .

期望观察点:

  • 两种设置都能打印 4 个 done(正确性不依赖 P 数)。
  • GOMAXPROCS=1 时四个忙等任务串行吃满一个 P,总墙钟往往更接近累加;=4 时更易重叠。
  • 再把忙等改成 time.Sleep 或 channel 等待:运行时可停 G,与纯 CPU 行为不同。

结合场景再看关注点

  1. 场景是理解成本与排障,不要用调度细节当锁。
  2. GOMAXPROCS 约束的是可同时跑 Go 代码的 P 规模(实现级直觉)。
  3. 阻塞 I/O 与 CPU 忙对 M/P 的占用模式不同——profile 前先分清工作类型。

核心模型

G、M、P 是什么(教学定义)

缩写含义直觉
Ggoroutine用户态轻量执行体:栈、状态、当前指令
MmachineOS 线程;真正被内核调度
Pprocessor逻辑处理器:调度上下文、本地可运行队列、本地分配缓存等
flowchart LR
    subgraph P0["P"]
      Q0["local run queue"]
    end
    subgraph P1["P"]
      Q1["local run queue"]
    end
    M0["M OS thread"] --> P0
    M1["M OS thread"] --> P1
    Q0 --> G1["G"]
    Q0 --> G2["G"]
    Q1 --> G3["G"]
    GRQ["global run queue"] --> P0
    GRQ --> P1

要点:

  1. 执行 Go 代码通常需要 M 持有 P
  2. P 的数量大致由 GOMAXPROCS 约束(实现细节:还有 sysmon 等特殊 M)。
  3. G 阻塞在 channel 等用户态原语上时,可与 M 解绑,M 去跑别的 G。
  4. G 进入某些系统调用时,M 可能被卡住;运行时会分离 P,让其他 M 拿走 P 继续跑(“handoff”类机制,实现演化中)。

为什么需要 P(相对旧模型)

早期设计讨论见 go11sched:引入 P 是为了:

  • 限制同时执行 Go 代码的并行度
  • 提供 per-P 本地队列,降低全局锁
  • 为本地内存缓存等提供挂靠点

这直接关系到分配器的 mcache 直觉(分配器)。

调度触发点(经验列表)

G 可能在以下时机被调度出去(不完全表):

  • channel / select / mutex 阻塞
  • time.Sleep、定时器
  • 系统调用
  • 函数调用间隙的协作式抢占检查
  • 基于信号的异步抢占(现代 Go 对忙循环更公平,实现细节)
  • GC STW 或安全点相关协作

不要假设“没有函数调用的死循环永远独占核”——实现已强化抢占,但仍可能观察到不公平,正确性仍靠同步。

网络轮询(netpoll)

运行时把许多 socket I/O 接到 netpoller:G 等待数据时休眠,事件就绪后被唤醒。因此大量网络连接 ≠ 大量阻塞 OS 线程。这是 Go 网络服务能高并发的关键实现支柱之一。

GOMAXPROCS

runtime.GOMAXPROCS(n) // 返回先前值;n==0 只查询
  • 默认通常与 NumCPU 相关(版本/容器感知策略有过改进)。
  • 调大:提高 CPU 型并行度,也可能增加调度与缓存开销。
  • 调小:限制占用核数。
  • 它不是“最大 goroutine 数”。

与正确并发的关系

调度器不提供 happens-before。两个 G 即使“看起来交替打印”,无同步仍可能 data race。必须用 channel、mutex、atomic 等。见 内存模型

规范 vs 实现分界

语言/文档保证实现(G-M-P)
go 语句启动新 goroutineG 结构体字段
channel/select/mutex 语义本地/全局运行队列算法
内存模型同步规则抢占信号、sysmon
程序应可被正确同步GOMAXPROCS 默认启发式
work stealing 细节、自旋策略

明确: G-M-P 是阅读 runtime 与设计文档用的地图;应用正确性不得依赖“调度器会按某顺序运行”。

边界

  1. CPU 密集 vs I/O 密集
    前者吃 GOMAXPROCS;后者更吃 netpoll 与阻塞路径设计。

  2. cgo / 阻塞 syscall
    长时间占住 M 会迫使运行时创建/调度更多线程,带来开销。见 cgo

  3. 公平性
    不保证严格 FIFO;饥饿通常靠抢占与调度策略缓解,但业务仍要避免忙等锁。

  4. 调试
    GODEBUG=schedtrace=1000 等输出是实现调试器,格式不稳定。

  5. 容器 CPU limit
    历史版本对 cgroup CPU 配额的默认 GOMAXPROCS 行为有变化;部署时要确认当前版本策略。

常见误区

[!warning] 把 G-M-P 当规范背诵当面试唯一真相 要说清“实现模型,非语言保证”。

[!warning] 认为 goroutine 泄漏只是“多占一点内存” 泄漏的 G 可能卡在 channel、持有锁、钉住堆对象,拖垮服务。

[!warning] 用“睡一下让别的 G 跑”当同步 time.Sleep 不是 happens-before 工具。

[!warning] 无界 go func 打满调度器 要有并发度限制(worker 池、信号量、队列)。

[!warning] 忙等 for{} 空转当进度等待 浪费 CPU;用 channel/条件变量/事件。

工程实践

  1. 默认信任调度器,把精力放在:边界同步、避免泄漏、限制并发度。
  2. CPU 型任务测量 GOMAXPROCS 敏感度;容器环境确认 CPU 配额。
  3. 阻塞库(同步磁盘、cgo、外部命令)隔离与超时。
  4. 画像pprofgoroutine profile 查堵塞点;CPU profile 查忙等。
  5. 测试go test -race;并发测试不要依赖调度顺序。
  6. 阅读源码时配合 SDK 内 runtime/HACKING.md 与设计文档,而不是过时博客字段名。

可验证实验

实验 A:GOMAXPROCS 与 CPU 任务

固定 N 个纯计算 goroutine,比较 GOMAXPROCS=1=NumCPU 的 wall time。

实验 B:阻塞不吃满线程直觉

大量 time.Sleep 的 G vs 大量忙等 G,观察机器负载差异。

实验 C:goroutine 泄漏

func leak() {
	ch := make(chan int)
	go func() { <-ch }()
}
// 重复调用 leak,用 runtime.NumGoroutine() 观察上升

实验 D:pprof goroutine

在 HTTP 服务导入 net/http/pprof,打负载后查看阻塞在 channel 的栈。

本节总结

  • Go 用用户态调度把海量 G 多路复用到少量 M;P 提供并行度与本地上下文。
  • G-M-P 来自实现与设计文档(go11sched),不是规范 ABI。
  • 阻塞、netpoll、抢占解释了高并发 I/O 行为;正确性仍靠内存模型。
  • 工程重点:泄漏、并发度、阻塞点、race,而不是手写调度器。

自测题

  1. M 没有 P 时通常还能不能执行普通 Go 代码?
  2. GOMAXPROCS 限制的是什么?
  3. 为什么大量网络连接不必创建等量 OS 线程?
  4. 调度器能否替代 mutex 保证共享内存可见性?
  5. 读调度源码前应区分哪两层文档?
参考答案
  1. 教学模型下:执行 Go 代码通常需要绑定 P;无 P 时 M 可能处于系统调用或其他状态(细节实现相关)。
  2. 大致是同时执行 Go 代码的逻辑并行度(P 数量相关),不是 goroutine 个数上限。
  3. 运行时 netpoll + G 休眠/唤醒,把 I/O 多路复用在少量线程上。
  4. 不能。必须使用内存模型认可的同步。
  5. 语言规范/内存模型(正确性)vs 设计文档与 runtime 实现(性能与机制)。

延伸阅读

创建于 2026/7/14 更新于 2026/7/15