0
点赞
收藏
分享

微信扫一扫

go GMP

1.goRoutine和线程的区别

1.1内存占用

goroutine:创建一个goroutine的栈内存消耗为2kb,运行过程中,栈空间不足可自动扩容

线程:创建一个线程默认分配1-8M内存,创建后栈空间大小不能改变,某些情况下可能存在栈溢出风险。

1.2创建和销毁

goroutine:是用户态线程,创建和销毁消耗很小。

线程:是内核态的,线程创建和销毁有巨大的消耗。

1.3调度切换

goroutine:goroutine的切换约为200ns

线程:线程切换会消耗1000-1500ns

1.4复杂性

goroutine:创建简单,goroutine间通信使用channel

线程:创建和退出复杂,线程间通讯使用share memory

2.GMP 调度模型

2.1概念

go GMP_系统调用

G

1.使用runtime.g这个结构体,包含了当前goroutine的状态、堆栈、上下文

2.每个goroutine都有自己的栈空间,定时器,初始化的栈空间在2k左右,空间会随着需求增长

M

1.代表工作线程,使用runtime.m结构体。

P

1.p决定了并行任务的数量,通过GOMAXPROCS来设定。在go1.5后,GOMAXPROCS默认设置为CPU核数

2.代表调度器,负责调度goroutine,维护一个本地goroutine队列,M从P上获得goroutine并执行,同时还负责部分内存的管理。

2.2被废弃的GM调度模型的介绍

go GMP_goroutine_02

M想要执行G或者把G放回全局队列(global queue)中,都要通过来操作全局队列。

该模型的问题

1.锁竞争

创建、销毁、调度G都需要每个M获取锁,多个M之间就存在锁竞争

2.goroutine传递问题

M转移G会造成延迟和额外的系统负载。

如:当前G1在M1上运行,G1中的逻辑包含创建新的goroutine G2时,G2需要交给M2运行。

而最好的方式是G2也交给M1运行,因为G2是和G1相关联的。

3.持有较大的内存缓存

每个M是一个线程,默认就需要1-8M的内存空间。

而M只有在运行Go代码的时候才需要使用内存,在goroutine进行系统调用(syscall)的时候并不需要使用内存,然而运行go代码的M和阻塞在syscall的M的比例可能高达1:100,存在内存的浪费。

4.严重的线程阻塞和唤醒

M找不到G,就休眠,当创建了goroutine,就需要重新唤醒M。

所以GMP模型引入了P来应对上面的问题

2.3GMP模型​介绍

go GMP_程序启动_03


1.全局队列(global queue)

存放等待运行的G。

什么时候放入的全局队列呢?

1.创建G时,本地队列中满了(256个了),会把本地队列中半数的G移动到全局队列。

2.阻塞在系统(后面会介绍)调用的G结束后,找不到空闲的P,也会放到全局队列中

什么时候从全局队列中获取呢?

1.获取G时,先从P的本地队列获取,获取不到,从全局队列获取,全局队列没有,最后从其他P的队列获取

2.为了避免全局队列饥饿,P的调度算法会每N轮调度后,就去全局队列中获取一个G。

2.P局部队列

和全局队列类似,存放的也是等待运行的G,存放的数量有限,不能超过256个

新建G时,G优先加入到P的本地队列,如果本地队列满了,则会把本地队列中一半的G移动到全局队列。

3.P列表

所有的P都在程序启动时创建,并保存在数据中,最多GOMAXPROC个

4.M线程

线程时goroutine运行的实体,调度器的功能就是吧可运行的goroutine分配到线程上。

线程要想运行goroutine,就必须要获取P。

先从P的本地队列获取G

如果P的本地队列为空,

会从全局队列中获取G放到P的本地队列

全局队列为空

M会尝试从其他P的本地队列偷一半G放到自己的本地队列中

获取到G后,运行G,G运行完成,M会从P中获取下一个G运行。

5.P和M的数量

P的数量

在程序启动时创建P,P的数量$GOMAXPROC的值。意味着在程序执行过程中,任意时刻只有¥GOMAXPROC个goroutine在同时运行

M的数量

go程序启动时,默认设置的最大线程(M)的数量时10000。

一个M阻塞了,会创建新的M。M和P的数量没有绝对关系,即使P的数量是1,也可能由于系统调用等原因创建出多个M。

M的数量远远大于P的数量。

6.P和M何时创建

P:在程序启动时创建P

M:没有足够的M来关联P,就需要创建M来执行P中的goroutine。

2.4调度器策略

2.4.1复用线程

1.work stealing机制

当 M绑定的P中本地队列没有可运行的G时,首先从全局队列中找G,全局队列中没有,就会从其他P中偷取G。

go GMP_复用_04

1.当一个P的本地队列执行完本地的所有G后,并且全局队列为空,就会挑选一个P,从中获取一半的G放到本地队列中。

2.还会每N轮后,从全局队列中获取G。

注意:

挑选其他P偷取G的时候,不是先从P1中拿,再从P2中拿,为了公平性,会随机化的选择P(选择一个小于GOMAXPROCS 并且和它互为质数的步长进行选择)

2.hand off机制(系统调用)

go GMP_GMP_05

1.G进行系统调用后,M会解绑P,M和G进入阻塞,此时P的状态是syscall,10ms内该P是不能被调度给其他M的。

2.如果10ms内阻塞的M被唤醒了,M会优先获取之前绑定的P,继续处理P上的其他G,这样有利于数据的局部性。

3.sysmon会定时扫描,如果10ms后,M还在执行syscall,会把改P放到空闲队列中,重新调度给需要的M。

syscall结束后,M按照如下规则执行

go GMP_GMP_06

1)10ms内结束,会优先获取刚刚解绑处于syscall的P,恢复执行G

2)10ms以后结束,会从P的idle list中获取空闲的P,恢复执行G。

3)找不到空闲的P,会把G放回global queue全局队列,M放回idle list

注意:当使用了syscall,Go无法限制线程M的数量,写程序的时候要注意,避免线程过多导致程序挂掉。

2.4.2监控进程(sysmon )

go GMP_系统调用_07

1.sysmon也叫监控线程,它无需P也可以运行

2.他是一个死循环,每20微妙~10毫秒循环一次,循环完一次sleep一会,避免空转。

3.sysmon做哪些事情呢?

1)释放闲置超过5分钟的 span 物理内存;

2)如果超过2分钟没有垃圾回收,强制执行;

3)将长时间未处理的 netpoll 添加到全局队列;

4)向长时间运行的 G 任务发出抢占调度,如果一个G运行超过10ms,就会被sysmon放到全局队列中。

5)收回因 syscall -系统调用长时间阻塞的 P,如上介绍。 

2.4.3自旋-Spinning thread

1.自旋类型

线程自旋是相对于线程阻塞而言的,表象就是循环执行一个指定逻辑(目的是不停的寻找G)。

缺点:如果G迟迟不来,CPU会白白浪费在无意义的计算上。

优点:降低了M的上下文切换成本,提高了性能。

自旋主要有两种类型

类型一:M不带P寻找P挂载(一有P释放就结合)

类型二:M带P的寻找G运行(一有runnable的G就执行)

2.确保至少有一个自旋M存在

在新G被创建​、M进入系统调用、M从空闲被激活这三种状态变化前,调度器会确保至少有一个自旋M存在(唤醒或者创建一个M),除非没有空闲的P。

当新G创建:

如果有可用P,就意味着新G可以被立即执行,即便不再同一个P也无妨,所以我们保留一个自旋M(这时应该不存在类型1的自旋只有类型2的自旋)就可以保证新G很快被运行。

当M进入系统调用:

意味着不知道M何时可以醒来,那么M对应的P中剩下的G就要有新的M来执行,所以我们保留一个自旋的M来执行剩下的G(这时应该不存在类型2的自旋只有类型1的自旋)。

当M从空闲变成活跃:

意味着可能一个自旋的M进入工作状态了,这时就要检查并确保还有一个自旋M存在,防止还有G或P空闲。

2.4.4调度亲和性-scheduler affinity(优先调度unblock G)

go GMP_复用_08

goroutine #9 在 chan 被阻塞后恢复。

但是,它必须等待#2、#5和#4之后才能运行。

goroutine #5将阻塞其线程,从而延迟goroutine #9,并使其面临被另一个 P 窃取的风险。

针对如上问题,在go 1.5 的P中,引入了runnext字段,可以优先执行unblock G

goroutine #9现在被标记为下一个可运行的。

这种新的优先级排序允许 goroutine 在再次被阻塞之前快速运行。

这一变化对运行中的标准库产生了总体上的积极影响,提高了一些包的性能。 

go GMP_系统调用_09


2.5go func()调度流程

go GMP_程序启动_10

从上图我们可以分析出几个结论:

1、我们通过 go func () 来创建一个 goroutine;

2、有两个存储 G 的队列,一个是局部调度器 P 的本地队列、一个是全局 G 队列。新创建的 G 会先保存在 P 的本地队列中,如果 P 的本地队列已经满了就会保存在全局的队列中;

3、G 只能运行在 M 中,一个 M 必须持有一个 P,M 与 P 是 1:1 的关系。M 会从 P 的本地队列弹出一个可执行状态的 G 来执行,如果 P 的本地队列为空,就会想其他的 MP 组合偷取一个可执行的 G 来执行;

4、一个 M 调度 G 执行的过程是一个循环机制(spinning自旋);

5、当 M 执行某一个 G 时候如果发生了 syscall 或则其余阻塞操作,M 会阻塞,如果当前有一些 G 在执行,runtime 会把这个线程 M 从 P 中摘除 (detach),然后再创建一个新的操作系统的线程 (如果有空闲的线程可用就复用空闲线程) 来服务于这个 P;

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

2.6 GMP问题总结

单一全局互斥锁(Sched.Lock)和集中状态存储问题的解决    

G 被分成全局队列和 P 的本地队列,全局队列依旧是全局锁,但是使用场景明显很少,P 本地队列使用无锁队列,使用原子操作来面对可能的并发场景

Goroutine 传递问题的解决

 G 创建时就在 P 的本地队列,可以避免在 G 之间传递(窃取除外)

Per-M 持有内存缓存 (M.mcache)   问题的解决 

内存 mcache 只存在 P 结构中,P 最多只有 GOMAXPROCS 个,远小于 M 的个数,所以内存没有过多的消耗。 

严重的线程阻塞/解锁   问题的解决  

通过引入自旋,保证任何时候都有处于等待状态的自旋 M,避免在等待可用的 P 和 G 时频繁的阻塞和唤醒。 

2.7调度器生命周期

go GMP_goroutine_11

2.7.1 M0

M0 是启动程序后的编号为 0 的主线程,这个 M 对应的实例会在全局变量 runtime.m0 中,不需要在 heap 上分配,M0 负责执行初始化操作和启动第一个 G, 在之后 M0 就和其他的 M 一样了。

2.7.2 G0

G0 是每次启动一个 M 都会第一个创建的 goroutine,G0 仅用于负责调度的 G,G0 不指向任何可执行的函数,每个 M 都会有一个自己的 G0。在调度或系统调用时会使用 G0 的栈空间,全局变量的 G0 是 M0 的 G0。

3.Go调度器场景过程解析

3.1G1创建G2

go GMP_系统调用_12

P 拥有 G1,M1 获取 P 后开始运行 G1,G1 使用 ​go func()​ 创建了 G2,为了局部性 G2 优先加入到 P1 的本地队列。

3.2G1执行完毕

go GMP_GMP_13

G1 运行完成后 (函数:goexit),M 上运行的 goroutine 切换为 G0,G0 负责调度时协程的切换(函数:schedule)。

从 P 的本地队列取 G2,从 G0 切换到 G2,并开始运行 G2 (函数:execute)。实现了线程 M1 的复用。

3.3G2开辟过多的G

go GMP_程序启动_14

假设每个 P 的本地队列只能存 3 个 G。G2 要创建了 6 个 G,前 3 个 G(G3, G4, G5)已经加入 p1 的本地队列,p1 本地队列满了。

3.4G2本地满了在创建G7

go GMP_GMP_15

G2 在创建 G7 的时候,发现 P1 的本地队列已满,需要执行负载均衡 (把 P1 中本地队列中前一半的 G,还有新创建 G 转移到全局队列)

(实现中并不一定是新的 G,如果 G 是 G2 之后就执行的,会被保存在本地队列,利用某个老的 G 替换新 G 加入全局队列)

这些 G 被转移到全局队列时,会被打乱顺序。所以 G3,G4,G7 被转移到全局队列。

3.5G2本地未满创建G8

go GMP_系统调用_16

G8 加入到 P1 点本地队列的原因还是因为 P1 此时在与 M1 绑定,而 G2 此时是 M1 在执行。所以 G2 创建的新的 G 会优先放置到自己的 M 绑定的 P 上。

3.6唤醒正在休眠的M

go GMP_系统调用_17

假定 G2 唤醒了 M2,M2 绑定了 P2,并运行 G0,但 P2 本地队列没有 G,M2 此时为自旋线程(没有 G 但为运行状态的线程,不断寻找 G)。

3.7被唤醒的M2从全局队列中获取批量G

go GMP_复用_18

M2 尝试从全局队列 (简称 “GQ”) 取一批 G 放到 P2 的本地队列(函数:findrunnable())。M2 从全局队列取的 G 数量符合下面的公式:

n = min(len(GQ)/GOMAXPROCS + 1, len(GQ/2))

至少从全局队列取 1 个 g,但每次不要从全局队列移动太多的 g 到 p 本地队列,给其他 p 留点。这是从全局队列到 P 本地队列的负载均衡。

假定我们场景中一共有 4 个 P(GOMAXPROCS 设置为 4,那么我们允许最多就能用 4 个 P 来供 M 使用)。所以 M2 只从能从全局队列取 1 个 G(即 G3)移动 P2 本地队列,然后完成从 G0 到 G3 的切换,运行 G3。

3.8M2从M1中获取G

go GMP_系统调用_19

假设 G2 一直在 M1 上运行,经过 2 轮后,M2 已经把 G7、G4 从全局队列获取到了 P2 的本地队列并完成运行,全局队列和 P2 的本地队列都空了,如场景 8 图的左半部分。

全局队列已经没有 G,那 m 就要执行 work stealing (偷取):从其他有 G 的 P 哪里偷取一半 G 过来,放到自己的 P 本地队列。P2 从 P1 的本地队列尾部取一半的 G,本例中一半则只有 1 个 G8,放到 P2 的本地队列并执行。

3.9自旋线程的最大限制

go GMP_程序启动_20

G1 本地队列 G5、G6 已经被其他 M 偷走并运行完成,当前 M1 和 M2 分别在运行 G2 和 G8,M3 和 M4 没有 goroutine 可以运行,M3 和 M4 处于自旋状态,它们不断寻找 goroutine。

为什么要让 m3 和 m4 自旋?

自旋本质是在运行,线程在运行却没有执行 G,就变成了浪费 CPU.

为什么不销毁现场,来节约 CPU 资源?

因为创建和销毁 CPU 也会浪费时间,我们希望当有新 goroutine 创建时,立刻能有 M 运行它,如果销毁再新建就增加了时延,降低了效率。当然也考虑了过多的自旋线程是浪费 CPU,所以系统中最多有 GOMAXPROCS 个自旋的线程 (当前例子中的 GOMAXPROCS=4,所以一共 4 个 P),多余的没事做线程会让他们休眠。

3.10G发生系统调用-阻塞

go GMP_goroutine_21

假定当前除了 M3 和 M4 为自旋线程,还有 M5 和 M6 为空闲的线程 (没有得到 P 的绑定,注意我们这里最多就只能够存在 4 个 P,所以 P 的数量应该永远是 M>=P, 大部分都是 M 在抢占需要运行的 P),G8 创建了 G9,G8 进行了阻塞的系统调用,M2 和 P2 立即解绑,P2 会执行以下判断:如果 P2 本地队列有 G、全局队列有 G 或有空闲的 M,P2 都会立马唤醒 1 个 M 和它绑定,否则 P2 则会加入到空闲 P 列表,等待 M 来获取可用的 p。本场景中,P2 本地队列有 G9,可以和其他空闲的线程 M5 绑定。

3.11G发生系统调用-非阻塞

go GMP_GMP_22

M2 和 P2 会解绑,但 M2 会记住 P2,然后 G8 和 M2 进入系统调用状态。

当 G8 和 M2 退出系统调用时,会尝试获取 P2,如果无法获取,则获取空闲的 P,如果依然没有,G8 会被记为可运行状态,并加入到全局队列,M2 因为没有 P 的绑定而变成休眠状态 (长时间休眠等待 GC 回收销毁)。

参考文献

该作者图画的清晰明了,有助于理解,我就直接截图了,感谢作者的知识分享。

​​https://learnku.com/articles/41728​​


举报

相关推荐

0 条评论