Go GMP 调度模型:Goroutine 如何被高效调度

Go 0 次阅读
Go GMP 调度模型:Goroutine 如何被高效调度

你的 go func() 创建完后发生了什么?探秘 Go GMP 调度器

你写下 go func() 只花了 1 毫秒,但 Go 运行时为了让它执行起来,背后跑了上万行调度代码。

从一个场景说起

先看一段人畜无害的代码:

package main

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

func main() {
    runtime.GOMAXPROCS(2) // 用 2 个逻辑处理器
    var wg sync.WaitGroup

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            // 模拟一些计算
            sum := 0
            for j := 0; j < 1000000; j++ {
                sum += j
            }
            fmt.Printf("goroutine %d done, sum=%d\n", id, sum)
        }(i)
    }
    wg.Wait()
}

表面上看:main 函数起了 10 个 goroutine,等它们执行完就退出了。但你有没想过这些问题:

  • 10 个 goroutine 在 2 个 P(逻辑处理器)上跑,怎么分配的?
  • 所有 goroutine 都在抢一个线程?还是有多个线程?
  • 如果一个 goroutine 卡在系统调用上了,后面的 goroutine 怎么办?

这些问题,都需要理解 Go 的 GMP 调度模型才能回答。


核心原理

为什么要造一个调度器?——历史包袱

在没有 goroutine 的时代,写高并发要么用进程(太重),要么用线程(也不少):

  • 一个内核线程至少占用 1MB 栈空间
  • 线程切换需要陷入内核态,涉及上下文保存、TLB 刷新等,约 1-3μs
  • 高并发下几万个线程,光是栈空间就吃掉几十 GB

Go 的解决思路:把"线程"拆两层——用户态的 goroutine(G)内核态的线程(M)。G 初始栈才 2KB,切换在用户态完成,代价不到 0.1μs。

但光拆还不够,调度器走了三次迭代:

阶段一:GM 模型(Go 1.0)
只有一个全局队列 + 一个锁。所有 M 从全局队列拿 G 都要抢锁。并发一高,锁竞争就炸了。

阶段二:引入 P(Go 1.1)
Dmitry Vyokov 引入了 Processor(P)—— 每个 P 维护自己的本地运行队列。M 先看本地队列,不够了才去全局"偷",大幅降低锁竞争。

阶段三:协作+信号抢占(Go 1.14)
加入基于信号的真正抢占机制,解决了大循环/GC 无限霸占线程的问题。

现在你看到的就是经过三次进化的 GMP 调度模型

三个角色:G、M、P

角色 全称 职责 数量
G Goroutine 协程,用户态轻量级执行单元 无上限(百万级别)
M Machine 系统线程,真正干活的 最多 10000,一般等于 P 数
P Processor 逻辑处理器,调度"调度员" 默认 = CPU 核数(GOMAXPROCS)

每个角色都有对应的 Go 运行时结构体:

// src/runtime/runtime2.go(简化后)

// G —— 一个 goroutine
type g struct {
    stack       stack      // 栈内存范围 [lo, hi)
    stackguard0 uintptr    // 栈扩张/抢占用
    m           *m         // 当前执行的 M(可能为空)
    sched       gobuf      // 调度上下文(sp, pc, 寄存器)
    atomicstatus uint32    // 状态:_Gidle/_Grunnable/_Grunning...
    goid        int64
    lockedm     muintptr   // 锁定的 M
}
// M —— 系统线程
type m struct {
    g0       *g         // 调度栈 G,每个 M 独有的"管家"
    curg     *g         // 当前正在运行的 G
    p        puintptr   // 绑定的 P
    nextp    puintptr   // 备用 P(解绑时暂存)
    spinning bool       // 是否在"自旋"找活干
    mcache   *mcache    // 内存缓存(从 P 搬过来的)
}
// P —— 逻辑处理器
type p struct {
    status     uint32         // _Pidle / _Prunning / _Psyscall ...
    m          muintptr       // 绑定的 M
    mcache     *mcache        // 内存分配缓存
    runq       [256]guintptr  // 本地环形队列(256个槽位)
    runqhead   uint32         // 队头
    runqtail   uint32         // 队尾
    runnext    guintptr       // 下一个优先执行的 G(比 runq 优先级高)
    gFree      struct {       // 空闲 G 链表,复用避免重新分配
        gList
        n int32
    }
}

三者的关系用一句话概括:

G 是要执行的代码,M 是执行代码的线程,P 是 M 的"调度员"——为 M 提供可运行的 G。

调度流程完整拆解

看一个 goroutine 从创建到执行的完整生命周期:

程序启动 → 初始化 g0 + m0 → 创建 main goroutine → 开始调度循环

步骤 1:初始化
运行时创建 m0(主线程)和 g0(调度 goroutine,负责调度其他人),然后把两者绑定。再根据 GOMAXPROCS 创建 N 个 P。

步骤 2:放入队列
当你写 go func(),新创建的 G 优先放入当前 P 的本地队列(runq,256 槽位环形队列)。如果本地队列满了,会把本地队列的前一半 + 新 G 一起打乱后放入全局队列

步骤 3:M 获取 G
M 每次从绑定的 P 的本地队列取一个 G 执行(优先级:runnext > runq > 全局队列 > 偷其他 P)。

步骤 4:执行与归还
G 执行完毕后,M 切回 g0,再调度下一个 G。不断循环。

Work Stealing(任务窃取)

这是 GMP 调度器的灵魂机制

核心逻辑:当一个 M 发现自己的 P 的本地队列为空时,它不会闲着,而是:

  1. 先去全局队列捞一批 G(捞取数量 ≈ len(global)/GOMAXPROCS + 1,保证公平)
  2. 如果全局也空了,随机挑一个别的 P,偷它本地队列的一半 G
  3. 还是空?M 进入自旋状态(spinning),过一会儿再试
// 伪代码:findrunnable 的核心逻辑
func findrunnable() (gp *g) {
    // 1. 先看本地 runq
    if gp := runqget(_g_.m.p.ptr()); gp != nil {
        return gp
    }
    
    // 2. 尝试从全局队列获取
    if sched.runqsize > 0 {
        lock(&sched.lock)
        gp := globrunqget(_g_.m.p.ptr(), 0)
        unlock(&sched.lock)
        if gp != nil {
            return gp
        }
    }
    
    // 3. 偷!随机选一个 P,偷它一半
    for i := 0; i < 4; i++ {
        for _, pp := range allp {
            if gp := runqsteal(pp, _g_.m.p.ptr()); gp != nil {
                return gp
            }
        }
    }
    
    // 4. 真没了,进入自旋或休眠
    stopm()
    goto top // 唤醒后重新找
}

这个机制保证了:只要还有可运行的 G,就不会有无事可做的 M,最大化利用 CPU。

Hand Off(交接机制)

想象一个场景:G1 正在执行一个 系统调用(比如 read() 读文件),阻塞了。

如果是传统的线程模型,这个线程就挂在那了,它绑定的 P 上的其他 G 也跟着遭殃。

GMP 的 Hand Off 机制解决了这个问题:

① G1 发生系统调用 → M1 随 G1 一起阻塞
② P 检测到 M1 阻塞 → 将 P 与 M1 解绑
③ P 从空闲 M 列表拿一个 M2(或者新建一个),绑定后继续执行
④ G1 系统调用完成 → M1 苏醒,尝试找回原来的 P
   ⑤ 如果 P 还在 → 重新绑定 M1,继续执行 G1
   ⑥ 如果 P 已被占用 → G1 放入全局队列,M1 进入空闲列表

看个代码例子会更直观:

// 模拟 syscall 阻塞场景
package main

import (
    "fmt"
    "os"
    "time"
)

func main() {
    // 用 1 个 P,方便观察
    // runtime.GOMAXPROCS(1)  // 解除注释试试
    
    go func() {
        // 这个 G 会发生 syscall(读文件)
        f, _ := os.Open("/dev/zero")
        buf := make([]byte, 1024)
        for i := 0; i < 100; i++ {
            f.Read(buf) // 系统调用,不会阻塞 M 太长时间
        }
        fmt.Println("reader done")
    }()
    
    go func() {
        // 纯计算的 G
        sum := 0
        for i := 0; i < 10000000; i++ {
            sum += i
        }
        fmt.Println("calculator done")
    }()
    
    time.Sleep(1 * time.Second)
}

f.Read() 发生时,M 进入内核态。如果是纯计算 goroutine 在另一个 P 上,它完全不受影响。这就是 Hand Off 的最大价值:系统调用不是并发瓶颈


深入细节

G 的状态机

一个 goroutine 在不同阶段有 9 种状态,最核心的 6 种:

                  ┌─────────┐
                  │ _Gidle  │ ← 刚分配,未初始化
                  └────┬────┘
                       │
                  ┌────▼────┐
                  │_Grunnable│ ← 放入队列,等待执行
                  └────┬────┘
                       │
                  ┌────▼────┐
                  │_Grunning │ ← 正在 M 上执行
                  └──┬──┬───┘
            ┌────────┘  └────────┐
       ┌────▼────┐          ┌────▼────┐
       │_Gwaiting│          │_Gsyscall│
       │ 等待事件 │          │ 系统调用 │
       └────┬────┘          └────┬────┘
            └────────┬───────────┘
                ┌────▼────┐
                │_Grunnable│   ← 条件满足,重新排队
                └────┬────┘
                     │
                ┌────▼────┐
                │ _Gdead  │   ← 执行完毕,等待回收
                └─────────┘

一个关键细节:channel 阻塞不会阻塞 M。只有系统调用(文件 I/O、网络 syscall 等)才会让 M 一起阻塞。channel 操作本质是内存拷贝,不涉及内核,所以 goroutine 进入 _Gwaiting 时,M 直接去干别的活。

P 的五种状态

状态 含义
_Pidle 空闲,没有绑定 M
_Prunning 正在运行,绑定了 M
_Psyscall 绑定的 M 正在系统调用
_Pgcstop GC 阶段暂停
_Pdead 已废弃

GC 发生时,所有 P 都会被设置成 _Pgcstop,等 GC 完成后再恢复。这也是 Go 1.14 之前 STW(Stop The World)能卡住几百毫秒的原因——GC 需要等待所有 M 都把 G 停下来。

基于信号的抢占(Go 1.14+)

在 Go 1.14 之前,GMP 是"协作式抢占"——只有当 G 主动让出(比如函数调用、channel 操作)时,才会触发调度。但如果是这样的代码:

func endless() {
    for {}  // 死循环,永远不调度
}

这个 goroutine 会霸占线程不放,其他 goroutine 全部饿死。

Go 1.14 引入了基于信号的真正抢占

  1. 监控线程(sysmon)定期检查每个运行中的 G
  2. 如果某个 G 运行超过 10ms,发送 SIGURG 信号给其所在线程
  3. 信号处理函数将 G 的 stackguard0 置为特殊值
  4. G 在下次指令边界被"骗"进调度函数,主动让出
// 验证抢占:跑个死循环,看其他 G 能否执行
package main

import (
    "fmt"
    "time"
)

func main() {
    go func() {
        for {
            fmt.Println("alive!")
            time.Sleep(500 * time.Millisecond)
        }
    }()
    
    // 死循环,但会被抢占
    go func() {
        for {
            // Go 1.14 之后,空循环也会被信号打断
        }
    }()
    
    time.Sleep(3 * time.Second)
}

这段代码在 Go 1.14+ 上,fmt.Println 能正常输出。在 1.14 之前,第二个 for{} 会锁死整个线程,"alive!" 永远打不出来。

全局队列的公平性保障

GMP 调度器并不是"本地队列优先"的一言堂。为了保证公平性,每次从全局队列获取 G 时有一个概率控制

// 如果 P 已经连续调度了 61 次,强制检查一次全局队列
if _g_.m.p.ptr().schedtick%61 == 0 {
    if gp := globrunqget(_g_.m.p.ptr(), 1); gp != nil {
        return gp
    }
}

这意味着每 61 次本地调度,至少有 1 次会去全局队列看看。防止全局队列中的 G 被饿死。


最佳实践

1. GOMAXPROCS 怎么设?

// 最佳实践:不设,保持默认
// 默认 = CPU 核数,大部分场景最优

但在容器环境(K8s、Docker)中要注意:

// 容器场景:必须显式设置!
// 因为 runtime.NumCPU() 检测的是宿主机的核数,不是容器的 limit
import "runtime"

// 推荐用 uber/automaxprocs 自动处理
import _ "go.uber.org/automaxprocs"

2. 不要滥用 goroutine

GMP 再好,也不是无代价的。每个 G 至少占用 2KB+ 元数据:

// ❌ 不要这样
for _, item := range hugeList {
    go process(item) // 百万级 goroutine 会压垮调度器
}

// ✅ 用 worker pool 限制
sem := make(chan struct{}, 100) // 限制并发 100
for _, item := range hugeList {
    sem <- struct{}{}
    go func(it Item) {
        defer func() { <-sem }()
        process(it)
    }(item)
}

3. 注意系统调用密集型场景

频繁的系统调用会导致 P 频繁 Hand Off,增加开销:

// ❌ 高频系统调用,每个都创建 goroutine
for {
    go func() {
        data, _ := os.ReadFile("/some/file") // syscall 频繁
        process(data)
    }()
}

// ✅ 用 goroutine 池 + 批量读取

4. 用 GODEBUG 观察调度

调试时可以用环境变量观察 GMP 的工作状态:

# 每 1000ms 输出调度摘要
GODEBUG=schedtrace=1000 go run main.go

# 输出示例:
# SCHED 1004ms: gomaxprocs=4 idleprocs=2 threads=5 spinningthreads=1 idlethreads=1 runqueue=0 [0 3 2 0]
#           ↑P数量  ↑空闲P数  ↑M总数   ↑自旋M数    ↑空闲M数  ↑全局G数  ↑每个P的本地G数

5. 避免 goroutine 泄漏

// ❌ 泄漏!G 永远阻塞,M 被"套牢"
go func() {
    <-neverClosed // 没人写数据,永远不返回
}()

// ✅ 用 context 超时控制
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go func() {
    select {
    case <-ctx.Done():
        return
    case val := <-someChan:
        process(val)
    }
}()

总结

GMP 调度模型是 Go 并发编程的基石。它用三个核心思路解决了传统线程模型的困境:

  1. P 的引入将全局锁竞争降低了几个数量级,每个 P 的本地队列可以无锁访问,只有跨 P 偷 G 时才涉及少量锁操作
  2. Work Stealing 保证了所有系统资源都被充分利用——没有 M 会白白闲着
  3. Hand Off 让系统调用不再是并发的死敌,阻塞一个 M 不影响其他 G 继续运行

理解了 GMP,你就理解了为什么 Go 敢说"十万 goroutine 洒洒水"——这背后是一个精心设计、经过十多年迭代的用户态调度器在默默支撑。

延伸阅读方向

  1. 深入 src/runtime/proc.go 中的 schedule()findrunnable()execute() 三个核心函数,看调度循环的源码级实现
  2. 对比学习 Kubernetes Scheduler 的调度策略,你会发现"队列 + 抢占 + 窃取"的设计思路在大型系统中无处不在
  3. 阅读 Go 官方提案 Proposal: Non-cooperative goroutine preemption 深入了解基于信号的抢占式调度设计细节