golang GMP模型

在讲 gmp 模型之前,要先对进程、线程、协程的一些概念要有清晰的认识。若不懂可以先看下我之前文章【Go 并发编程之协程】。

1.gmp模型概念

在这里插入图片描述

基于宏观图,对 gmp 模型中各个名词概念逐一介绍:

1.1 g (Goroutine)
  1. g 是 goroutine 的缩写,是 Go 语言中对协程的抽象。它代表了一个可以被调度和执行的任务
  2. g 只有绑定到 p 上后,才能被调度执行。这意味着 g 需要一个处理器 p 来管理其执行。
1.2 m (Machine)
  1. m 是 machine 的缩写,表示一个底层的操作系统线程,是 Go 语言中对线程的抽象。
  2. m 不能直接运行 g,要运行 g,必须先与 p 绑定。
  3. m 从 p 的本地队列中获取 g,如果 p 的 队列为空,m 会尝试从全局队列中取一批 g 放入 p 的本地队列,或者从其他 p 的本地队列中偷取一半的 g 放入自己 p 的本地队列中。
  4. 正因为 m 无需与 g 直接绑定,也不需要记录 g 的状态,因此 g 才能在不同的 m 上切换运行。
1.3 p (Processor)
  1. p 是 processor 的缩写,是 Go 语言中的调度器。
  2. p 在 m 和 g 之间起到桥梁作用:对于 m 来说,只有绑定了 p 才能运行 g;而对于 g 来说,只有被 p 调度后才能执行。
  3. p 的数量决定了 g 的最大并行度,这个数量由 GOMAXPROCS 参数决定。
1.4 p 的本地队列
  1. p 的本地队列用于存放等待运行的 g,数量有限制,一般不超过 256 个。
  2. 当新建 g 时,会优先加入 p 的本地队列,如果本地队列已满,会将队列中一半的 g 移动到全局队列中。
1.5 全局队列
  1. 全局队列用于存放当本地队列满后等待运行的 g。由于全局队列的访问需要加锁,因此性能相对较低。

2.gmp模型调度策略

gmp 模型的调度策略是 Go 语言中实现高效并发的关键,它确保了大量的 goroutines(g)能够在多个处理器(p)上有效地执行,同时利用底层操作系统线程(m)来最大化 CPU 的使用率。

2.1 调度器的工作机制
  1. 协作式调度: Go 语言使用协作式调度机制,意味着 g 在某些特定点(如系统调用、I/O 操作)会主动让出 CPU,从而使调度器有机会调度其他 g 执行。这种机制减少了不必要的上下文切换,提高了调度效率。
  2. 抢占式调度: 为了防止某个 g 长时间占用 CPU 资源,Go 语言在 1.14 版本中引入了抢占式调度。如果一个 g 占用 CPU 时间过长,调度器会强制中断其执行,并将控制权交还给调度器,确保其他 g 能够得到执行机会。
2.2 gmp模型的工作流程
  1. 新建goroutine: 当新建一个 g 时,调度器会优先将其放入当前 p 的本地队列。如果本地队列已满,会将一半的 g 移动到全局队列中。
  2. 优先本地队列调度: 当一个 m 需要执行 g 时,优先从绑定的 p 的本地队列中获取 g 进行执行。这种方式达到无锁,提高调度效率。
  3. 全局队列调度: 如果 p 的本地队列为空,m 会尝试从全局队列中获取 g。访问全局队列需要加锁,所以相对较慢,但是能保证系统的负载均衡。
  4. 工作窃取(Work Stealing): 如果 m 从 p 的本地队列和全局队列都无法获取到 g,会尝试从其他 p 的本地队列中偷取一部分 g。这种机制能够避免某些 p 长时间空闲而其他 p 过载的情况。
  5. goroutine 的阻塞与唤醒(Hand Off): 当 g 由于 I/O 操作或系统调用而阻塞时,m 会解除与 p 的绑定,并尝试从其他 p 获取 g 继续执行。当阻塞的 g 被唤醒时,调度器会将其重新分配到某个 p 的本地队列中,等待执行。

3. 代码层面深入理解 gmp 调度模型

gmp 数据结构定义为 runtime/runtime2.go 文件中,摘取核心字段进行介绍。

3.1 g

核心数据结构:

type g struct {
    goid      int64  // 协程id   
    stack     stack  // 协程栈   
    m         *m     // 当前协程被哪一个m调度执行
    sched     gobuf  // 协程上下文,存储g的调度相关数据
    _panic    *_panic     // 最内侧的 panic 结构体
    _defer    *_defer     // 最内侧的 defer 延迟函数结构体  
    atomicstatus uint32     // g的状态  
    ...
}
// g 调度的相关数据
type gobuf struct {
 sp   uintptr      // 指向函数调用栈栈顶
 pc   uintptr      // 程序计数器,指向程序下一条执行指令的地址
 g    guintptr     // 持有 runtime.gobuf 的 g
 ret  uintptr      // 系统调用的返回值
 bp   uintptr      // 存储函数栈帧的起始位置
 ......
}

g 生命周期状态:

const(
  _Gidle = itoa // 0 g刚刚被创建,还未初始化
  _Grunnable // 1    在待执行队列中,等待被执行
  _Grunning // 2     正在执行
  _Gsyscall // 3     系统调用
  _Gwaiting // 4     运行时被阻塞
  _Gdead // 6        协程刚初始化完成或者已经被销毁
  _Gcopystack // 8   栈拷贝中,可能处理栈扩容
  _Gpreempted // 9   抢占被阻塞
)

g 的 状态变化流程图:

在这里插入图片描述

3.2 m

核心数据结构:

type m struct {
    g0            *g                // 特殊的调度协程,不用于执行用户函数,负责执行 g 之间的切换调度. 与 m 的关系为 1:1
    curg          *g                // 当前正在调度执行的协程  
    p             puintptr          // 当前绑定的p
    nextp         puintptr         //保存下一次可能要执行的 P,用于调度过程中快速切换。
    spinning      bool        // 是否处于自旋状态
    lockedg       guintptr    // 表示与当前 m 锁定的g
    ...
}

主要状态:

  1. Idle 空闲: 没有任何goroutine,也没有与任何 p 绑定。
  2. Spinning(自旋中): m 处于自旋状态时,它在等待获取一个可用的 p。自旋状态是为了减少 m 的阻塞开销,通过短暂的自旋来等待 p,避免频繁的休眠和唤醒操作。当 m 自旋失败(未能获取到 p),则可能会进入休眠状态。
  3. Running(运行中): 在运行状态下,m 已经获取了一个 p,并且正在执行 p 中的 Goroutine。
  4. Blocked(阻塞): m 进入阻塞状态的原因通常是因为执行系统调用或等待某种资源。
3.3 p

核心数据结构:

type p struct {
    status      uint32     // 状态,如空闲,正在运行(已经被M绑定)等等
    m           muintptr   // 当前绑定的m
    runqhead    uint32  // 队列头部
    runqtail    uint32  // 队列尾部
    runq        [256]guintptr    // 本地可运行协程队列
    runnext     guintptr   // 线程下一个需要执行的g
    ...
}

主要状态:

  1. _Pidle: p 已经初始化,但未与任何 m 关联
  2. _Prunning: p正在与某个m关联并运行g
  3. _Psyscall: 当前运行的g正在执行系统调用
  4. _Pgcstop: 运行时系统需要停止调度
  5. _Phead: p 已经不被使用

在这里插入图片描述

3.4 schedt
type schedt struct {
    lock mutex       // 全局锁
    runq     gQueue  // 全局队列
    runqsize int32   // 全局队列容量
    ...
}

4. 常见调度场景解析

4.1 场景一 优先放入本地队列

p拥有g1,m获取p后开始运行g1,g1使用go func()创建了g2,为了局部性g2优先加入到p的本地队列

在这里插入图片描述

4.2 场景二 本地队列满了移到全局队列

假设每个p的本地队列只能存4个g。g1创建了6个g

在这里插入图片描述

4.3 场景三 尝试从全局队列中取

m 尝试从全局队列取一批g放到P的本地队列,假设本地队列容量是4,gomaxprocs大小为4

在这里插入图片描述

第二步从全局队列取,那为啥是取1个呢?看下具体源码

func globrunqget(pp *p, max int32) *g {
	// 从全局队列中偷取,调用时必须锁住调度器
  assertLockHeld(&sched.lock)

  // 全局队列长度为0 直接返回
	if sched.runqsize == 0 {
		return nil
	}

  // 至少取一个,但是不要取太多,留一点给其他p 达到负载均衡
	n := sched.runqsize/gomaxprocs + 1
	if n > sched.runqsize {
		n = sched.runqsize
	}
  
	if max > 0 && n > max {
		n = max
	}
  
  // 计算本地队列能不能放在
	if n > int32(len(pp.runq))/2 {
		n = int32(len(pp.runq)) / 2
	}

  // 修改本地队列的剩余空间
	sched.runqsize -= n

  // 取出全局队列队头元素
	gp := sched.runq.pop()
	n--
	for ; n > 0; n-- {
		gp1 := sched.runq.pop()
		runqput(pp, gp1, false)
	}
	return gp
}

源码总结公式:
n = m i n ( l e n ( 全局队列 ) / g o m a x p r o c s + 1 , c a p ( 本地队列 ) ) / 2 ) n=min(len(全局队列)/gomaxprocs + 1,cap(本地队列))/2) n=min(len(全局队列)/gomaxprocs+1,cap(本地队列))/2)
上述例子:
n = m i n ( 3 / 4 + 1 , 4 / 2 ) = 1 n=min(3/4+1,4/2)=1 n=min(3/4+1,4/2)=1

4.4 场景四 尝试从其他m绑定的p的本地队列中偷

全局队列为空,m2尝试从m1中偷取一半g放到p2

在这里插入图片描述

4.5 场景五 系统调用

系统调用

在这里插入图片描述

总结

gmp模型能够在高并发场景下高效地管理和执行goroutine,最大化利用系统资源,同时保证系统的公平性和负载均衡。如果您觉得有帮助,请关注我,另公众号【小张的编程世界】,如有任何错误或建议,欢迎指出。感谢您的阅读!

  • 21
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小张的编程旅途

您的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值