目录

Go 原理之 GMP 并发调度模型

一、Go 的协程 goroutine

go 的特性:协程(goroutine),goroutine 是 go 自己实现的、为了解决线程的性能问题,goroutine 协程是用户态的,由 go runtime 创建和销毁,没有内核消耗,线程是内核态的,与操作系统相关,创建和销毁成本较高。

goroutine 提高 cpu 的利用率,解决了高消耗的CPU调度,用户态的轻量级的线程,约4k

这也是 go 为什么性能那么好的原因,而 go 实现 goroutine 协程的原理:GMP 调度模型

二、GMP 调度模型

/img/go-goroutine-gmp/1.png
GMP调度模型

  • G:

    goroutine 协程,go runtime 包自己实现的一种数据结构,存储执行时唯一的内存栈信息

  • P:

    processor 处理器,go runtime 包实现的调度器,主要用来并发调度 goroutine 协程的启动、执行、等待、暂停、销毁等生命周期

  • M:

    thread 线程,就是我们平时理解的线程,如果你不理解什么是线程,请参考文章 进程线程协程的概念和区别

那么 go 的协程 goroutine 是如何实现以及调度执行的过程是什么样子的呢?

比如我们使用 go 开启一个协程:

go func(){
    // 开启协程,处理逻辑
}()

‘go func()‘经历了哪些过程

    1. 通过 ‘go func()’ 创建一个 goroutine(数据结构,有个唯一 gid,以及内存栈信息),这里称为:G
    1. 有两个存储 G 的队列(本地P队列,全局队列),新创建的 G 会优先加入本地 P 队列中,如果满了就会保存在全局队列中
    1. G 最终会通过 P 调度运行在 M 中,MP 是组合(一个M必须持有一个P,M:P=1:1)
    1. M 会从 P 的队列中弹出一个可执行的 G 来执行,如果没有,则会从全局队列中获取,

      全局队列也没有,则会从其他 MP 队列中偷取一个 G执行,从其他 P 偷的方式称为 work stealing

    1. 一个 M 调度 G 执行是一个循环过程
    1. 当 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

    1. 当 M 系统调用结束后进入休眠/销毁状态,这个 G 会尝试获取一个空闲的 P 执行,如果没有,这个 G 会放入全局队列

/img/go-goroutine-gmp/2.png
GMP调度过程

/img/go-goroutine-gmp/3.png
GMP调度完整流程

M 每隔约 10ms 会切换一个 G,被切换的 G 会重新回到本地P队列

如果在 Goroutine 去执行一个 sleep 操作,导致 M 被阻塞了。Go 程序后台有一个监控线程 sysmon,它监控那些长时间运行的 G 任务然后设置可以强占的标识符,别的 Goroutine 就可以抢先进来执行。

三、M0 & G0 的启动

go 启动的时候,默认会启动 M0 线程 和 G0 协程

M0:编号为 0 的主线程

GO:编号为 0 的主协程

/img/go-goroutine-gmp/4.png
go的启动

四、 协程 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 的底层所在了

/img/go-goroutine-gmp/5.png
goruntime

6. 怎么启动第一个 goroutine?

main 启动函数会默认启动 G0 协程

7. Go 的协程为什么那么好,与线程的区别

● 大小

    协程大概是 2k-4k,线程大概是1m

● 创建、切换和销毁

    协程是用户态的,由runtime创建和销毁,没有内核消耗,线程是内核态的,与操作系统相关,创建和销毁成本较高

    提高cpu的利用率,解决了高消耗的CPU调度,用户态的轻量级的线程,约4k

    减少了内核切换成本,操作系统分为用户态和内核态(表示操作系统底层)

/img/go-goroutine-gmp/6.png
线程和线程的区别

8. 线程与协程的区别

一个线程有多个协程,协程是用户态的轻量级的线程,非抢占式的,由用户控制,没有内核切换的开销的开销