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


相关推荐
Victor3563 小时前
https://editor.csdn.net/md/?articleId=139321571&spm=1011.2415.3001.9698
后端
Victor3563 小时前
Hibernate(89)如何在压力测试中使用Hibernate?
后端
灰子学技术4 小时前
go response.Body.close()导致连接异常处理
开发语言·后端·golang
Gogo8165 小时前
BigInt 与 Number 的爱恨情仇,为何大佬都劝你“能用 Number 就别用 BigInt”?
后端
fuquxiaoguang5 小时前
深入浅出:使用MDC构建SpringBoot全链路请求追踪系统
java·spring boot·后端·调用链分析
毕设源码_廖学姐6 小时前
计算机毕业设计springboot招聘系统网站 基于SpringBoot的在线人才对接平台 SpringBoot驱动的智能求职与招聘服务网
spring boot·后端·课程设计
mtngt117 小时前
AI DDD重构实践
go
野犬寒鸦7 小时前
从零起步学习并发编程 || 第六章:ReentrantLock与synchronized 的辨析及运用
java·服务器·数据库·后端·学习·算法
逍遥德8 小时前
如何学编程之01.理论篇.如何通过阅读代码来提高自己的编程能力?
前端·后端·程序人生·重构·软件构建·代码规范
MX_93599 小时前
Spring的bean工厂后处理器和Bean后处理器
java·后端·spring