Home Go Gmp 模型
Post
Cancel

Go Gmp 模型

前言

     线程数过多,意味着操作系统会不断地切换线程,频繁的上下文切换就成了性能瓶颈。
     Golang的调度模型是GMP模型,它提供一种机制,可以在线程中自己实现调度,上下文切换更轻量,从而达到了线程数少,而并发数并不少的效果。而线程中调度的就是Goroutine.

调度的机制用一句话描述就是:runtime准备好G,M,P,然后M绑定P,M从本地或者是全局队列中获取G,然后切换到G的执行栈上执行G上的任务函数,调用goexit做清理工作并回到M,如此反复

接下来我来分模块介绍一下Golang的GMP模型及创建流程

1. Goroutine调度器的基本概念

G(goroutine)

  • 即Go协程,每个go关键字都会创建一个协程,它存储了goroutine的执行stack信息(运行时栈信息)、goroutine状态以及goroutine的任务函数等
  • 在G眼中只有P,P就是运行G的 CPU

M(machine)

  • 工作线程,在Go中称为Machine。
  • M是真正调度系统的执行者,它会优先从关联的 P 的本地队列中直接获取中可运行的G,如果本地队列没有的话, 再到调度器持有的全局队列中领取一些任务或是向其他的MP组合偷一半可以执行的G来执行,M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。

P(processor)

  • processor处理器,它包含了运行 goroutine 的资源,
  • 它用于处理M与G的关系:如果线程想运行 goroutine,必须先获取 P,P 中还包含了可运行的 G 队列
  • P的个数在程序启动时决定,默认等同与CPU的核数,通过 runtime.GOMAXPROCS() 设置P的个数

M必须拥有P才可以执行G中的代码,P含有一个包含多个G的队列,P可以调度G交由M执行

2. GMP 数据结构

g、m、p数据结构均在 runtime/runtime2.go

2.1 G

g 的关键字段

type g struct {
	stack       stack // 当前G的栈范围
	stackguard0 uintptr // 判读当前G是否被抢占

	preempt       bool // 抢占信号
	preemptStop   bool // 抢占时将状态修改成 `_Gpreempted`
	preemptShrink bool // 在同步安全点收缩栈

	_panic       *_panic // 最内侧的 panic 结构体
	_defer       *_defer // 最内侧的延迟函数结构体

	m              *m // 当前G占用的线程
	sched          gobuf // 调度相关数据的存储
	atomicstatus   uint32 // G的状态
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

2.2 M

m的关键字段

  • p最多可以创建10000个线程
  • 最多只有GOMAXPROCS个活跃线程(与核数一致),这样不会频繁地切换线程上下文
type m struct {
	g0   *g         // 调度栈   使用的G
	curg *g         // 当前在M上运行的G

	p      puintptr // 正在运行代码的P
	nextp  puintptr // 暂存的P
	oldp   puintptr // 之前使用的P
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

2.3 P

p的关键字段

type p struct {
	m           muintptr // 调度的M

	runqhead uint32 // G队列头
	runqtail uint32 // G队列尾
	runq     [256]guintptr // G队列
	runnext guintptr // 下一个可运行的G

	status int // 当前P的状态
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

状态取值:

  • _Pidle:运行队列为空,没有需要运行的G
  • _Prunning:M正在执行用户G
  • _Psyscall:M处于系统调用
  • _Pgcstop:M处于GC垃圾回收的stop中
  • _Pdead:P不再被使用

3. M缓冲池

在介绍GMP概念的时候说到:P的个数默认等于CPU核数,每个M必须持有一个P才可以执行G,一般情况下M的个数会略大于P的个数,这多出来的M将会在G产生系统调用时发挥作用。类似线程池,Go也提供一个M的池子,需要时从池子中获取,用完放回池子,不够用时就再创建一个。

4. 调度策略

复用线程:避免频繁的创建、销毁线程,而是对线程的复用。

4.1 work stealing机制

当M没有可运行的 G 时,尝试从其他线程M绑定的 P 偷取一半的G过来,而不是销毁线程。

work stealing机制触发:当前M线程的P本地队列中没有可运行的G时 并且 全局队列G中也没有可运行的G时,则会执行workstealing机制.

即:本地队列 → \rightarrow 全局队列 → \rightarrow 窃取

4.2 hand off 机制

当M阻塞时,M释放绑定的 P(MP分离),把 P 转移给其他空闲的线程执行。

4.3 抢占

在 coroutine 中要等待一个协程主动让出 CPU 才执行下一个协程,在 Go 中,一个 goroutine 执行的时间不能超过 10ms,防止其他 goroutine 被饿死。

4.4 阻塞的两种情况

用户态阻塞/唤醒
例如网络IO、阻塞式channel、sleep等场景(简单来说就是CPU这时候对于这个协程没有事情要做),对于这类阻塞会将G暂时挂起到某一临时等待队列中,待阻塞结束后重新寻找P放入。

系统调用阻塞
M 执行某一个 G 时,如果发生系统调用或则其余阻塞操作,M 会阻塞,如果当前有 G 在执行,runtime 会将这个 MP 进行分离,如果有空闲的M就用或者是从线程池中取,如果没有就创建一个新的M 来服务于这个 P;

当 M 系统调用结束时候,这个 G 会尝试获取一个空闲的 P 执行,并放入到这个 P 的本地队列。如果获取不到 P,那么这个线程 M 变成休眠状态, 加入到空闲线程中,然后这个 G 会被放入全局队列中

4.5 拓展

判定阻塞的原理:

go程序启动时会首先创建一个特殊的内核线程 sysmon,用来监控和管理,其内部是一个循环:

记录所有 P 的 G 任务的计数 schedtick,schedtick会在每执行一个G任务后递增

如果检查到 schedtick 一直没有递增,说明这个 P 一直在执行同一个 G 任务,如果超过10ms,就在这个G任务的栈信息里面加一个 tag 标记

然后这个 G 任务在执行的时候,如果遇到非内联函数调用,就会检查一次这个标记,然后中断自己,把自己加到队列末尾,执行下一个G

如果没有遇到非内联函数 调用的话,那就会一直执行这个G任务,直到它自己结束;如果是个死循环,并且 GOMAXPROCS=1 的话。那么一直只会只有一个 P 与一个 M,且队列中的其他 G 不会被执行!

5. go func()调度流程

下方图片转自Go夜读 go
在这里插入图片描述

  1. 使用go关键子创建一个G,写法:go func(){}
  2. 将G放入P的本地队列(如果当前M绑定的P的本地队列满了,会放在全局队列中)
  3. 唤醒或者新建M来执行任务
  4. 进入调度循环(M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去;)
  5. 尽力获取可执行的G,并执行(如果p本地队列没有可运行的G时,会去全局队列中拿取一半的,如果全局队列中也没有,则会进行执行work stealing机制,会随机的去另一个线程M中的P本地队列偷取一半的G来运行)
  6. 清理现场并重新进入调度循环

6. P和M的个数

6.1 P的数量

P的数量会由启动时环境变量$GOMAXPROCS 或是runtime的方法 GOMAXPROCS()来设定

6.2 M的数量

  • go程序启动时默认的M的最大数量为 10000
  • runtime/debug 中的 SetMaxThreads 函数,设置 M 的最大数量
  • 当某个M阻塞了,会创建新得的M

6.3 P和M什么时候会被创建

  1. P:在确定了P的最大数量为n的时候,运行时系统会根据这个n创建n个P
  2. M:当的M都阻塞了,但是绑定的P中还有很多就绪任务G,这时会去寻找空闲的M或者去线程池中找,且找不到空闲的M的情况下会创建新的M

6.4 问题?

问题来自B战评论

整体的逻辑与单线程调度器没有太多区别,因为我们的程序中可能同时存在多个活跃线程,所以多线程调度器引入了 GOMAXPROCS 变量帮助我们灵活控制程序中的最大处理器数,即活跃线程数。

这个GOMAXPROCS 到底是 P的个数,还是M的个数呢?

答:GOMAXPROCS是限制P的个数,你可以理解成M是线程,P是M需要执行G的时候需要持有的局部资源,只有M持有P的时候才有局部资源可以执行G。注意,也存在M持有G但是不持有P的情况,这时候一般是由于M持有P执行G的时候陷入了长时间的系统调用,被系统监控sysmon发现后将P夺走,将P给另一个M用来继续执行其他G,被夺走P的M此时陷入系统调用,不使用CPU了,也不执行G。因此,总体上可以这么认为,M如果需要访问CPU资源,那么就需要持有P,同时有多少个CPU核心,那么就有多少个P,同时也就有多少个M可以使用CPU。

This post is licensed under CC BY 4.0 by the author.
Contents