Go调度器设计思想
Go调度器设计思想[GMP调度模型]
线程和协程
线程由CPU调度,是抢占式的。
协程由用户态调度,是协作式的,一个协程让出CPU后,才能执行下一个协程。
CSP
Communicating Sequnetial Process,通讯顺讯进程,是七大并发模型中的一种。
核心观念是将两个并发执行的实体,通过通道channel连接起来,所有的消息都通过channel传输。
Go语言对CSP并发模型的实现-GMP调度模型。
通过通信的方式共享内存。
不通过共享内存进行通信,而是通过通信实现共享内存。
GMP概念[GMP调度模型]
GMP代表了三个角色:Goroutine、Processor、Machine。
- G (Goroutine)
用go关键字创建的执行体,对应一个结构体g,结构体里保存了goroutine的堆栈信息。
- Machine
表示操作系统线程。
- Processor
表示处理器、通过Process建立G、M联系。
G(Goroutine)
Goroutine就是代码中使用go关键字创建的执行单元,也是就是协程。
协程是不为操作系统所知的,由编程语言层面实现,上下文切换。
M(Machine)
goroutine调度器与os调度器通过M结合起来,每个M代表一个内核线程。
os调度器负责把内核线程,分配到CPU的核心上执行。
线程想要运行任务就得获取P。
从P的本地队列获取G,P本地队列为空时,M尝试从全局队列拿一批G放到P的本地队列。或者从其他P的本地队列偷一半,放到自己P的本地队列。
M运行G,G执行之后,M会从P获取下一个G,不断重复下去。
没有足够的M来关联P并运行其中的可运行G,比如所有的M此时都阻塞住了,而P中还有很多就绪的任务,就会寻找空闲的M,没有空闲的,就会去创建新的M。
- 简单调度机制过程:
- M绑定P进入调度循环[M要先获得P才能进度调度循环]
- 从P的队列(本地/全局)获取G
- 切换到G的执行栈执行G函数
- 调用goexit做清理工作
- 回到M
P(Process)
P(Process),调度器,虚拟处理器。
processor,包含了运行goroutine的资源,如果线程想要运行goroutine,必须先获取P,P中还包含了可运行的G队列。
线程是运行goroutine的实体,调度器的功能,是把可运行的goroutine分配到工作线程上。
P的数量决定了系统最大可并行的G的数量,P的数量,受本机CPU核数的影响。默认是CPU的核心数。
M和P的数量没有绝对关联关系,一个M阻塞,P就会去创建或者切换另一个M。
全局队列
存放等待运行的G。
P的本地队列
同全局队列类型,存放的也是等待运行的G。
存放数量有限,不超过256个。
新建G时,G加入到本地P队列,如果队列满了,把本地队列中,一半的G,移动到全局队列。[新的G实在本地队列还是全局队列?]
P列表
所有的P都在程序启动时创建,并且保存在数组中,最多有GOMAXPROCS个,在确定了P的最大数量n后,运行时系统会根据这个数量创建n个P。
调度器设计
早期不支持抢占式调度,这导致一旦某个G中出现死循环的代码逻辑,那么这个G将永久的占用分配给它的P和M,而同一个P中的G将得不到调度,出现饿死的情况。
在Go1.2版本,实现了基于协作的抢占式调度。
在Go1.4版本,实现了基于信号的抢占式调度。
设计思想
- 抢占调度
一个goroutine最多占用CPU10ms,防止其他goroutine被饿死。
- 线程复用
work stealing 机制 和 hand off 机制。
避免频繁的创建、销毁线程。对线程进行复用。
- 利用并行
设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行。
G调度流程
创建G:go func()
保存G:新创建的G会先保存在P的本地队列中,如果本地队列已满,则保存到全局队列中。
唤醒或新建M,绑定P,用于执行G:G只能运行在M中,一个M必须持有一个P,在创建G时,运行的G会尝试唤醒其他空闲的P和M组合去执行。
M获取G:
- 首先从P的本地队列获取G;
- 如果P为空,则从全局队列获取G;
- 如果全局队列也为空,则从另一个本地队列偷取一半数量的G(负载均衡),这种从其他P偷的方式,称为work stealing。
M调度G执行:
- 在执行G的过程发生系统调度阻塞(同步),会阻塞G和M(操作系统限制),此时P会和当前M解绑,并寻找新的M,如果没有空闲的M就会新建一个M,接着继续执行P中其余的G,这种阻塞后释放P的方式称为hand off。
- 系统调度结束后,这个G会尝试获取一个空闲的P执行,优先获取之前绑定的P,并放到这个P的本地队列,如果获取不到P,这个线程M变成休眠状态,加入到空闲线程中,然后这个G会被放入到全局队列中。
- 如果M在执行G的过程中,发生网络IO的操作阻塞(异步),阻塞G,不会阻塞M,M会寻找P中其他可以执行的G继续执行,G会由网络轮训器(network poller)接手,当阻塞的G恢复后,G从network poller被移回到P的LRQ(Local Run Queue,本地运行队列)中,重新进入可执行状态。异步情况下,通过调度,Go Scheduler成功地将I/O任务转变成CPU任务,或者说将内核级别的线程切换转变成了用户级的goroutine切换,提高了效率。
- M执行完G后清理线程,重新进入调度循环(将M上运行的goroutine切换为G0),G0负责调度时协程的切换。
调度器生命周期 G0、M0
M0是启动程序后编号为0的主线程,这个M对应的实例会在全局变量runtime.M0中,不需要再heap上分配,M0负责执行初始化操作和启动第一个G,在这之后M0就和其他的M一样了。
G0是每次启动一个M都会第一个创建的goroutine,G0仅负责调度G,G0不指向任何可执行的函数,每个M都会有一个自己的G0,在调度或系统调用时会使用G0的栈空间。全局变量的G0是M0的G0。G0是用来做调度的,如:从G1切换到G2时,会先切回G0,保存G1的栈等调度信息,然后再切换到G2。
package main
import "fmt"
func main() {
fmt.Println("Hello World")
}
- 以上代码的运行流程:
- runtime创建最初的线程M0和G0,并把两者关联;
- 调度器初始化:初始化M0、栈、垃圾回收,创建和初始化由GOMAXPROCS个P构成的P列表。
- 实例代码中的main函数main.main,程序启动时会为runtime.main创建goroutine,称它为main goroutine,然后main goroutine加入到P的本地队列。
- 启动M0,M0已经绑定了P,会从P的本地队列获取G,获取到main goroutine。
- G拥有栈,M根据G中栈信息和调度信息设置运行环境。
- M运行G;
- G退出,再次回到M获取可运行的G,这样重复下去,直到main.main退出,runtime.main执行defer和panic处理,或调用runtime.exit退出程序。
调度器的生命周期,几乎沾满一个G程序的一生。runtime.main和goroutine执行之前都是为了调度器做准备工作,runtime.main和goroutine运行,才是调度器的真正开始,直到runtime.main结束而结束。
抢占式调度
- 基于协作的抢占式调度
- Go1.2中实现了基于协作的抢占式调度,协作式:是否让出P的决定权在goroutine自身。
- 编译器会在调用函数前插入 runtime.morestack,让运行时有机会在这段代码中检查是否需要执行抢占调度;
- Go语言运行时会在垃圾回收站定程序,系统监控发现goroutine运行超过10ms,那么会在这个协程设置一个抢占标记。
- 当发生函数调用时,可结合编译器插入的runtime.morestack,调用runtime.netstack会检查抢占标记,如果有抢占标记就会触发抢占,让出CPU,切换调度主协程。
- Go1.2中实现了基于协作的抢占式调度,协作式:是否让出P的决定权在goroutine自身。
这种方案只局部解决饿死问题,只在有函数调用的地方,才能插入抢占代码(埋点),对于没有函数调用而是纯算法循环计算的G,Go调度器依然无法抢占。
- 基于信号的抢占式调度
- Go1.4中实现了基于信号的抢占式调度,不管协程有没有意愿主动让出CPU运行权,只要某个协程执行时间过长,就会发送信号,强行夺取CPU的运行权。
- M注册一个SIGURG信号的处理函数:sighandler;
- sysmon启动后,会间隔性的进行监控,最长间隔10ms,最短间隔20us,如果发现某协程独占P超过10ms,会给M发送抢占信号。
- M接收到信号后,内核执行sighandler函数把当前协程的状态从_Grunning正在执行改为_Grunnable可执行,把抢占的写成放到全局队列里,M继续寻找其他goroutine来运行。
- 被抢占的G再次调度过来执行时,会继续原来的执行流。
- Go1.4中实现了基于信号的抢占式调度,不管协程有没有意愿主动让出CPU运行权,只要某个协程执行时间过长,就会发送信号,强行夺取CPU的运行权。
抢占分为_Prunning和_Psyscall:
_Psyscall抢占通常是有阻塞性系统调用的,比如磁盘IO,CGO。
_Prunning抢占通常是有一些类似死循环的计算逻辑引起的。
浙公网安备 33010602011771号