← [ BACK_TO_NOTES ]

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

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

#concept #growing #dev #go

[!info] related notes

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 执行普通用户 Go 代码需要绑定 P,部分运行时代码可无 P 执行——这是当前实现模型,不是语言规范条文。

最小可观察示例

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

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

比较 GOMAXPROCS=1 与 =4 时,应让两次运行完成相同计算量,并保留结果,避免工作被消除。按墙钟忙等 50ms 会让被抢占的任务少算一些,不能用来证明 CPU 加速比。P 数限制用户 Go 代码并行度,实际速度还受 CPU 配额和其他负载影响。 本实验只建立直觉——G-M-P 是实现模型,不是语言规范同步原语。

package main

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

func work(seed uint64) uint64 {
	x := seed
	for i := 0; i < 5_000_000; i++ {
		x = x*6364136223846793005 + 1
		x ^= x >> 17
	}
	return x
}

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

	var wg sync.WaitGroup
	results := make([]uint64, 4)
	start := time.Now()
	for i := range results {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			// 各 worker 只写自己独占的元素;等待结束后统一读取。
			results[id] = work(uint64(id + 1))
		}(i)
	}
	wg.Wait()
	elapsed := time.Since(start)
	fmt.Println("results:", results)
	fmt.Println("elapsed:", elapsed)
}

保存为新模块中的 main.go,先执行 go mod init example.com/scheduler-demo。构建一次再运行,避免把编译时间计入比较:

go build -o scheduler-demo .
GOMAXPROCS=1 ./scheduler-demo
GOMAXPROCS=4 ./scheduler-demo

期望观察点:

  • 两次 results 应相同,每个任务的固定计算量相同;结果正确性不依赖 P 数。
  • P=1 时任务仍会交错执行;P=4 允许并行,但不保证更快或四倍加速。elapsed 只作本机观察,应多次测量并记录 CPU 配额。
  • 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 是线程,不是额外 P,M 总数也不受 GOMAXPROCS 直接限制。
  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 < 1 只查询
  • Go 1.25+ 的默认策略考虑逻辑 CPU、CPU affinity 和 Linux cgroup CPU quota;旧语言版本的兼容默认值与 GODEBUG 可改变行为,不能只看工具链版本。
  • 当前实现通常取这些限制的最小值,quota 非整数时向上取整;除逻辑 CPU/affinity 少于 2 外,不会因 quota 将默认值压到 2 以下。配额是吞吐限制,不等于可同时使用几个核。
  • 显式设置正整数 GOMAXPROCS 环境变量或调用 GOMAXPROCS(n>0) 会关闭默认值自动更新;Go 1.25+ 的 SetDefaultGOMAXPROCS 可恢复运行时默认和更新行为。
  • 调小只限制用户 Go 代码并行度,不是整个进程的 CPU 硬上限;cgo 与部分运行时工作不受同样限制。它也不是 goroutine/OS 线程数上限。

与正确并发的关系

调度器不提供 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. 画像:pprof 的 goroutine 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

net/http/pprof 注册 handler,本身不会启动监听;应在受控调试入口上提供这些 handler,再观察 channel 等待栈。不要把导入包等同于 profile 已可访问。

本节总结

  • 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. 当前实现的 P 数及普通用户 Go 代码的并行度;不是 goroutine、OS 线程或整个进程 CPU 使用量的硬上限。
  3. 运行时 netpoll + G 休眠/唤醒,把 I/O 多路复用在少量线程上。
  4. 不能。必须使用内存模型认可的同步。
  5. 语言规范/内存模型(正确性)vs 设计文档与 runtime 实现(性能与机制)。

延伸阅读

创建于 2026/7/14 更新于 2026/10/7
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本