跳至内容

GMP 调度模型

Q: GMP 模型概述

GMP 是 Go 运行时调度器的核心模型:

角色说明
G (Goroutine)协程,包含栈、程序计数器、状态等信息
M (Machine)操作系统线程,真正执行代码的载体
P (Processor)逻辑处理器,持有本地 Goroutine 队列
全局队列          本地队列
[G][G][G]  →  P[G][G][G] → M(OS线程)
                P[G][G][G] → M(OS线程)

调度逻辑

  1. M线程要运行任务就会先和P进行关联,从P的本地队列中获取G
  2. 如果队列中没有可用的G,就会尝试从全局队列中放一批到P的本地队列(需要加锁)
  3. 如果全局队列中也没有,就会从其他队列中获取G放到自己的本地队列里面
  4. 拿到可以运行的G之后,就会执行,然后重复这个过程

Q: 为什么引入 P?

核心目的:减少锁竞争,提升调度效率

  1. 去掉P的问题:所有M都需要从全局队列中获取goroutine,这就需要全局锁保护。在高并发场景下,大量M争抢同一把锁会造成严重的锁竞争,CPU大部分时间都浪费在等锁上,调度效率急剧下降

  2. P的优势:P的存在实现了无锁的本地调度。每个P维护独立的本地队列,M绑定P后可以直接从本地队列取G执行,大部分情况下都不需要全局锁。只有本地队列空了才去偷取,这大大减少了锁竞争

工作窃取机制

  • 每个 P 持有本地 G 队列,减少锁竞争
  • 当某个 P 的队列为空时,可以从其他 P 的队列尾部窃取 G
  • P 的数量由 GOMAXPROCS 控制,默认为 CPU 核心数

Q: 能不能去掉P层?

不能,去掉P会带来严重的性能问题:

  1. 锁竞争加剧:去掉P之后,所有M都需要从全局队列中获取goroutine,这就需要全局锁保护。在高并发场景下,大量M争抢同一把锁会造成严重的锁竞争,CPU大部分时间都浪费在等锁上

  2. P实现无锁调度:P的存在实现了无锁的本地调度。每个P维护独立的本地队列,M绑定P后可以直接从本地队列取G执行,大部分情况下都不需要全局锁。只有本地队列空了才去偷取,这大大减少了锁竞争

Q: Goroutine 的调度时机?

  • 函数调用(编译器插入调度检查点)
  • 系统调用(M 与 P 解绑,等待 IO)
  • runtime.Gosched() 主动让出
  • 通道操作阻塞
  • time.Sleep
  • GC STW

Q: 调度阻塞的处理机制?

用户态阻塞

如果G因为channel发生阻塞,这个时候M不会被阻塞:

  • 只需要把当前阻塞的G放到等待队列(比如channel的等待队列)
  • 切换到下一个G进行执行
  • 阻塞的G被唤醒后,会尝试加入唤醒者所在P的runnext队列,如果满了就加入本地队列,最后是全局队列

系统调用阻塞

发生系统调用阻塞时M会进入内核态,所以会阻塞M:

  • 需要把P和M断开,去连接一个新的M(如果没有就新建)
  • 继续执行别的G
  • 系统调用完成后,G会尝试加入到一个活跃P的本地队列,如果没有就会被加入到全局队列中

Q: Go Scheduler(调度器) 的工作原理?

基本概念:Go scheduler是Go的协程调度器,它的主要工作是决定哪个goroutine在哪个线程上运行,以及何时进行上下文切换。scheduler的核心是schedule()函数,它在无限循环中寻找可运行的goroutine。当找到后通过execute()函数切换到goroutine执行,goroutine主动让出或被抢占时再回到调度循环。

调度策略

  • Go 1.14之前:协作式调度,只有函数调用时才会检查preempt标志,如果是true就把自己挂起。缺点是如果G里面没有函数调用,执行大循环时会导致其他G没有机会执行
  • Go 1.14之后:异步抢占机制,检测到运行了10ms以上的G,向运行G的M发送信号,触发专门的gsignal goroutine,在信号处理过程中设置preempt=true,即使G没有函数调用也会被强制挂起

Q: m0 和 g0 是什么?

m0

m0是go启动时创建的主线程,主要用来:

  • 执行Go程序的启动
  • 调度器初始化
  • 内存管理器初始化
  • 垃圾回收器初始化
  • 创建g0然后执行main函数
  • 之后其实就和其他M线程一样了

g0

g0是调度协程,主要负责调度逻辑:

  • 职责:goroutine创建、销毁、调度。比如在g1协程上执行,需要调度时(goroutine阻塞、抢占、系统调度等),M就会切换到g0上执行调度逻辑,选出下一个运行的goroutine,然后运行
  • 为什么需要g0
    1. 调度器执行时会操作全部goroutine,如果和普通goroutine共用可能会相互干扰
    2. 如果在普通goroutine栈上运行,如果调度的下一个goroutine还是自己,可能出现递归现象导致栈溢出
    3. g0提供一个安全的栈来执行调度