Skip to content

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 有一个特殊的 g0 goroutine,用于执行调度逻辑。普通 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
    end

3.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/goroutine
go
// 代码中打印 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 / timergopark()只阻塞 G,不阻塞线程
网络 I/O交给 netpollerG 挂起,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、cgo
  • runqueue 很长:优先查 CPU、锁竞争、长任务不让出

十、调度延迟的量化分析 ​

10.1 从 Runnable 到 Running 要多久? ​

调度延迟(Scheduling Latency)= goroutine 从进入 runnable 状态到真正开始执行的等待时间。这个延迟直接影响 P99。

典型数值(基于 Go 1.21+,8 核机器,中等负载):

场景调度延迟说明
P 本地队列有空位,无竞争< 100ns最快路径:schedule() → runqget → execute
需要 work stealing1-10μs随机选 P + 可能的锁 + cache miss
全局队列获取500ns-5μs需要获取 sched.lock
netpoll 唤醒1-20μsepoll_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.out

trace 视图中关键看什么:

视图看什么含义
Goroutine analysisScheduling 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"]
    end

Go 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 GMPJava ForkJoinPoolJava Virtual ThreadErlang Scheduler
调度模型M:N (G→P→M)工作窃取 (Deque)N:1 用户态 (Carrier)M:N 抢占式
任务单位Goroutine (~2KB栈)ForkJoinTaskVirtual Thread (~1KB)Process (~300 words)
工作窃取✅ 随机选P,偷一半✅ LIFO pop / FIFO steal❌ (无需,1:1 mount)✅ 按任务数均衡
抢占机制信号异步抢占(10ms)❌ 协作式(yield)❌ 协作式(IO/block)✅ 归约计数器(每函数调用)
网络I/Onetpoll异步(epoll)线程阻塞自动unmount/unpark原生NIF/link驱动
公平性全局队列每61次轮询随机窃取N/A任务迁移
GC协作写屏障+STW协调SafepointSafepoint独立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 放回本地 runq

12.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 休眠,等待下次需要时唤醒                      │
└────────────────────────────────────────────────┘

参考:Go 源码 runtime/proc.go、runtime/runtime2.go

批注模式

💬 文章评论

暂无评论,来说点什么吧 👇

编程学习笔记