Go 高级教程:深入理解 GMP 调度器

为什么 Go 语言能轻松支撑百万并发? 为什么 Goroutine 切换成本这么低?

这一切的背后,都站着一位神秘的大管家 —— GMP 调度器

1. 为什么需要 GMP?

在很久很久以前(其实也就几十年前),我们写代码都是直接跟 线程 (Thread) 打交道。线程是操作系统(OS)调度的最小单位。

但是,线程这玩意儿太“贵”了:

  1. 内存占用高:一个线程栈大概要几 MB。
  2. 切换成本大:线程切换需要陷入内核态,保存寄存器、上下文,这简直就是“劳民伤财”。

这时候,Go 语言的设计师们拍案而起:“我们要造一种更轻量的线程!” 于是,Goroutine (协程) 诞生了。它初始只要几 KB,切换成本极低。

这就带来了一个问题:操作系统只认识线程,不认识 Goroutine。谁来负责把成千上万个 Goroutine 分配给 CPU 跑呢?

这就需要一个“中间商” —— Go 运行时调度器 (Scheduler)

线程与协程的区别 图示: Thread 与 Goroutine 的区别

2. GMP 模型大揭秘

GMP 其实是三个角色的缩写:

  • G (Goroutine):我们写的代码任务,也就是协程。
  • M (Machine):工作线程(Thread),对应操作系统的真实线程。它是真正的干活人(搬砖工)。
  • P (Processor):逻辑处理器(Context),可以理解为“调度上下文”或“资源”。它是包工头,负责管理 G,并把 G 交给 M 去执行。

形象的比喻

想象一个大型搬砖工地:

  • G (砖头):待搬运的任务。
  • M (工人):负责搬砖的劳动力。
  • P (手推车):工人必须推着车才能搬砖(因为车里装着搬砖工具和任务清单)。

如果没有 P(手推车),M(工人)就不知道该干啥。

GMP 模型比喻 图示: GMP 模型示意图

3. 调度器的核心策略

Go 调度器之所以高效,全靠以下几招“绝活”。

3.1 两个队列

P(手推车)不仅自己带着一小堆砖头(本地队列 Local Queue),工地上还有一个巨大的公共砖头堆(全局队列 Global Queue)。

  1. 优先吃独食:M(工人)推着 P(车)干活时,优先从 P 的本地队列里取 G(砖头)。这不需要加全局锁,速度飞快!
  2. 偶尔吃大锅饭:如果本地队列空了,M 就会去全局队列取 G(这时候要加锁,慢点但安全)。

3.2 WORK STEALING (工作窃取)

这是最骚的操作。

如果 M1(工人甲)手脚麻利,把自己车里和全局堆里的砖头都搬完了,而 M2(工人乙)还在呼哧呼哧地干活,M1 绝不会闲着抽烟。

M1 会悄悄跑到 M2 的车里,偷走一半的砖头(G)来干!

这就保证了所有 CPU 核心都一直忙碌,绝不摸鱼。

工作窃取

(图示:工人甲(M1)的车空了,正在从工人乙(M2)满满的车里"窃取"一部分砖头)

3.3 HAND OFF (移交)

如果 M1(工人甲)正在搬一块“超级重”的砖头(G 进行了系统调用,比如读文件,导致线程阻塞),M1 就被卡住了,动弹不得。

这时候,P(手推车)里的其他 G(砖头)咋办?

P 会果断抛弃 M1,带着剩下的 G 转移到另一个空闲的 M(或者新建一个 M)身上继续干活。

这就是 P 的无情剥离,保证了任务队列永远有 CPU 在跑。

4. Show Me The Code

我们用代码来验证一下 G 的轻量级。

我们尝试启动 100 万个 Goroutine,看看电脑会不会爆炸。

package main

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

func main() {
    // 查看当前系统的 P 数量(通常等于 CPU 核数)
    fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))

    var wg sync.WaitGroup
    count := 1000 * 1000 // 100 万

    start := time.Now()
    for i := 0; i < count; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            // 假装做点复杂的数学运算
            _ = 1 + 1
        }()
    }
    wg.Wait()

    fmt.Printf("Finished %d goroutines in %v\n", count, time.Since(start))
}

运行结果(Mac M1 Pro):

GOMAXPROCS: 10
Finished 1000000 goroutines in 280ms

仅仅用了 280 毫秒!如果换成 Java 线程,这台电脑估计已经闻到焦味了。

5. 总结

Go 调度器的核心哲学:

  1. 复用线程:避免频繁创建、销毁 M。
  2. 利用多核:通过 P 管理并发,让 M 并行执行。
  3. 抢占式调度:防止某个 G 霸占 CPU 太久(Go 1.14 引入基于信号的抢占)。

理解了 GMP,你就理解了 Go 高并发的基石。下次面试官问你“协程为什么快”,请把“工人推车搬砖”的故事讲给他听。


思考题: 如果你的 Go 程序里的 G 全是死循环(比如 for {}),会发生什么?其他的 G 还有机会执行吗?(提示:搜索“GMP 抢占调度”)

本文代码示例


相关阅读