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+)。栈空间不足时会触发栈拷贝,将旧栈内容拷贝到新的更大的栈空间。


相关推荐
Coding君2 小时前
每日一Go-20、Go语言实战-利用Gin开发用户注册登录功能
go
J_liaty2 小时前
SpringBoot深度解析i18n国际化:配置文件+数据库动态实现(简/繁/英)
spring boot·后端·i18n
牧小七2 小时前
springboot 配置访问上传图片
java·spring boot·后端
用户26851612107562 小时前
GMP 三大核心结构体字段详解
后端·go
一路向北⁢2 小时前
短信登录安全防护方案(Spring Boot)
spring boot·redis·后端·安全·sms·短信登录
古城小栈2 小时前
Tokio:Rust 异步界的 “霸主”
开发语言·后端·rust
进击的丸子2 小时前
基于虹软Linux Pro SDK的多路RTSP流并发接入、解码与帧级处理实践
java·后端·github
techdashen2 小时前
Go 1.18+ slice 扩容机制详解
开发语言·后端·golang
浙江巨川-吉鹏2 小时前
【城市地表水位连续监测自动化系统】沃思智能
java·后端·struts·城市地表水位连续监测自动化系统·地表水位监测系统