Golang调度器的由来
N:1的模型是在用户态有多个routine对应核心态一个线程,这导致它并不适应现在的多核情况,只能在一个CPU中进行调度,可能这个CPU很忙,但其他CPU还是空闲状态,因此出现了M:N的情况。而M:N重点在于我们无法对核心态进行优化,那么只能在语言层面优化,因此调度器的优化就显得尤为重要。
早期的Goroutine调度器比较传统,它只有一个全局的队列,轮询给核心态thread去调用,那假如在这个Goroutine中还有Goroutine那该给哪个thread去调用,给其他的会导致上下文切换消耗,造成额外的系统负载,自己正在运行一个Goroutine无法分配,并且公共的队列会存在thread对队列锁的竞争,以及多个thread处于不同的内核中,频繁的切换,增加阻塞和取消阻塞增加了系统的开销。
GMP模型
G
协程,很好理解,就是个goroutine的,里面除了存放本goroutine信息外 还有与所在P的绑定等信息。P
处理器,管理着一组goroutine队列,P里面会存储当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界),P会对自己管理的goroutine队列做一些调度(比如把占用CPU时间较长的goroutine暂停、运行后续的goroutine等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务。M(machine)
内核线程,是Go运行时(runtime)对操作系统内核线程的虚拟, M与内核线程一般是一一映射的关系, 一个groutine最终是要放到M上执行的;
同一时刻并行的G数量就是P的数量,P会从队列中拿出G交给M执行,执行完一个时间片后再放回队列栈顶
P的本地队列是存放当前P即将要执行的G,且P是有数量限制的,一般不超过256个G
当新开一个G协程时,会优先分配给任意P的本地队列中,如果P队列满了才会尝试把这个G协程加这个本地队列中的前一半G一起放到全局队列中
P列表是在程序启动时创建,最多有GOMAXPROCS配置的个数。M列表则是操作系统分配给当前Go程序的内核线程数。
在同一时刻运行的总Goroutine数量最大等于CPU核心数,这也是为什么GOMAXPROCS默认值就是CPU核心数
调度器的设计策略
- 复用线程:避免频繁的创建、销毁线程,主要是对线程的复用。
-
work stealing 机制:类似于偷取,如有两个P存在,第一个P中有两个G,然而第二个没有G,且全局G队列中没有G,此时1P有G可以运行,而2P是空闲的,为了避免2P的线程被销毁,2P就会尝试从1P中偷取G过来运行,偷取的是1P队列长度的后一半,它会将1P等分取后一半,偷队头万一即将执行呢。要注意的是这里的一半是总长度的后一半,并不是偷取1P中G总数的一半,假如容量为8,有6个G,它等分后偷取的是后两个。
-
hand off 机制: 分离机制,如果一个P上的G长久阻塞,那么Go会唤醒一个新的M,将原本的P和它的队列跟新的M绑定。此时阻塞的G直接和M绑定,M处于睡眠/销毁状态。
- 如果G在执行过程中阻塞,则会根据hand off 机制重新从休眠M队列(程序启动时分配的)获取,如果休眠M队列中没有M了,则会创建一个M,和原本的P进行绑定。然后将阻塞的那个G和原先的M进行绑定等待阻塞结束。
- 当G阻塞结束后,与G绑定的M会优先去寻找原本绑定的P是否和其他M绑定,如已和其他M绑定,则会去空闲P队列中寻找有没有空闲的P,两者任意一个满足则会获取P来执行这个恢复阻塞的G。如果都不满足的话,G会重新加入全局队列,全局队列满则会随机加入任意一个P本地队列,而M则放回休眠M队列,M队列满了则进行销毁。而M队列中的M如果长期没有被唤醒也会被销毁。
-
- 利用并行:通过GOMAXPROCS来限定P的个数,默认等于CPU的核心数,最好只用CPU核心数的一半,剩下的一半给其他程序运行
- 抢占:时间片到期则被其他G抢占运行
- 全局G队列:当P本地队列中没有G时,P会先从全局队列中拿取G,如果全局也没有则会从其他P中偷取。全局G队列是有锁的,拿取需要解锁,速度会慢一点。
在放入时优先放入执行go func函数的那个线程的P的本地队列中,如果是主函数则先放主函数的线程中,如果满了,则会打乱本地队列前一半的G,加这个线程一起放入全局队列中。
main函数也是一个协程,它在M0上,但不是M0的G0,G0是只负责调度的。
M0的G0存放在全局变量中,其他M的G0放在自己的局部变量中。
GMP可视化调试
G的创建时
开辟G时,它会优先放入本地队列,假如本地队列此时已满,则会将本地队列等分切割。将队首的多个G打乱然后加上新增的G一起放入全局队列中。这样的好处是负载均衡,避免一个P满而其他P为空或极少的情况,全局队列的G大概率会被不同的核运行,打乱顺序,能降低不同核同时修改同一缓存行的概率,且本地队列也会空出来一半。防止饥饿。
当每次创建一个G时,它会尝试从休眠线程队列中唤醒一个M,如果有的话,M被唤醒,然后M会寻找有没有尚未绑定M的P(没有和M绑定的P会存在于空闲P队列中),没有就会再次返回休眠,找到P之后两者进行绑定并初始化本地队列,此时称为自旋线程(没有G但为运行状态的线程),它会不断的寻找G,先从全局中找,全局没有则会去其他P队列中偷取。先从全局中取是避免一直偷取其他队列让全局中的G饥饿。虽然自旋也会浪费CPU的资源,但是如果不采用这种策略会导致线程销毁,更浪费资源。
并且自旋线程的数量要满足 自旋线程+执行中线程<=GOMAXPROCS ,因为GOMAXPROCS指的是P的数量,即使有多余的M被G开辟出来,它也没有多余的P可供使用,即使开辟出M也会被放回休眠线程队列。
GQ是全局队列的容量值