导语
基础概念介绍
进程
通常表示计算机中正在运行的程序实例。
关键点:
- 进程包含代码段、数据段和堆栈
- 多个进程可以同时运行,相互隔离
- 每个进程独占计算机CPU和内存等资源,是获取资源的基本单位
线程
通常表示内核级的线程。计算机中最小可执行单元
关键点:
- 计算机中最小可调度、可执行单元
- 不独立拥有系统资源,共享进程内资源
- 可以充分利用多核进行并发、并行执行
- 创建、调度、销毁都需要CPU参与,实现用户态和内核态切换
协程
通常也被称为用户级线程,只存在于用户空间
关键点:
- 与线程是N:1(多对一)的关系
- 创建、调度、销毁都是在用户空间完成的,无需CPU参与,更轻量
- 都映射到同一个线程,无法实现并行,如果一个协程阻塞,则整组协程全部阻塞
Goroutine
经过Golang优化后的“协程”
关键点:
- 与线程是M:N(多对多)的关系
- 创建、调度、销毁都是在用户空间完成,对内核透明,更轻量
- 映射至多个线程,可实现并行
- 经过调度器(processor)的调度,能实现动态绑定和灵活调度
- 栈空间可动态扩缩容
GM模型
在介绍GMP模型之前,先看看go1.1版本之前采用的调度模型——GM模型
GM = Goroutine + Machine
G指的就是Goroutine协程,M指的就是Machine内核级线程
GM调度模型见下图
从上图中可以看到,每个线程M在获取Goroutine时,都需要从全局队列中获取,这时候就必须要先获取锁,然后才能获取Goroutine,而如果被M获取的Goroutine在运行过程中产生了新的Goroutine,则新的Goroutine也会被放回到全局队列中。通过描述,我们很容易发现GM模型有这么几个缺点:
- 调度:获取和返回Goroutine都需要先获取全局队列的锁,这就会在获取锁这里形成激烈地竞争,严重影响性能
- 资源利用率:当Goroutine1结合M1产生新的Goroutine2时,由于M1要继续运行Goroutine1,那么就需要把Goroutine2交还到全局队列,然后由其他的M获取后运行,而Goroutine1和Goroutine2是有相关性的,上下文都保存在M1中,这样会导致上下文切换或者保存原有状态是产生不必要的系统开销。
- 缓存资源:每个M在获取到Goroutine时,都需要缓存Goroutine的状态、加载上下文,这就要求M必须要配有一定的存储。而这就会产生严重的问题,我们知道M获取的Goroutine如果产生阻塞,M就会被悬挂起来,当M不够用时,系统会产生新的M,而每产生一个新的M就要对应地分配存储资源,这就增加了存储系统的开销,极端一点可能会导致存储资源消耗殆尽(一般系统中M存在的数量上限是10000),这显然是不合理的。
鉴于GM模型的上述几个缺点,在go1.1之后的版本,引入了P(processor)形成GMP模型来解决GM模型的几个缺点问题
GMP = Goroutine + Machine + Processor
解释一下上图的调度过程
- 当我们使用go func()创建一个G时,首先会被加入到一个P的本地队列中,以供P进行调度。
- 当P获取到一个G之后,会从空闲M队列中获取一个M(如果没有则会新建一个M),此时P同时获取到G和M,G只能跟M绑定运行,这样就可以正式运行G任务了。
- 在运行G的过程中,如果产生了新的G,则会将新产生的G放入到P的本地队列中,如果P的本地队列已满,则会放入全局队列中。
- 当一个G运行结束后(未发生系统调用),P会从本地队列中获取下一个G来运行,如此往复,直到P的本地队列为空。此时P首先会查找其他P的本地队列,如果其他P的本地队列中还有尚未执行的G,则会“偷取”一半数量的G,放到自己的本地队列中继续执行;而如果其他P的本地队列中也没有多余的G,则会从全局队列中获取G来执行。
- 而如果跟P绑定的M在运行G的过程中发生了系统调用(如I/O),P会跟绑定的GM解绑,解绑后GM会去处理系统调用,而P会重新获取一个G和一个M运行。当原来的GM处理完成后,会重新获取原来的P,如果原来的P已被占用,则会从空闲P队列中获取一个P,如果还没有,则G会被添加到全局队列中,M会被添加到空闲M队列中。