golang的线程模型——GMP模型
导语
众所周知,go的性能非常高,特别是并发能力,主要得益于goroutine的存在,可以在语言层面上实现编发编程。
goroutine性能高有三个根本原因:
1、goroutine是一种轻量级线程,一般我们说的线程是指系统的内核线程,而goruntine更轻量级,只是对系统线程进行分段使用。
2、goroutine是通过变长栈来实现的,一般的线程其本身的栈内存为固定的2MB,而goroutine初始化时仅2KB,随需自动扩展。
3、goroutine的上下文切换以及调度是由go的GMP中的P进行管理的,而不是线程级别的。
-
操作系统的进程占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、页表、文件句柄等)比较大。
线程(有时也被称做轻量级进程)是CPU调度的最小单位,从属于进程;
多个线程共享所属进程的资源,线程间通信主要通过共享内存,上下文切换很快,资源开销较进程少。
协程:一个线程可以拥有多个协程,协程不是被操作系统内核所管理,而完全是由程序所控制
goroutine协程比线程更轻量级,上下文切换由GMP中的P进行管理,资源消耗更小。
相比较,goroutine在初始化内存和上下文切换,以及协(线)程调度方面完全秒杀线程和进程;
并且一条go语句即可创建一个goroutine,使用十分方便。
// 试想一下:仅初始化内存方面就比线程节约资源1000倍,所以传说的创建十万、百万goroutine并不是夸张的说法。
GMP模型
Goroutine的并发编程模型基于GMP模型,简要解释一下GMP的含义:
G: 是基于协程建立的用户态线程,是Goroutine的缩写。其中包括执行的函数指令及参数;每个goroutine都有自己的栈空间,定时器,初始化的栈空间在2k左右,空间会随着需求增长。
M: 它关联一个os内核线程,用于执行G。是一个系统线程或称为Machine,所有M是有线程栈的。当goroutine调度到线程时,使用该goroutine自己的栈信息而不是M的线程栈。
P: Processor(协程处理器),负责调度goroutine,维护一个本地goroutine队列,M从P上获得goroutine并执行,同时还负责部分内存的管理。P里面一般会存当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界)
// 关于P也就是Processor,网上有说叫调度器的,也有说叫上下文环境的,我个人认为都不准确
// GMP里的P英文就是Processor,中文:处理器的意思,同时他的作用类似于CPU,但它不是真正意义上的处理器,而是**协程处理器**。
// 《Go语言底层原理剖析》一书中对于P的叫法为:**逻辑处理器**,这个叫法也比较能接受。
我们从下往上看每个节点的作用:
CPU : 线程是在CPU中执行。每个CPU一次只能处理一个线程。
M : M从P本地队列中获取G执行,执行后从P获取下一个G, 不断的重复下去。当M因为 G 进行系统调用阻塞时,M释放绑定的 P,把 P以及剩余的G转移给其他空闲的M执行
OS调度器 : 管理M和CPU之间的调度。
P(Processor) : P是M与G之间的协调。所有的P都在程序启动时创建,最多有GOMAXPROCS(可配置)个。P在一般情况下是和M1:1绑定的关系,但是当某个M执行G超时时例外,一个 M 阻塞,P 就会去创建或者切换另一个 M。
goroutine调度器 :管理P和G的调度
P的本地队列 : 存放等待运行的G, 新建G时会优先加入到P的本地队列, 如果队列满了, 则会把本地队列中一半的G移动到全局队列。
全局队列(Global Queue):=> 存放等待运行的G。
-
以上都是针对各个节点的能力进行的简短描述,下面我们详细说一下调度器是如何对G、P和M进行调度的,共两个阶段10个步骤
- 阶段一
步骤1:获取执行终结函数的G,放入本地P的可运行G队列。【终结器是一个其关联的对象被垃圾回收器收集之前会执行的一个终结函数,该函数的执行会有个专门的G负责。 调度器会判断该G已经完成任务之后获取它,把它置为Grunable状态并放入本地P的可运行G队列】
步骤2:从本地P的可运行G队列获取一个G
步骤3:找不到则从调度器的可运行队列获取G【即全局的可执行G队列】
步骤4:找不到则从网络I/O轮询器(netpoller)获取G(非阻塞)【下面有详述:netpoller获取G】
步骤5:找不到则从其他P的可运行G队列获取一半的G【原子操作】
- 如果上述步骤还是无法搜索到可用G,那么搜索进入阶段二:【P找不到G,就要对当前P进行处理】
步骤1:获取本地P中的可执行GC标记任务的G:返回该G。 如果系统是正处在GC标记阶段,且本地P可用于GC标记任务,则把当前P持有的GC标记的G状态改为Grunnable并返回结果【处于Pgcstop状态,等待GC】
步骤2:再次从调度器全局可运行G队列获取G,找不到则解除本地P与当前M关联,并把P放入调度器的空闲P列表【此时这个P已经完成本次使命】
步骤3:遍历全局P列表中的P,检查可运行G队列,如果发现某个P的可运行G队列不为空,则取出一个P,关联到当前M,进入阶段一继续执行【当前M找了一个新的P】
步骤4:全局P列表也没有可运行G队列,则判断是否正处于GC标记节点,以及相关资源是否可用,如果都是true,调度器会从空闲P列表拿出一个P,如果该P持有一个GC标记专用G,就关联P与M,执行阶段二的步骤一【这里是执行不是跳转】
步骤5:继续从I/O轮询器获取G
-
在经历过两个阶段十个步骤后,仍然没有得到可用G,则当前M会被调度器停止,放入调度器的空闲M列表。
关于M和P何时创建
1、当程序开始时或M阻塞时,新的M会初始化并预绑定P,将自己加入全局M空闲列表,真正执行时与P绑定进入running状态,之后就开始执行阶段1的步骤1
2、当程序开始时,P会初始化为gcstop状态,加入全局P空闲列表,真正执行时与M绑定,进入Prunning状态,之后就开始执行阶段1的步骤1
netpoller获取G
netpoller是go的网络轮询器,这里不展开说,只说一下有关G获取:当直接从P本地的G列表,全局G列表获取不到G时,会从netpoller处获取G,此时获取不到G,才会从其他P的本地队列获取一半的G。
当netpoller已被初始化且已有网络IO操作,会从netpoller拿到一个G列表,并把列表表头的G返回,其他的G放入全局G队列。
系统监测任务
Go运行时系统使用sysmon函数实现一个系统的监测任务,它在Go程序生命周期内根据周期时间,周而复始的运行。它主要做如下四件事:
1、在需要时抢夺符合条件的P和G
-
抢夺有两种方式,第一种为从netpoller处获取可执行G,与一整轮调度的从netpoller处获取G方式类似;第二种为抢夺调度器符合条件的P和G。它会遍历全局P列表,假如P的状态为Psyscall,且P的可执行G队列里面存在可执行G(且没有spinning状态的M),就把这个P设置为Pidle状态,等待调度系统将该P与M关联。假如P状态为Prunning,且存在G运行时间过长,则系统监测任务告知调度器,当前G运行时间过长,期望停止该G运行其他G(调度器不一定停止,这里监测任务只负责告知)。
2、在需要时强制GC
-
Go运行时系统在调度器初始化时会开启一个专用强制GC的G,它一般处于暂停状态。一旦GC当前未执行且距离上次执行时间已超过GC最大时间间隔,系统监测任务就会把这个G恢复并放入调度器的可运行G队列。