Go 协程调度原理

深入理解 G-M-P 模型与用户态调度机制,探索 Go 如何在用户空间实现高性能并发

核心概念:G-M-P 模型

G

Goroutine

Go 语言的并发执行单元

类似:轻量级线程
大小:2KB 初始栈
特点:由 Go 运行时管理
M

Machine

操作系统线程的抽象

类似:内核线程
数量:可超过 CPU 核心数
特点:执行 G 的载体
P

Processor

调度器的执行上下文

数量:GOMAXPROCS
默认:= CPU 核心数
特点:持有调度资源
P (Processor)
本地运行队列
G6 (running)
G7
G8
P (Processor)
本地运行队列
G3 (running)
G4
P (Processor)
本地运行队列
G5 (running)
M (Machine)
🔵 全局运行队列 (Global RunQueue)
G1
G2
G9
G10
M (Machine)
P (Processor)
本地运行队列
G11 (running)
G12
G13
G14
P (Processor)
本地运行队列
G15 (running)
G16
P (Processor)
本地运行队列
G17 (running)
Goroutine (协程)
Machine (系统线程)
Processor (执行上下文)

为什么是用户态调度?

特性 传统内核线程 Go 协程 (用户态)
创建成本 ~1MB 栈,需要系统调用 ~2KB 栈,Go 运行时分配
切换成本 用户态→内核态→用户态 (~1-10μs) 纯用户态切换 (~0.2-0.5μs)
调度方式 OS 内核抢占式调度 Go 运行时协作式+抢占式
上下文保存 完整寄存器+栈+内核态 仅需保存寄存器+栈指针
数量限制 受限于系统线程数 (数千) 可轻松创建数十万
调度延迟 取决于内核调度器 可控制在微秒级

调度流程详解

1

创建协程

使用 go func() 创建 G,加入 全局队列本地队列。新协程初始栈仅 2KB,按需增长。

2

M 获取 P

M (Machine/线程) 必须绑定一个 P 才能执行协程。P 持有运行队列和调度资源。GOMAXPROCS 决定 P 的数量。

3

从队列取 G

优先从 P 的本地队列取 G执行,若为空则尝试从全局队列偷取其他 P 的队列。

4

执行与让出

G 执行中可能:① 主动调用 runtime.Gosched() 让出;② 阻塞等待 (channel/锁);③ 被抢占式调度中断。

// 调度伪代码简化版 func schedule() { var gp *g // 1. 尝试从本地队列获取 if gp = getg().m.p.runq.pop(); gp != nil { execute(gp) return } // 2. 尝试从全局队列获取 if gp = globrunqget(); gp != nil { execute(gp) return } // 3. 工作窃取:从其他 P 偷一半 work stealing(getg().m.p) }

工作窃取调度算法

🍀 P1 (忙碌)
G1
G2
G3
G4
G5
队列长度: 5
😴 P2 (空闲)
队列长度: 0
等待任务中...

🎯 P2 空闲时会随机选择其他 P,偷走其一半的协程(这里偷走 G3, G4)

窃取算法要点

触发条件

本地队列为空 + 全局队列为空时触发

📋
窃取数量

从目标 P 的本地队列尾部取走一半(约 50%)

🎲
选择目标

随机选择其他 P,避免全局竞争

⚖️
负载均衡

保证所有 CPU 核心充分利用,最小化空闲

协程切换时序示意

0ms 25ms 50ms 75ms 100ms

普通调度:时间片到期后,M 从 P1 本地队列取下一个 G 执行

核心要点总结

🏗️
三级队列结构

每个 P 有本地队列 + 全局队列 + 网络轮询器,形成高效的分布式调度

🔄
用户态切换

协程切换仅保存/恢复寄存器,无需进入内核,开销是内核线程的 1/100

⚖️
工作窃取保证均衡

空闲 P 主动从繁忙 P 偷任务,确保 CPU 100% 利用,无任务饥饿

🎯
M:N 映射

M 个线程映射到 N 个协程,通过 P 解耦,实现高并发同时控制资源

调度器架构全景

OS Kernel
↑ 阻塞/系统调用
M
Machine 1
M
Machine 2
M
Machine 3
↓ 绑定关系
P
P
P
(绿色方块 = G 协程)

核心洞察: P 是调度资源的抽象(类似 CPU 核心),M 是执行载体,G 是任务单元。
通过 M:N 映射 + 工作窃取,Go 在用户态实现了高效、灵活的多核调度。