Go 原理之 GMP 并发调度模型
一、Go 的协程 goroutine
go 的特性:协程(goroutine),goroutine 是 go 自己实现的、为了解决线程的性能问题,goroutine 协程是用户态的,由 go runtime 创建和销毁,没有内核消耗,线程是内核态的,与操作系统相关,创建和销毁成本较高。
goroutine 提高 cpu 的利用率,解决了高消耗的CPU调度,用户态的轻量级的线程,约4k
这也是 go 为什么性能那么好的原因,而 go 实现 goroutine 协程的原理:GMP 调度模型
二、GMP 调度模型
-
G:
goroutine 协程,go runtime 包自己实现的一种数据结构,存储执行时唯一的内存栈信息
-
P:
processor 处理器,go runtime 包实现的调度器,主要用来并发调度 goroutine 协程的启动、执行、等待、暂停、销毁等生命周期
-
M:
thread 线程,就是我们平时理解的线程,如果你不理解什么是线程,请参考文章 进程线程协程的概念和区别
那么 go 的协程 goroutine 是如何实现以及调度执行的过程是什么样子的呢?
比如我们使用 go 开启一个协程:
go func(){
// 开启协程,处理逻辑
}()
‘go func()‘经历了哪些过程
-
- 通过 ‘go func()’ 创建一个 goroutine(数据结构,有个唯一 gid,以及内存栈信息),这里称为:G
-
- 有两个存储 G 的队列(本地P队列,全局队列),新创建的 G 会优先加入本地 P 队列中,如果满了就会保存在全局队列中
-
- G 最终会通过 P 调度运行在 M 中,MP 是组合(一个M必须持有一个P,M:P=1:1)
-
-
M 会从 P 的队列中弹出一个可执行的 G 来执行,如果没有,则会从全局队列中获取,
全局队列也没有,则会从其他 MP 队列中偷取一个 G执行,从其他 P 偷的方式称为 work stealing
-
-
- 一个 M 调度 G 执行是一个循环过程
-
- 当 M 执行 G 过程中发生 systemCall 阻塞,M 会阻塞,如果当前有一些 G 在执行,runtime 会把这个线程 M 从 P 中摘除(detach),此时 P 会和 M 解绑即 hand off,然后再创建/从休眠队列中取一个 M 来服务这个 P
-
系统调用(如文件IO)阻塞(同步):阻塞MG,M与P解绑
-
网络 IO 调用阻塞(异步):G 移动到NetPoller,M 继续执行 P 中的 G
-
mutex/chan阻塞(异步):G 移动到 chan 的等待队列中,M 继续执行 P 中的 G
-
- 当 M 系统调用结束后进入休眠/销毁状态,这个 G 会尝试获取一个空闲的 P 执行,如果没有,这个 G 会放入全局队列
M 每隔约 10ms 会切换一个 G,被切换的 G 会重新回到本地P队列
如果在 Goroutine 去执行一个 sleep 操作,导致 M 被阻塞了。Go 程序后台有一个监控线程 sysmon,它监控那些长时间运行的 G 任务然后设置可以强占的标识符,别的 Goroutine 就可以抢先进来执行。
三、M0 & G0 的启动
go 启动的时候,默认会启动 M0 线程 和 G0 协程
M0:编号为 0 的主线程
GO:编号为 0 的主协程
四、 协程 goroutine 的调度策略
-
队列轮转:
P 会周期性的将G调度到M中执行,执行一段时间后,保存上下文,将 G 放到队列尾部,然后从队列中再取出一个G进行调度
除此之外,P还会周期性的查看全局队列是否有G等待调度到M中执行
-
系统调度:
当 G0 即将进入系统调用时,M0 将释放 P,进而某个空闲的 M1 获取 P,继续执行 P 队列中剩下的 G。
M1 的来源有可能是 M 的缓存池,也可能是新建的
当 G0 系统调用结束后,如果有空闲的P,则获取一个P,继续执行 G0。如果没有,则将 G0 放入全局队列,等待被其他的 P 调度。然后 M0 将进入缓存池睡眠
-
抢占式调度:
sysmon 监控协程,如果 g 运行时间过长
10 ms
,那会发送信号给到 m,g 会被挂起,m继续执行 p 中的 g,防止其他 g 被饿死
五、协程的生命周期
创建、等待(调用 gopark 进入等待状态)、唤醒执行(调用 goready 唤醒等待的 g 执行)、销毁
五、GMP 的数量
G 的初始化大小是 2-4 k,具体数量由内存决定,
P 的数量由用户设置的 GoMAXPROCS 决定,等于CPU的核心数,但是不论 GoMAXPROCS 设置为多大,P 的储存G的数量最大为 256
M 默认限制 10000
常见问题
1. Golang 为什么要创建 goroutine 协程
轻量:1.大小只有 2-4 k,用户级线程,减少了内核态切换创建的开销
操作系统中虽然已经有了多线程、多进程来解决高并发的问题,但是在当今互联网海量高并发场景下,对性能的要求也越来越苛刻,大量的进程/线程会出现内存占用高、CPU消耗多的问题,很多服务的改造与重构也是为了降本增效。
一个进程可以关联多个线程,线程之间会共享进程的一些资源,比如内存地址空间、打开的文件、进程基础信息等,每个线程也都会有自己的栈以及寄存器信息等,线程相比进程更加轻量,而协程相对线程更加轻量,多个协程会关联到一个线程,协程之间会共享线程的一些信息,每个协程也会有自己的栈空间,所以也会更加轻量级。从进程到线程再到协程,其实是一个不断共享,减少切换成本的过程。
Golang 使用协程主要有以下几个原因:
● 大小
协程大概是2-4k,线程大概是1m
● 创建、切换和销毁
协程是用户态的,由 runtime 创建和销毁,没有内核消耗,线程是内核态的,与操作系统相关,创建和销毁成本较高
2. 什么是 CSP 并发模型?
CSP 并发模型:不要以共享内存的方式来通信,而以通信的方式来共享内存
go 实现 CSP 并发模式是通过: goroutine + chan
3. G 调度执行中断是如何恢复的?
G 是一个数据结构,存储上下文堆栈信息
中断的时候将寄存器里的栈信息,保存到自己的 G 对象(sudog)里面。当再次轮到自己执行时,将自己保存的栈信息复制到寄存器里面,这样就接着上次之后运行了。
4. 当 G 阻塞时,g、m、p 会发生什么
G 的状态会从运行态变为阻塞态,放入 P 等待队列
M 会从该 Goroutine 所在的 P 中分离出来,转而执行其他 Goroutine
P 会从该 Goroutine 所在的 M 中分离出来,将该 Goroutine 放入等待队列中,并从空闲的 M 队列中取出一个 M,将其绑定到该 P 上
5. runtime 是什么?
golang 底层的基础设施:
-
GPM 的创建和调度
-
内存分配
-
GC
-
内置函数如 map,chan,slice,反射的实现等
-
pprof,trace,CGO
-
操作系统以及 CPU 的一些封装
-
….
基本上就是 go 的底层所在了
6. 怎么启动第一个 goroutine?
main 启动函数会默认启动 G0 协程
7. Go 的协程为什么那么好,与线程的区别
● 大小
协程大概是 2k-4k,线程大概是1m
● 创建、切换和销毁
协程是用户态的,由runtime创建和销毁,没有内核消耗,线程是内核态的,与操作系统相关,创建和销毁成本较高
提高cpu的利用率,解决了高消耗的CPU调度,用户态的轻量级的线程,约4k
减少了内核切换成本,操作系统分为用户态和内核态(表示操作系统底层)
8. 线程与协程的区别
一个线程有多个协程,协程是用户态的轻量级的线程,非抢占式的,由用户控制,没有内核切换的开销的开销