GMP 调度模型
#golang · #GMP · #Goroutine · #P · #M · #调度 · #底层实现
Go 运行时调度器的核心设计——Goroutine、Machine、Processor 三元组。
一、为什么要 GMP?
1.1 传统线程模型的痛点
| 模型 | 内存占用 | 切换开销 | 并发上限 |
|---|---|---|---|
| OS 线程 | ~1MB 栈 | 内核态切换(~μs) | 数千 |
| Goroutine | ~2KB 栈(可增长) | 用户态切换(~ns) | 百万级 |
Go 用 GMP 模型实现了用户态轻量级调度,每个 goroutine 只需要很小的初始栈,且按需增长。
1.2 演进史
GM 模型(Go 1.0)
↓ 问题:全局锁竞争严重、M 之间没有 cache 亲和性
GMP 模型(Go 1.1)
↓ P 作为 M 和 G 之间的桥梁,引入本地队列
work stealing(Go 1.1+)
↓ 空闲 P 从其他 P 窃取 G
抢占式调度(Go 1.14+)
基于信号的异步抢占,解决 CPU 密集型 goroutine 饿死问题二、GMP 核心结构
2.1 G(Goroutine)— 执行单元
go
// runtime/runtime2.go
type g struct {
stack stack // 栈内存 [lo, hi),初始 2KB
stackguard0 uintptr // 栈溢出检测边界
m *m // 当前绑定的 M
sched gobuf // 调度信息(sp, pc, bp 等寄存器)
atomicstatus uint32 // G 的状态
goid int64 // goroutine ID
waitreason string // 等待原因
// ...
}
type gobuf struct {
sp uintptr // 栈指针
pc uintptr // 程序计数器
bp uintptr // 基址指针
ret uintptr // 返回值
lr uintptr // 链接寄存器(ARM)
}G 的状态转换:
_Gidle → _Grunnable → _Grunning → _Gwaiting
↑ ↓
_Gdead ← _Gwaiting_Gidle:刚创建,未初始化_Grunnable:在运行队列中,等待被调度_Grunning:正在执行(绑定到 M 上)_Gwaiting:阻塞中(channel、网络、锁等)_Gdead:执行完成,可被复用
2.2 M(Machine)— OS 线程
go
type m struct {
g0 *g // 调度用的特殊 goroutine(每个 M 都有)
curg *g // 当前正在运行的 goroutine
p puintptr // 绑定的 P
nextp puintptr // 暂存的 P
spinning bool // 是否处于自旋状态(空转找 G)
parked bool // 是否休眠
// ...
}g0 vs 普通 g:每个 M 有一个特殊的
g0goroutine,用于执行调度逻辑。普通 goroutine 在用户栈上运行,g0 在系统栈上运行。
2.3 P(Processor)— 逻辑处理器
go
type p struct {
m muintptr // 绑定的 M
runq [256]guintptr // 本地运行队列(环形数组,无锁)
runnext guintptr // 下一个优先运行的 G
runqhead uint32 // 队列头
runqtail uint32 // 队列尾
gFree struct { // 空闲 G 链表(复用)
n int32
list *g
}
// ...
}关键设计:P 的本地队列是无锁的环形数组,容量 256。只有 P 自己没有 G 时才会去全局队列偷。
三、调度流程
3.1 核心调度循环 — schedule()
go
// runtime/proc.go
func schedule() {
_g_ := getg() // g0
top:
// 1. 检查是否需要 GC(每 61 次调度检查一次全局队列)
if gp == nil {
if _g_.m.p.ptr().schedtick%61 == 0 {
gp = globrunqget(_g_.m.p.ptr(), 1)
}
}
// 2. 从本地队列获取
if gp == nil {
gp = runqget(_g_.m.p.ptr())
}
// 3. 从全局队列获取
if gp == nil {
gp = globrunqget(_g_.m.p.ptr(), 0)
}
// 4. 从网络轮询器获取
if gp == nil {
gp = netpoll(false)
}
// 5. work stealing —— 从别的 P 偷!
if gp == nil {
gp = findrunnable() // 阻塞直到找到可运行的 G
}
// 执行 G
execute(gp)
}查询优先级:本地队列 > 全局队列 > 网络轮询 > work stealing
3.2 Work Stealing 详解
go
func findrunnable() (gp *g, inheritTime bool) {
// 尝试 4 轮:从全局队列 + 网络 + runq
for i := 0; i < 4; i++ { ... }
// 随机选一个 P,偷它一半的 G
for i := 0; i < stealTries; i++ {
p2 := allp[random(len(allp))]
gp = runqsteal(_p_, p2, stealTimersOrRunNextG)
if gp != nil { return gp }
}
// 没偷到就休眠
stopm()
}关键:work stealing 保证负载均衡——空闲的 P 会随机选择目标 P,"偷取"它本地队列中一半的 G。
为什么是一半(half)而非全部或四分之一?——负载均衡与 cache 亲和性的权衡:
三种偷取策略的对比分析:
偷取全部:
优点: 一次偷取解决全部负载不均问题
缺点:
1. 原 P 突然变为空闲,产生新的不均(从过载变成空闲)
2. 被偷的 G 全部换了 P → cache 亲和性完全丧失 → 大量 cache miss
3. 如果被偷 P 也有任务要处理,后续 goroutine 恢复后反而无任务可跑
偷取 1/4:
优点: 更少影响原 P
缺点: 可能一次偷取不够,需要多轮 stealing → 更多随机的 cache line bounce
偷取一半 (Go 的选择):
优点:
1. 一次偷取后两个 P 的负载基本平衡
2. 被偷 P 仍有工作可做(不会立即空闲)
3. 偷取方获得足够任务,减少后续 stealing
缺点:
大约 50% 的被偷 G 会遇到 cache cold start
Go team 在实现时做过 benchmark:
steal half 在大多数 benchmark 中比 steal all 和 steal quarter 表现更好
因为"工作量足够 + 不会让原 P 饥饿"的折中最为平衡mermaid
graph TD
subgraph "G 的调度路径"
A["新 goroutine"] --> B{当前 P 本地队列满?}
B -->|否| C["放入本地队列 runq"]
B -->|是| D["放入全局队列"]
end
subgraph "M 找 G 的路径"
E["schedule()"] --> F["runqget(本地)"]
F --> G{找到?}
G -->|是| Z["执行 G"]
G -->|否| H["globrunqget(全局)"]
H --> I{找到?}
I -->|是| Z
I -->|否| J["netpoll"]
J --> K{找到?}
K -->|是| Z
K -->|否| L["findrunnable → work stealing"]
L --> Z
end3.3 Goroutine 阻塞与唤醒
阻塞场景:
channel 操作 → gopark() 暂停 G,放入 waitq
网络 I/O → netpoll() 异步等待
系统调用 → entersyscall() / exitsyscall()
锁/定时器 → gopark()
唤醒场景:
channel 发送 → goready() 将 G 放回运行队列
网络事件 → netpoll() 返回就绪的 G
定时器超时 → timer 触发四、系统调用处理
4.1 entersyscall / exitsyscall
go
// G 进入系统调用
func entersyscall() {
// 解绑 P(P 可以去执行其他 G)
_g_.m.p.ptr().m = 0
_g_.m.p = 0
}
// G 退出系统调用
func exitsyscall() {
// 1. 尝试获取原来的 P
if oldp != nil && oldp.status == _Psyscall {
acquirep(oldp) // 快速路径:原来的 P 还在等
return
}
// 2. 慢速路径:找一个空闲的 P
_p_ = pidleget()
if _p_ != nil { acquirep(_p_); return }
// 3. 没有空闲 P,G 放回全局队列
globrunqput(gp)
stopm() // M 休眠
}Handoff 机制:M 进入系统调用时会"交还"P,让 P 可以绑定其他 M 继续执行。等系统调用返回后,G 尝试重新获取 P。
4.2 抢占式调度(Go 1.14+)
同步抢占(Go 1.13 前):只在函数调用时检查栈边界,CPU 密集型 goroutine 无法被抢占。
异步抢占(Go 1.14+):基于信号的抢占:
go
// sysmon() 监控线程检测到 G 运行超 10ms
// 发送 SIGURG 信号 → 目标 M 中断 → 保存上下文
// 将 G 放回运行队列 → 重新调度五、运行时全局变量与初始化
go
// runtime/runtime2.go
var (
allm []*m // 所有 M
allp []*p // 所有 P(数量 == GOMAXPROCS)
sched schedt // 全局调度器
)
type schedt struct {
lock mutex // 全局锁
runq gQueue // 全局运行队列
runqsize int32 // 全局队列大小
midle mQueue // 空闲 M 队列
pidle pQueue // 空闲 P 队列
nmspinning int32 // 自旋 M 数量
// ...
}六、实战调优
6.1 GOMAXPROCS
bash
# 默认等于 CPU 核数
export GOMAXPROCS=4
# 代码中设置
runtime.GOMAXPROCS(4)经验:容器环境建议用
uber-go/automaxprocs,自动根据 cgroup 限制设置 GOMAXPROCS。
6.2 Goroutine 泄露排查
go
// 使用 pprof 查看 goroutine 数量和分析
import _ "net/http/pprof"
// http://localhost:6060/debug/pprof/goroutinego
// 代码中打印 goroutine 数量
fmt.Println(runtime.NumGoroutine())6.3 调度器延迟分析
bash
# GODEBUG 追踪调度行为
GODEBUG=schedtrace=1000 ./program # 每秒打印调度统计
GODEBUG=scheddetail=1,schedtrace=1000 ./program # 详细信息输出示例:
SCHED 0ms: gomaxprocs=8 idleprocs=5 threads=10 spinning=1 idlethreads=2 runqueue=0 [1 0 0 0 0 0 0 0]八、GMP 与线上阻塞传播链
8.1 不是所有阻塞都一样
在 Go 里,表面上看都是“goroutine 卡住了”,但底层有三种完全不同的阻塞:
| 阻塞类型 | 底层表现 | 对调度器的影响 |
|---|---|---|
| channel / mutex / timer | gopark() | 只阻塞 G,不阻塞线程 |
| 网络 I/O | 交给 netpoller | G 挂起,M/P 继续干别的 |
| syscall / cgo | 线程进内核或外部库 | 可能占住 M,严重时带来线程膨胀 |
这三者的差异,是理解线上“为什么 goroutine 多不一定有事,但 threads 多通常有事”的关键。
8.2 一个最常见的链路:goroutine -> syscall -> thread
mermaid
flowchart LR
G["业务 goroutine"] --> S["syscall / cgo 调用"]
S --> M["绑定的 M 被占住"]
M --> P["P 被 handoff 给其他 M"]
P --> NEWM["runtime 可能创建新线程继续跑其他 G"]
NEWM --> TH["线程数上升"]如果少量 syscall 很快返回,这套机制没有问题;但如果是下面这些场景,线程数就可能持续上涨:
- 阻塞磁盘 I/O
- DNS 退化到 libc / cgo 解析
cgo调第三方库且库内部阻塞- 应用主动调用长时间阻塞的 syscall
这时你会看到:
goroutine数量可能不夸张- 但线程数持续增加
- 容器 CPU 抖动、RSS 上涨
top -H里线程很多,schedtrace里threads=上升
8.3 为什么 cgo 特别危险
cgo 最大的问题不是“慢一点”,而是它会绕开 Go runtime 对阻塞的精细控制。
text
纯 Go 网络 I/O:
goroutine 阻塞 → netpoll 接管 → M/P 可继续调度其他 G
cgo 阻塞:
goroutine 进入 cgo → 往往直接占住一个 OS 线程
如果调用时间长,runtime 只能继续创建新 M 顶上因此 cgo 常见的副作用是:
- 线程数膨胀
- 栈内存上涨
- 调度器可控性下降
- GC 和抢占行为变复杂
九、调度器排障 Runbook
9.1 先看哪几个指标
| 指标 | 现象 | 常见含义 |
|---|---|---|
runtime.NumGoroutine() | goroutine 暴涨 | 请求堆积、泄露、下游超时 |
线程数(threads=) | 持续上升 | syscall / cgo / 阻塞线程过多 |
idleprocs | 很多 P 空闲 | 可能没有足够 runnable G,或业务在等 I/O |
runqueue | 长期偏大 | CPU 不够、热点锁、长任务占住 P |
| block profile | 阻塞热点 | channel / mutex / cond 等 |
| mutex profile | 锁竞争热点 | 临界区大、热点锁 |
9.2 schedtrace 怎么看
text
SCHED 1000ms: gomaxprocs=8 idleprocs=0 threads=40 spinningthreads=1 runqueue=120 [20 18 15 14 16 12 13 12]这个例子说明:
idleprocs=0:所有 P 都忙threads=40:线程远大于GOMAXPROCS=8,通常不正常runqueue=120:可运行 G 在排队,CPU 已经吃满或者被长任务占住
如果看到的是:
text
gomaxprocs=8 idleprocs=6 threads=35 runqueue=0则更像是:并不是 CPU 忙,而是很多 goroutine 在 syscall / cgo / 网络 / 下游等待。
9.3 常见故障模式
模式一:下游 MySQL 变慢
mermaid
sequenceDiagram
participant U as 用户请求
participant G as Goroutine
participant DB as MySQL
participant RT as Go Runtime
U->>G: 进入 handler
G->>DB: 查询数据库
Note over DB: 慢 SQL / 锁等待
G-->>RT: 大量 goroutine 处于等待 I/O
RT-->>RT: goroutine 数上涨,但线程不一定暴涨这类问题更像请求堆积,不是调度器本身坏了。
模式二:大量阻塞 syscall / cgo
mermaid
sequenceDiagram
participant G as Goroutine
participant M as M / Thread
participant C as cgo / syscall
participant RT as Runtime
G->>M: 获得执行
G->>C: 进入阻塞调用
C-->>M: 长时间不返回
RT->>RT: 创建新 M 顶上
RT-->>RT: threads 数持续增加这类问题的核心风险是:
- 线程数打爆
- 栈和调度开销上涨
- 容器内存压力变大
9.4 推荐排障顺序
text
1. 看 goroutine 数、线程数、CPU 使用率
2. 看 schedtrace:runqueue / idleprocs / threads
3. 看 goroutine dump:是卡在 channel、netpoll、syscall 还是 cgo
4. 看 block/mutex/CPU profile
5. 再回头确认下游依赖是否变慢经验上:
goroutine多 +threads不高:更像 I/O 等待、下游慢、请求堆积threads明显升高:优先查 syscall、DNS、cgorunqueue很长:优先查 CPU、锁竞争、长任务不让出
十、调度延迟的量化分析
10.1 从 Runnable 到 Running 要多久?
调度延迟(Scheduling Latency)= goroutine 从进入 runnable 状态到真正开始执行的等待时间。这个延迟直接影响 P99。
典型数值(基于 Go 1.21+,8 核机器,中等负载):
| 场景 | 调度延迟 | 说明 |
|---|---|---|
| P 本地队列有空位,无竞争 | < 100ns | 最快路径:schedule() → runqget → execute |
| 需要 work stealing | 1-10μs | 随机选 P + 可能的锁 + cache miss |
| 全局队列获取 | 500ns-5μs | 需要获取 sched.lock |
| netpoll 唤醒 | 1-20μs | epoll_wait 返回 + 放入 runq |
| 所有 P 都忙,排队等待 | 10μs-数ms | 取决于队列深度和当前 G 执行时间 |
| 抢占延迟(信号抢占) | ~10ms 上限 | sysmon 检测周期 |
10.2 调度延迟和 P99 的关系
mermaid
flowchart LR
A["请求进入"] --> B["goroutine 创建"]
B --> C["等待调度<br/>(scheduling latency)"]
C --> D["业务执行"]
D --> E["可能再次被抢占/阻塞"]
E --> F["响应返回"]关键洞察:
text
请求总延迟 = 调度延迟 + 业务执行时间 + (可能的多次调度切换)
如果业务逻辑本身只需 1ms,但调度延迟有 500μs:
P50 ≈ 1ms(大部分请求调度很快)
P99 ≈ 1.5-2ms(少量请求等待调度)
P999 ≈ 10ms+(极端情况:被抢占 + 重新调度)什么时候调度延迟会成为瓶颈:
| 条件 | 影响 | 判断方式 |
|---|---|---|
| GOMAXPROCS 设置过小 | P 不够,runnable G 排队 | schedtrace 看 runqueue |
| CPU 密集型 G 不让出 | 其他 G 等待抢占(最多 10ms) | trace 看 scheduling wait |
| goroutine 数远大于 P 数 | 排队严重 | runtime.NumGoroutine() vs GOMAXPROCS |
| work stealing 频繁 | 每次 steal 有 cache miss 代价 | schedtrace 看 spinning |
10.3 Work Stealing 的隐藏代价
Work stealing 保证了负载均衡,但它不是免费的:
text
steal 一次的成本:
1. 随机选择目标 P → 可能选到空的 P(白跑一趟)
2. 锁定目标 P 的 runq → 原子操作 + 可能的 cache line bounce
3. 拷贝一半的 G 到本地 → 内存拷贝 + cache miss(G 的数据在目标 P 的 cache 里)
4. 被偷的 G 在新 P 上执行 → 之前的 cache 预热全部失效
总代价: 1-10μs + 后续 cache cold start什么时候 work stealing 代价明显:
- 大量短生命周期 goroutine(每个 G 执行 < 10μs)
- 频繁的 steal 导致 cache 亲和性完全丧失
schedtrace中spinningthreads持续 > 0
10.4 用 runtime/trace 看调度延迟
runtime/trace 是分析调度延迟最精确的工具:
go
import (
"os"
"runtime/trace"
)
func main() {
f, _ := os.Create("trace.out")
trace.Start(f)
defer trace.Stop()
// ... 业务代码 ...
}bash
# 生成 trace 文件
go run -trace trace.out main.go
# 或者通过 HTTP 采集(生产推荐)
import _ "net/http/pprof"
# curl http://localhost:6060/debug/pprof/trace?seconds=5 > trace.out
# 分析
go tool trace trace.outtrace 视图中关键看什么:
| 视图 | 看什么 | 含义 |
|---|---|---|
| Goroutine analysis | Scheduling wait | 每个 goroutine 等待调度的时间分布 |
| Scheduler latency | 延迟直方图 | 整体调度延迟分布 |
| Proc (P) timeline | 每个 P 的忙闲 | 是否有 P 长期空闲或长期被占 |
| Network wait | 网络等待时间 | netpoll 唤醒是否及时 |
典型问题在 trace 中的表现:
text
问题: CPU 密集型 goroutine 占住 P
trace 表现:
- 某个 G 在 P 上连续执行 10ms+(直到被信号抢占)
- 其他 G 的 "Scheduling wait" 出现 10ms 的尖峰
- Scheduler latency 直方图出现 10ms 附近的长尾
问题: goroutine 泄漏导致排队
trace 表现:
- 所有 P 都满载
- runqueue 持续增长
- 新 G 的 scheduling wait 线性增长
问题: 频繁 syscall 导致线程膨胀
trace 表现:
- Proc timeline 上频繁出现 "syscall" 标记
- 线程数持续上升
- P 频繁在不同 M 之间切换10.5 调度延迟优化方向
| 问题 | 优化方向 | 具体措施 |
|---|---|---|
| P 不够 | 增加 GOMAXPROCS | 容器环境用 automaxprocs |
| CPU 密集 G 占住 P | 主动让出 | 循环中加 runtime.Gosched() |
| goroutine 过多排队 | 控制并发度 | 信号量、worker pool、限流 |
| work stealing 频繁 | 提高 cache 亲和性 | 减少短 G、批量处理 |
| 抢占延迟 | 升级 Go 版本 | Go 1.14+ 信号抢占(10ms 上限) |
10.6 一个量化对比表
| 操作 | 典型耗时 | 对比 |
|---|---|---|
| goroutine 切换(用户态) | ~200ns | |
| OS 线程切换(内核态) | ~1-5μs | 慢 5-25× |
| work stealing 一次 | ~1-10μs | |
| 信号抢占触发 | ~10ms(最坏) | |
| netpoll 唤醒 | ~1-20μs | |
| channel send/recv(无竞争) | ~50-100ns | |
| mutex Lock(无竞争) | ~20-50ns | |
| mutex Lock(有竞争) | ~μs-ms | 取决于持有时间 |
十一、有栈协程 vs 无栈协程
Go 的 goroutine 属于有栈协程(stackful coroutine),这是理解 GMP 跨语言对比的关键。
10.1 概念对比
有栈协程(Stackful Coroutine)
┌─────────────────────────────┐
│ 每个协程有自己独立的栈 │
│ 可以任意位置挂起/恢复 │
│ 调度器保存完整寄存器上下文 │
│ 代表:Go goroutine, Lua │
└─────────────────────────────┘
无栈协程(Stackless Coroutine)
┌─────────────────────────────┐
│ 协程没有独立栈,复用调用者栈 │
│ 只能在顶层函数挂起 │
│ 编译器将函数拆成状态机 │
│ 代表:Python async, Rust async, C++20 coroutine, JS async/await │
└─────────────────────────────┘10.2 有栈协程(Go 模式)的优缺点
优点:
- 编程模型简单:写同步代码就行,Go 运行时自动处理挂起/恢复,无需
async/await标记 - 任意深度挂起:可以在嵌套多层函数调用中任意位置阻塞(channel、锁、I/O),调度器自动保存整个调用栈
- 栈按需增长:初始 2KB,可增长到 1GB,递归和其他深度调用天然安全
缺点:
- 内存开销:每个 goroutine 至少 2KB 栈,百万 goroutine = 2GB+(虽然比线程 1MB 好很多)
- 栈拷贝开销:栈增长需要复制整个栈,大栈的 goroutine 切换成本高
- 调度器复杂:需要在用户态实现完整的寄存器上下文保存/恢复
10.3 无栈协程的优缺点
优点:
- 零内存开销:不需要独立栈,一个协程就是一个状态机结构体(几十字节)
- 无需栈拷贝:状态保存在固定大小的结构体中
- 更适合嵌入式/IoT:内存极度受限场景
缺点:
- 函数染色(function coloring):
async函数只能被await调用,导致整个调用链都变async - 不能深层挂起:只能在顶层
await挂起,不能在普通函数中直接调用阻塞操作 - 需要编译器支持:编译器将
async函数编译成状态机,实现复杂
10.4 各语言协程实现一览
| 语言 | 协程类型 | 实现方式 | 内存占用 | 切换开销 |
|---|---|---|---|---|
| Go | 有栈 | GMP 调度器,用户态上下文切换 | ~2KB 栈 | ~200ns |
| Python | 无栈 | asyncio + async/await,事件循环 | 几十字节 | ~100ns |
| C++20 | 无栈 | co_await/co_return,编译器生成状态机 | 几十字节 | ~100ns |
| Rust | 无栈 | async/await + Future trait,零成本抽象 | 状态机大小 | ~100ns |
| Kotlin | 无栈 | suspend + 编译器 CPS 变换 | 几十字节 | ~100ns |
| Java 21 | 有栈 | Virtual Threads (Project Loom),与 OS 线程 1:1 调度 | ~1KB | ~1μs |
| Erlang | 有栈 | 轻量进程(类似 goroutine),抢占式调度 | ~300 words | ~100ns |
| Lua | 有栈 | lua_newthread + lua_resume | ~100 bytes | ~100ns |
10.5 跨语言调度模型对比
mermaid
graph TB
subgraph "Go GMP —— M:N 两级调度"
direction TB
G1["G1"] --> P1["P0 (本地队列)"]
G2["G2"] --> P1
P1 --> M1["M (OS Thread)"]
G3["G3"] --> P2["P1 (本地队列)"]
G4["G4"] --> P2
P2 --> M2["M (OS Thread)"]
end
subgraph "Java Virtual Thread —— N:1 用户态调度"
direction TB
V1["Virtual Thread 1"] --> CT1["Carrier Thread (OS)"]
V2["Virtual Thread 2"] --> CT1
V3["Virtual Thread 3"] --> CT1
end
subgraph "Python asyncio —— 单线程事件循环"
direction TB
T1["Task 1 (async)"] --> EL["Event Loop (单线程)"]
T2["Task 2 (async)"] --> EL
EL --> OS["OS Thread"]
endGo GMP 的独特优势:
- M:N 模型:N 个 goroutine 运行在 M 个 OS 线程上,充分利用多核
- Work stealing 自动负载均衡,无需手动指定线程亲和性
- 网络 I/O 完全非阻塞,线程不阻塞,一个线程能同时处理大量连接
- 系统调用自动 handoff P,不影响其他 goroutine
十二、跨语言调度模型对比
mermaid
graph TB
subgraph "Go GMP —— M:N 两级调度"
direction TB
G1["G1"] --> P1["P0 (本地队列)"]
G2["G2"] --> P1
P1 --> M1["M (OS Thread)"]
G3["G3"] --> P2["P1 (本地队列)"]
G4["G4"] --> P2
P2 --> M2["M (OS Thread)"]
end
subgraph "Java ForkJoinPool —— 工作窃取"
direction TB
J1["Task1"] --> WQ1["Worker Queue (Deque)"]
J2["Task2"] --> WQ1
WQ1 --> JT1["ForkJoinWorkerThread"]
J3["Task3"] --> WQ2["Worker Queue (Deque)"]
J4["Task4"] --> WQ2
WQ2 --> JT2["ForkJoinWorkerThread"]
end
subgraph "Java Virtual Thread —— N:1 携带式"
direction TB
V1["Virtual Thread1"] --> CT1["Carrier Thread"]
V2["Virtual Thread2"] --> CT1
V3["Virtual Thread3"] --> CT1
end
subgraph "Erlang/Akka —— Actor 模型"
direction TB
E1["Actor1 (Mailbox)"] --> ES1["Scheduler Thread1"]
E2["Actor2 (Mailbox)"] --> ES1
E3["Actor3 (Mailbox)"] --> ES2["Scheduler Thread2"]
end| 维度 | Go GMP | Java ForkJoinPool | Java Virtual Thread | Erlang Scheduler |
|---|---|---|---|---|
| 调度模型 | M:N (G→P→M) | 工作窃取 (Deque) | N:1 用户态 (Carrier) | M:N 抢占式 |
| 任务单位 | Goroutine (~2KB栈) | ForkJoinTask | Virtual Thread (~1KB) | Process (~300 words) |
| 工作窃取 | ✅ 随机选P,偷一半 | ✅ LIFO pop / FIFO steal | ❌ (无需,1:1 mount) | ✅ 按任务数均衡 |
| 抢占机制 | 信号异步抢占(10ms) | ❌ 协作式(yield) | ❌ 协作式(IO/block) | ✅ 归约计数器(每函数调用) |
| 网络I/O | netpoll异步(epoll) | 线程阻塞 | 自动unmount/unpark | 原生NIF/link驱动 |
| 公平性 | 全局队列每61次轮询 | 随机窃取 | N/A | 任务迁移 |
| GC协作 | 写屏障+STW协调 | Safepoint | Safepoint | 独立GC进程 |
| 适合场景 | 通用并发(网络+计算) | 递归分解(fork/join) | 高并发I/O(DB/web) | 容错分布式系统 |
Go GMP 的独特优势:M:N 模型 + Work Stealing + 信号异步抢占 + netpoll 网络轮询器,四位一体。Java ForkJoinPool 擅长 CPU 密集的递归分解,Java Virtual Thread 适合 I/O 密集型但无网络轮询优化,Erlang 适合容错分布式但非通用计算。
十三、详细交互图
12.1 GMP 全生命周期交互
mermaid
sequenceDiagram
participant G1 as Goroutine G1
participant P as Processor P0
participant M as Machine M0
participant Net as NetPoller
participant OS as OS Kernel
Note over G1,M: 1. 正常执行
G1->>M: 获取 G 并执行
M->>P: 从 runq 获取下一个 G
P-->>M: 返回 G1
M->>G1: execute(G1)
Note over G1,OS: 2. 网络 I/O 阻塞
G1->>G1: conn.Read() → 非阻塞,返回 EAGAIN
G1->>Net: netpoll 注册 fd,G1 挂起
Net-->>P: P 从 runq 取 G2 继续执行
Note over G1,OS: 3. 系统调用阻塞
G1->>G1: syscall.Read()
G1->>P: entersyscall() → 解绑 P
P->>M: P 获取新 M' (handoff)
G1->>OS: 阻塞在内核
Note over G1,OS: 4. 系统调用返回
OS-->>G1: 返回
G1->>G1: exitsyscall() → 尝试获取 P
alt P 空闲
G1->>P: 重新绑定
else P 均忙
G1->>G1: 放入全局队列 runq
M->>M: M 休眠等待
end
Note over G1,Net: 5. 网络事件就绪
Net-->>P: netpoll(true) → [G1, G3, ...]
P->>P: 就绪的 G 放回本地 runq12.2 Handoff 细节过程
场景:G1 执行 read() 系统调用
┌────────────────────────────────────────────────┐
│ Step 1: entersyscall │
│ ┌──────┐ ┌────┐ ┌────┐ │
│ │ G1 │──│ M0 │──│ P0 │ 正常执行 │
│ └──────┘ └────┘ └────┘ │
├────────────────────────────────────────────────┤
│ Step 2: 解绑 │
│ ┌──────┐ ┌────┐ ┌────┐ │
│ │ G1 │──│ M0 │ │ P0 │ P0 变成空闲 │
│ └──────┘ └────┘ └────┘ │
├────────────────────────────────────────────────┤
│ Step 3: P0 绑定新 M (handoff) │
│ ┌──────┐ ┌────┐ ┌────┐ │
│ │ G2 │──│ M1 │──│ P0 │ G2 可以继续执行 │
│ └──────┘ └────┘ └────┘ │
│ G1 在 M0 上阻塞在 syscall 中 │
├────────────────────────────────────────────────┤
│ Step 4: exitsyscall │
│ ┌──────┐ ┌────┐ P0 还在忙? │
│ │ G1 │──│ M0 │ G1 → 全局队列 → 后续被 P 捡起 │
│ └──────┘ └────┘ │
│ M0 休眠,等待下次需要时唤醒 │
└────────────────────────────────────────────────┘
登录后即可发表评论 👇