目录
1.为什么引入协程?
-
线程进程模型的弊端
-
为了解决多线程多进程频繁切换,导致的CPU浪费
-
多线程随着同步竞争(锁、竞争资源冲突),导致性能下降
-
占用内存:进程4GB、线程4MB
-
-
协程的优点
-
协程是用户态实现的,不需要经过内核态和用户态之间的切换,更加轻量
-
一个goroutine:几KB
-
灵活调度,切换成本低
-
2.早期的Go调度器
-
全局go协程队列,存放着M个协程g
-
有N个线程去全局go协程队列获取G执行,每次获取都需要加全局锁(锁竞争)
3.GMP模型简介
① M每次都先去获取P ② P再去获取G
一个线程M想执行协程G:M就要先去「空闲P队列」获取P,然后P和M绑定,之后P再依次去「本地协程队列、全局协程队列」获取G,将G交给线程M去执行
-
G:协程
-
M:thread线程(内核线程)
-
有一个M阻塞,会先从空闲M队列获取新的M,若没有,再去创建一个新的M
-
如果有M空闲,那么就会回收or放回空闲M队列
-
-
P:processor处理器(每个P具有自己的协程本地队列,P管理了协程队列中的G)
-
P的本地队列存放等待运行的G
-
优先将新创建的G存放在P的本地队列,本地队列满了,才会放到全局队列
-
-
除了P的本地队列,还有一个全局队列
数量
-
M的数量:GO语言本身限定M的最大量是1w,一般设置为核心数(runtime/debug包中的SetMaxThreads函数来设置)
-
P的数量问题
-
环境变量$GOMAXPROC,一般设置为 = 内核线程数/2
-
在程序中通过runtime.GOMAXPROCS()来设置
-
-
G的数量问题
4.调度器的设计策略
复用线程、利用并行、抢占、全局G队列
4.1. work-stealing机制
概述
- 场景:当本线程⽆可运⾏的G时,尝试「从其他线程绑定的P偷取G」
- 获取的流程:
- 从本地队列获取任务
- 从全局队列获取任务
- 从其它M的本地队列窃取任务
case1:从全局队列中steal协程G
此时,M2内核线程绑定的P,没有协程G了,M1的P也没有协程G,但是全局队列中有空闲的G
M2去全局队列中steal协程G3,存放在自己的P中
case2:从其他P中steal协程G
1. M1和P绑定,G1正在运行,M2线程是空闲的
2. 此时M2想执行,那么将会从M1绑定的P的本地队列中steal协程
4.2. hand-off机制(切换机制)
概述
- 场景:当本线程因为G进⾏系统调⽤阻塞时,线程M释放绑定的P,把P转移给其他空闲的线程执⾏
- 流程:当G阻塞时,与该G绑定的M也会陷入阻塞,在阻塞之前,会先把M绑定的P转移给其他M',然后将CPU切换到M'去执行
1. 假设,此时M1绑定的P队列中正在执行的协程G1,执行了一个阻塞操作,比如read
2. hand-off执行过程
2.1. 首先,创建一个线程or唤醒一个睡眠状态的线程,如M3
2.2. 将M1绑定的P,迁移到M3上
2.3. 将G1与M1进行绑定,此时
① M1阻塞等待read事件的返回
② 内核线程切换到M3,通过P去获取本地队列中的G2,继续执行
这样就完成了hand-off机制
5. Go指令的调度流程
1~2步骤:执行go func(),先创建一个G,优先放入P的本地队列,如果满了,放入全局队列(此时P已经存放到P的本地队列)
3步骤:此时M获取G:优先从M的本地队列P中获取G,如果为空,依次去全局队列、其他M的本地队列P去偷取G。(当获取P成功后,将P与M进行绑定)
4~6步骤:
之后,M1调度协程G,执行G的func()函数(备注,每个G的运行时间不超过10ms,防止其他G被饿死)
此时,G执行,执行分为以下情况
case1:G的执行时间片超时,即执行时间大于10ms,G会重新放到M1绑定的本地队列P中
case2:func函数执行了systemcall\阻塞(如read、write),则会获取新的M(从休眠M空闲队列or创建一个M)
若此时,M1的P队列还有很多G等待执行,因为M在执行G1时调用了systemcall\阻塞操作,所以,M1的P队列将交给新的M接管(hand-off机制)
执行完后的效果:①M1和G1捆绑 ②M3接管了M1的P
之后,与M1绑定的G1,因为处于阻塞状态,所以下一步会解除绑定关系,此时①M1销毁或者存放回休眠队列M中 ②G1放回全局队列中
6. 调度器的生命周期
M0
1. 启动程序后编号为0的主线程
2. 在全局变量runtime.m0中,不需要在heap上分配
3. 负责执行初始化和启动第一个G
4. 执行第一个G之后,M0就和其他的M一样了
G0
1. 每次启动一个M,都会第一个创建的G
2. G0仅用于负责调度G
3. G0不指向任何可执行的函数
4. 每一个M都会有一个自己的G0
5. 在调度或系统调用时就会使用M会切换到G0,来调度
6. M0的G0会放在全局空间
7. 场景分析
7.1.(场景1)G1创建G3
此时,存在M1、M2,每个M绑定了P,P上分别有一个G
此时,G1创建了G3:满足局部性,即G1创建的G3,应该存放在M1和G1所在的P上(如下图所示)
7.2.(场景2)G1执行完毕
当M1绑定的G1执行goexit(),G1执行完毕:M1继续获取G,优先从本地的P获取G
7.3.(场景3-4-5)
场景3:G2开辟过多的G
场景4:G2本地满,再创建G7
1. 将本地队列P拆分成2段
2. 将 前一段和G7 打散,再存放在本地队列中
场景5:G2本地未满,创建G8:直接将G8放到本地队列中
7.4.(场景6)唤醒正在休眠的M
M1与P1绑定,P1获取了G,此时,G2创建了G8
G2创建一个协程G8的时候
1. 首先尝试去休眠线程队列中,唤醒一个休眠的线程
2. 唤醒之后,将M从休眠线程队列中取出来
3. 此时,被唤醒的M2,将尝试与新的P绑定
一旦M2绑定了空闲的P,此时会调用G0
自旋线程:M2的本地队列P2中没有G && M2正在运行G0去寻找G
7.5.(场景7)被唤醒的M2从全局队列中获取批量G
获取G的个数 N = min{ len(GQ)/GOMAXPROCS+1, len(GQ/2) } , GQ:全局队列的总长度
7.6.(场景8)M2从M1中批量偷取G
假设此时全局队列中没有G,M2就需要从其他M的P中获取G(批量个数N=后半段)
7.7.(场景9)自旋线程的最大限制
自旋线程 + 执行线程 <= GOMAXPROCS
此时,假设新创建了M5,因为GOMAXPROCS=4,不能在创建自旋线程了,所以,M5会被放入休眠线程队列1
7.8.(场景10)G发生系统调用/阻塞
1. M2的P2执行G8,此时G8执行了systemcall 阻塞(此时M2绑定了G8)
2. 因为此时M2的P2中存在G9,因为M2已经全权为G8负责了,为了不能阻塞G9的运行,所以P2会重新寻找有没有其他的M能继续为它执行(根据休眠线程队列中是否有空闲线程,分为两种情况)
2.1. 有M
P2将从空闲线程队列中取出M5,将P2挂到M5上(M5和P2组成新的MP)
2.2. 无M:将P放入空闲队列
7.9.(场景11)G发生系统阻塞,再变为非阻塞
M2中的G8,此时变为非阻塞,执行过程见下
1. M2中记录了上一次绑定的P,P是P2,即优先获取原配
2. M2发现P2已经被绑定给了M5,因此,M2是抢不过M5的
3. M2会先尝试从空闲P队列中寻找P
4.空闲P队列没有P,此时M2放弃绑定P,将执行释放逻辑:① M2放到空闲线程队列 ②G8放到全局P队列
8. Golang系统调用与阻塞处理 😄
8.1. 阻塞
8.1.1. Go阻塞的4种场景
- 由于原子、互斥量、通道操作调用导致 Goroutine 阻塞,调度器将把当前阻塞的 Goroutine 切换出去,重新调度 本地P队列 上的其他 Goroutine
- 由于 网络请求、网络IO 操作导致 Goroutine 阻塞。Go 程序提供了网络轮询器(NetPoller)来处理网络请求和 IO 操作的问题,其后台通过 kqueue(MacOS),epoll(Linux)或 iocp 来实现 IO 多路复用。通过 使用 NetPoller 进行网络系统调用,调度器可以防止 Goroutine 在进行这些系统调用时阻塞 M。这可以让 M 执行 P 的 LRQ 中其他的 Goroutines,而不需要创建新的 M。执行网络系统调用不需要额外的 M,网络轮询器使用系统线程,它时刻处理一个有效的事件循环,有助于减少操作系统上的调度负载。用户层眼中看到的 Goroutine 中的“block socket”,实现了 goroutine-per-connection 简单的网络编程模式。实际上是通过 Go runtime 中的 netpoller 通过 Non-block socket + I/O 多路复用机制“模拟”出来的。
- 当调用一些系统方法的时候(如文件 I/O),如果系统方法调用的时候发生阻塞,这种情况下,网络轮询器(NetPoller)无法使用,而进行系统调用的 G1 将阻塞当前 M1。调度器引入 其它M 来服务 M1 的P。
- 如果在 Goroutine 去执行一个 sleep 操作,导致 M 被阻塞了。Go 程序后台有一个监控线程 sysmon,它监控那些长时间运行的 G 任务然后设置可以强占的标识符,别的 Goroutine 就可以抢先进来执行。
8.2. 系统调用与调度机制
8.2.1.异步系统调用
异步系统调用:网络IO
结论:当G1执行异步系统调用时,会发生阻塞,该阻塞动作,①不需要创建新的M,② G会和MP分离(G挂到netpoller),阻塞事件会由NetPoller接管
刚开始,G1在M上运行,此时G1想去执行「网络系统调用」
G1执行「网络系统调用」后,发生阻塞,此时,将G1挂在到NetPoller上&&监听G1网络系统调用的返回,M会从P队列中找到新的协程运行。(注:不需要创建新的M)
当G1的「网络系统调用」返回后,G1会被移回到P队列中
8.2.2.同步系统调用
同步系统调用:读写文件
结论:当G1执行同步系统调用时,G2会发生阻塞,同时会导致与G1绑定的M1也阻塞,之后,MG 会和P分离(P另寻M),当M从系统调用返回时,不会继续执行,而是将G放到run queue
刚开始,G1在M上运行,此时G1想去执行「同步系统调用」,G1会阻塞
同步调用,当G1阻塞后,会导致M1也阻塞,具体的执行动作是:G1和M1绑定在一起&&陷入阻塞,M1绑定的P会转移给新的M
阻塞的系统调用完成后:G1可以移回 LRQ 并再次由P执行。如果这种情况需要再次发生,M1将被放在旁边以备将来使用