GMP 调度器深度学习笔记

一、核心概念(理论构建)

1. GMP 三大核心组件

G (Goroutine)

  • 定义:代表一个 Goroutine,包含了函数指针、栈信息、状态等
  • 重要状态
    • _Gidle: 刚刚分配,还未初始化
    • _Grunnable: 在运行队列中,等待被执行
    • _Grunning: 正在执行,已经和 M 绑定
    • _Gsyscall: 正在执行系统调用
    • _Gwaiting: 被阻塞(等待 channel、锁等)
    • _Gdead: 刚刚退出或正在被初始化

M (Machine)

  • 定义:操作系统线程的抽象,真正执行 Goroutine 的实体
  • 特点
    • M 必须持有 P 才能执行 G
    • 数量可以动态增长(上限默认 10000)
    • 存在特殊的 M0 和系统监控线程 sysmon

P (Processor)

  • 定义:逻辑处理器,代表执行 Go 代码所需的资源
  • 核心功能
    • 维护本地可运行队列 runq(环形队列,最多 256 个 G)
    • 持有 mcache(用于内存分配)
    • GOMAXPROCS 决定 P 的数量(默认等于 CPU 核心数)

2. GMP 模型的演进

less 复制代码
旧模型(Go 1.0): G - M 直接绑定
问题:全局队列竞争激烈,锁开销大

新模型(Go 1.1+): G - P - M 三层结构
优势:
  - P 的本地队列减少锁竞争
  - Work Stealing 提高 CPU 利用率
  - 系统调用时,M 和 P 解绑,P 可以继续被其他 M 使用

3. 调度器的关键机制

调度时机

  1. 主动调度 :Goroutine 调用 runtime.Gosched() 主动让出 CPU
  2. 被动调度:Channel 阻塞、锁等待、网络 IO 等
  3. 抢占式调度
    • 协作式抢占(Go 1.13-):函数调用时检查抢占标记
    • 基于信号的异步抢占(Go 1.14+):sysmon 通过信号强制抢占长时间运行的 G

Work Stealing(工作窃取)

  • 当 P 的本地队列为空时,会尝试从以下位置窃取 G:
    1. 全局队列(需要加锁)
    2. 其他 P 的本地队列(窃取一半)
    3. netpoller(检查是否有就绪的网络 IO)

Hand Off(移交)

  • 当 M 因系统调用阻塞时,会将 P 移交给其他空闲或新建的 M
  • 保证 P 的数量始终不变,最大化 CPU 利用率

二、源码关键位置

核心文件

  • src/runtime/runtime2.go: G、M、P 结构体定义
  • src/runtime/proc.go: 调度器核心逻辑(schedule、findrunnable、execute 等)
  • src/runtime/asm_*.s: 汇编入口(不同架构)
  • src/runtime/mgc.go: GC 相关

关键函数调用链

css 复制代码
程序启动:
rt0_go (汇编) 
  -> schedinit() (初始化调度器)
  -> newproc() (创建 main goroutine)
  -> mstart() (启动 M)
  -> schedule() (进入调度循环)

调度循环:
schedule()
  -> findrunnable() (寻找可执行的 G)
  -> execute() (执行 G)
  -> goexit() (G 执行完毕)
  -> schedule() (循环)

三、核心数据结构字段详解

g 结构体关键字段

go 复制代码
type g struct {
    stack       stack    // 栈内存范围 [stack.lo, stack.hi)
    stackguard0 uintptr  // 栈溢出检测
    m           *m       // 当前绑定的 M
    sched       gobuf    // 调度信息(PC、SP 等寄存器)
    atomicstatus uint32  // 状态(原子操作)
    goid        int64    // goroutine ID
    gopc        uintptr  // 创建此 goroutine 的 PC(用于栈追踪)
    startpc     uintptr  // goroutine 函数的 PC
}

m 结构体关键字段

go 复制代码
type m struct {
    g0       *g        // 用于执行调度代码的特殊 g(栈更大)
    curg     *g        // 当前运行的 G
    p        puintptr  // 当前绑定的 P
    nextp    puintptr  // 唤醒 M 时,即将绑定的 P
    spinning bool      // 是否正在窃取 G
}

p 结构体关键字段

go 复制代码
type p struct {
    status   uint32      // P 的状态
    m        muintptr    // 绑定的 M
    runqhead uint32      // 本地队列头
    runqtail uint32      // 本地队列尾
    runq     [256]guintptr // 本地运行队列(循环队列)
    runnext  guintptr    // 下一个要运行的 G(优先级最高)
    gFree    *g          // 已终止的 G 的缓存列表
}

四、核心函数解析

1. schedule() - 调度循环入口

markdown 复制代码
职责:找到下一个可运行的 G,并执行它
核心逻辑:
  1. 每调度 61 次,从全局队列拿一个 G(防止全局队列饥饿)
  2. 尝试从 P 的本地队列获取 G
  3. 如果没有,调用 findrunnable() 进行全局搜索
  4. 调用 execute() 执行 G

2. findrunnable() - 查找可运行的 G

markdown 复制代码
查找顺序(Work Stealing):
  1. 本地队列 (runq)
  2. 全局队列 (globrunqget)
  3. netpoller (网络轮询器)
  4. 窃取其他 P 的队列 (stealWork -> runqsteal)
  5. 再次检查全局队列和 netpoller
  6. 如果还是没有,park M(休眠)

3. execute() - 执行 G

scss 复制代码
职责:
  1. 将 G 绑定到当前 M
  2. 将 G 的状态改为 _Grunning
  3. 调用 gogo() (汇编) 切换到 G 的栈并执行

4. sysmon() - 系统监控线程

markdown 复制代码
职责:
  1. 检查长时间运行的 G(>10ms),发送抢占信号
  2. 回收长时间阻塞的 P
  3. 触发强制 GC
  4. 网络轮询器的超时检查
  
抢占机制(Go 1.14+):
  - 向目标 M 发送 SIGURG 信号
  - M 收到信号后,在信号处理函数中将当前 G 标记为可抢占
  - 在下一次安全点检查抢占标记,切换到 g0 并调用 schedule()

五、实战要点

GODEBUG=schedtrace 输出解析

运行命令:

bash 复制代码
GODEBUG=schedtrace=1000 go run main.go

输出示例:

ini 复制代码
SCHED 1000ms: gomaxprocs=8 idleprocs=6 threads=12 spinningthreads=0 idlethreads=5 runqueue=0 [0 0 0 0 0 0 0 0]

字段含义:

  • 1000ms: 程序运行时间
  • gomaxprocs=8: P 的数量(GOMAXPROCS)
  • idleprocs=6: 空闲的 P 数量
  • threads=12: 当前 M 的总数
  • spinningthreads=0: 正在窃取任务的 M 数量
  • idlethreads=5: 空闲的 M 数量
  • runqueue=0: 全局队列中的 G 数量
  • [0 0 0 0 0 0 0 0]: 每个 P 的本地队列中的 G 数量

关键观察指标

  1. idleprocs 过高:说明 CPU 利用率不足,Goroutine 不够或有大量阻塞
  2. spinningthreads > 0:有 M 在不停窃取,说明存在负载不均衡
  3. runqueue 持续积累:全局队列有大量待处理的 G,可能有性能瓶颈
  4. 某些 P 的 runqueue 特别大:负载不均,可能某些 Goroutine 创建了过多子任务

六、常见面试题

Q1: 为什么需要 P?直接 G-M 不行吗?

A: P 的存在是为了减少锁竞争。每个 P 有独立的本地队列,避免了所有 M 竞争全局队列的问题。同时 P 可以在 M 阻塞时移交给其他 M,保证并行度。

Q2: Work Stealing 如何避免一直窃取不到任务导致的自旋?

A: findrunnable() 会有多轮尝试,如果多次尝试后仍找不到任务,M 会进入 park 状态(休眠),直到有新任务时被唤醒。

Q3: 什么情况下会创建新的 M?

A:

  1. 现有 M 都在执行且有空闲的 P
  2. M 因系统调用阻塞,需要 Hand Off P 时
  3. CGO 调用时(CGO 调用会阻塞 M)

Q4: Goroutine 的栈是如何增长的?

A: Go 使用分段栈(Segmented Stack,Go 1.2-)或连续栈(Contiguous Stack,Go 1.3+)。栈空间不足时会触发栈拷贝,将旧栈内容拷贝到新的更大的栈空间。


相关推荐
李梨同学丶6 小时前
0201好虫子周刊
后端
思想在飞肢体在追6 小时前
Springboot项目配置Nacos
java·spring boot·后端·nacos
Loo国昌8 小时前
【垂类模型数据工程】第四阶段:高性能 Embedding 实战:从双编码器架构到 InfoNCE 损失函数详解
人工智能·后端·深度学习·自然语言处理·架构·transformer·embedding
ONE_PUNCH_Ge9 小时前
Go 语言泛型
开发语言·后端·golang
良许Linux9 小时前
DSP的选型和应用
后端·stm32·单片机·程序员·嵌入式
不光头强9 小时前
spring boot项目欢迎页设置方式
java·spring boot·后端
怪兽毕设10 小时前
基于SpringBoot的选课调查系统
java·vue.js·spring boot·后端·node.js·选课调查系统
学IT的周星星10 小时前
Spring Boot Web 开发实战:第二天,从零搭个“会卖萌”的小项目
spring boot·后端·tomcat
郑州光合科技余经理10 小时前
可独立部署的Java同城O2O系统架构:技术落地
java·开发语言·前端·后端·小程序·系统架构·uni-app
Remember_99310 小时前
Spring 事务深度解析:实现方式、隔离级别与传播机制全攻略
java·开发语言·数据库·后端·spring·leetcode·oracle