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概念
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调度模型的介绍
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模型介绍
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。
1.当一个P的本地队列执行完本地的所有G后,并且全局队列为空,就会挑选一个P,从中获取一半的G放到本地队列中。
2.还会每N轮后,从全局队列中获取G。
注意:
挑选其他P偷取G的时候,不是先从P1中拿,再从P2中拿,为了公平性,会随机化的选择P(选择一个小于GOMAXPROCS 并且和它互为质数的步长进行选择)
2.hand off机制(系统调用)
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按照如下规则执行
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 )
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)
goroutine #9 在 chan 被阻塞后恢复。
但是,它必须等待#2、#5和#4之后才能运行。
goroutine #5将阻塞其线程,从而延迟goroutine #9,并使其面临被另一个 P 窃取的风险。
针对如上问题,在go 1.5 的P中,引入了runnext字段,可以优先执行unblock G 。
goroutine #9现在被标记为下一个可运行的。
这种新的优先级排序允许 goroutine 在再次被阻塞之前快速运行。
这一变化对运行中的标准库产生了总体上的积极影响,提高了一些包的性能。
2.5go func()调度流程
从上图我们可以分析出几个结论:
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调度器生命周期
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
P 拥有 G1,M1 获取 P 后开始运行 G1,G1 使用 go func()
创建了 G2,为了局部性 G2 优先加入到 P1 的本地队列。
3.2G1执行完毕
G1 运行完成后 (函数:goexit),M 上运行的 goroutine 切换为 G0,G0 负责调度时协程的切换(函数:schedule)。
从 P 的本地队列取 G2,从 G0 切换到 G2,并开始运行 G2 (函数:execute)。实现了线程 M1 的复用。
3.3G2开辟过多的G
假设每个 P 的本地队列只能存 3 个 G。G2 要创建了 6 个 G,前 3 个 G(G3, G4, G5)已经加入 p1 的本地队列,p1 本地队列满了。
3.4G2本地满了在创建G7
G2 在创建 G7 的时候,发现 P1 的本地队列已满,需要执行负载均衡 (把 P1 中本地队列中前一半的 G,还有新创建 G 转移到全局队列)
(实现中并不一定是新的 G,如果 G 是 G2 之后就执行的,会被保存在本地队列,利用某个老的 G 替换新 G 加入全局队列)
这些 G 被转移到全局队列时,会被打乱顺序。所以 G3,G4,G7 被转移到全局队列。
3.5G2本地未满创建G8
G8 加入到 P1 点本地队列的原因还是因为 P1 此时在与 M1 绑定,而 G2 此时是 M1 在执行。所以 G2 创建的新的 G 会优先放置到自己的 M 绑定的 P 上。
3.6唤醒正在休眠的M
假定 G2 唤醒了 M2,M2 绑定了 P2,并运行 G0,但 P2 本地队列没有 G,M2 此时为自旋线程(没有 G 但为运行状态的线程,不断寻找 G)。
3.7被唤醒的M2从全局队列中获取批量G
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
假设 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自旋线程的最大限制
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发生系统调用-阻塞
假定当前除了 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发生系统调用-非阻塞
M2 和 P2 会解绑,但 M2 会记住 P2,然后 G8 和 M2 进入系统调用状态。
当 G8 和 M2 退出系统调用时,会尝试获取 P2,如果无法获取,则获取空闲的 P,如果依然没有,G8 会被记为可运行状态,并加入到全局队列,M2 因为没有 P 的绑定而变成休眠状态 (长时间休眠等待 GC 回收销毁)。
参考文献
该作者图画的清晰明了,有助于理解,我就直接截图了,感谢作者的知识分享。
https://learnku.com/articles/41728