This repository has been archived on 2026-05-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
obsidian/DEV/GO/GMP调度模型.md
T

5.6 KiB
Raw Blame History

tags, create time
tags create time
go
golang
runtime
GMP
goroutine
scheduler
2026-04-24 10:00

GMP 调度模型

概述

Go 的并发模型建立在 GMP 调度架构之上。与传统的 OS 线程 1:1 模型不同,Go 引入了 G(Goroutine)、M(Machine/Worker)、P(Processor)三层抽象,实现了高效的用户态调度。

思考:如果 Go 直接使用 OS 线程(1:1 模型),同时启动百万级 goroutine 会发生什么?内存开销和上下文切换代价分别有多大?

GMP 的核心目标是:用最小的调度开销,最大化多核 CPU 的并行利用率。

G — Goroutine(协程)

G 是 Go 并发执行的最小单元——协程。它的核心特征:

  • 初始栈空间 2KB,可动态伸缩(最大可达 1GB)
  • 栈的伸缩采用分段扩展策略(segment),每次增长时翻倍
  • 每个 G 包含:程序计数器(PC)、寄存器状态、栈指针、等待队列指针
// 创建 goroutine 的代价极低
go func() {
    // 这个匿名函数运行在一个独立的协程中
    fmt.Println("running in goroutine")
}()

// 对比:创建 OS 线程的代价
// runtime/debug.SetThreadThreadsLimit() 限制线程数
// 通常 OS 线程栈默认 1~8MB,且不可动态伸缩

为什么 2KB 起步? 大多数函数不需要很大的栈空间。小默认值让创建海量协程成为可能。

M — Machine(执行器)

M 代表工作线程,绑定一个 OS 线程,真正执行 G 的代码。

  • M : OS Thread = 1 : 1,M 是 G 的执行载体
  • M 负责执行 G 的代码,同时参与 GMP 调度循环
  • 当 G 阻塞(如网络 IO、channel 操作)时,M 会被 P 释放去执行其他 G
// 限制 Go 使用的 OS 线程数,理解 M 的规模
// GOPTHREADS=1000 go run main.go

// runtime.NumGoroutine() 返回 goroutine 数量
// runtime.NumCPU() 返回逻辑 CPU 数量
// M 的数量默认是 GOMAXPROCS(默认 NumCPU)

P — Processor(处理器)

P 是调度器本地状态的抽象,可以理解为一个"局部调度器"。

  • P 维护一个本地 goroutine 队列(runq),最多缓存 256 个待执行的 G
  • P 的数量由 GOMAXPROCS 控制(Go 1.5+ 默认等于 CPU 逻辑核心数)
  • 每个时刻,一个 P 只能绑定一个 M 来执行代码

P 的核心职责:

职责 说明
本地调度 从自己的 runq 取出 G 分配给绑定的 M 执行
工作窃取(Work Stealing) 当自己的队列为空时,从其他 P 的队列中"偷"一半的 G
全局队列 所有 P 共享一个全局 G 队列(最多容纳 64 个),用于负载均衡
系统调用处理 当绑定的 M 进入系统调用时,P 会"脱落"(handoff),找一个空闲 M 绑定

GMP 调度循环

flowchart TD
    subgraph GQueue["G 队列"]
        G1["G1 待执行"]
        G2["G2 待执行"]
        G3["G3 待执行"]
        G4["G4 待执行"]
        G5["G5 待执行"]
    end

    subgraph GlobalQ["全局队列 (最多64个)"]
        GQ["Global run queue"]
    end

    subgraph LocalQ["P 本地队列 (最多256个)"]
        PQL["P local runq"]
    end

    subgraph MExec["M 执行 G"]
        ME["执行 G 的代码"]
    end

    subgraph OSys["操作系统"]
        NET["网络 IO / 系统调用"]
        SYS["系统调用 - 阻塞"]
    end

    GQueue --> |入队| GlobalQ
    GlobalQ --> |P 空闲时取| PQL
    PQL --> |分配给 M| ME
    ME --> |执行完 G| ME
    ME --> |新建 G| PQL
    ME --> |阻塞 channel/IO | NET
    ME --> |系统调用| SYS

    SYS --> |P 脱落, 另找 M| PQL
    NET --> |完成阻塞| PQL

    PQL --> |队列太长, 移到全局| GQ
    GQ --> |工作窃取: 从其他 P 偷一半| PQL

    classDef queue fill:#e3f2fd,stroke:#1565c0
    classDef exec fill:#fff3e0,stroke:#e65100
    classDef sys fill:#fce4ec,stroke:#c62828
    class GlobalQ,PQL queue
    class ME exec
    class NET,SYS sys

调度流程:

  1. 新建 G → 先尝试放入 P 的本地队列(runq),满了则放入全局队列
  2. P 取 G → 从本地队列取(优先),本地为空时从全局队列取,再空则"工作窃取"其他 P
  3. M 执行 G → M 拿着 P 分配的 G 执行代码
  4. G 阻塞 → G 进入等待队列,M 继续执行下一个 G,P 寻找新的 M 绑定
  5. G 恢复 → G 被放回到某个 P 的队列中,等待下次被调度

为什么不用 M:N 模型?

早期 Go 讨论过 M:N 模型(N 个 G 映射到 M 个 OS 线程),最终选择了 G:M ≈ 1:1 + P 用户态调度,原因:

对比维度 M:N 模型 Go 的 GMP 模型
系统调用阻塞 M 被阻塞,N 个 G 全部卡住 G 阻塞,M 脱手,P 找新 M
抢占式调度 需要内核协作 用户态即可,P 完全控制
实现复杂度 极高(需要内核干预) 相对简洁
Go 版本 1.1 之前实验过 1.1+ 确定方案

关键点:Go 1.5 引入 GOMAXPROCS = NumCPU,每个 P 绑定一个逻辑 CPU 核心,实现了真正的并行执行。

关键参数

// 获取和设置 GOMAXPROCS
import "runtime"

func main() {
    n := runtime.GOMAXPROCS(0)    // 获取当前值
    runtime.GOMAXPROCS(4)          // 设置为 4
    fmt.Println("M 的最大并行度:", n)
}

// 其他 runtime 参数
fmt.Println("Goroutine 数量:", runtime.NumGoroutine())
fmt.Println("逻辑 CPU 数量:", runtime.NumCPU())

思考:GOMAXPROCS=1 时,Go 还是并行的吗?还是只是并发?

关联笔记