前言
在 golang 的并发编程中,gmp 是一个重要的概念,它代表了 goroutine、m(线程)和 p(调度器)。 这个强大的三位一体的并发模型使得 golang 在处理并发任务时非常高效和灵活。 通过 gmp 的组合,golang 实现了一种高效的并发模型。 它充分利用了多核处理器的优势,并通过轻量级的 goroutine 实现了高并发的编程模式。 但是gpm到底是怎么工作的呢?今天这篇文章就为您解开gpm的神秘面纱。
调度器由来
单进程系统
早期的计算机都是单进程操作系统,各个进程之间都是顺序执行,也就是进程a执行完了才能执行进程b。
「对于cpu来说,进程和线程是一样的,这里我们就不讨论进程和线程的区别了」
存在的问题
- 单一执行流程,计算机只能一个任务一个任务的处理
- 如果进程a阻塞,会带来很多cpu浪费的时间
多进程/线程操作系统
基于以上的问题,于是就出现了多进程/线程操作系统
- 系统把cpu分成了一段一段的时间片(微妙级别)
- cpu在第一个时间片执行进程A,然后切换到进程B执行,再切换到进程C,一直这样轮询的执行
- 因为cpu被分成的时间片是微妙级别的,所以直观的感觉就是进程A,B,C是在同时执行的
- 多进程/线程操作系统的确解决了阻塞的问题,但是又出现了新的问题
存在的问题
- 因为cpu需要不断地进程A,B,C之间切换,切换肯定避免不了各种复制,计算等消耗,所以在切换过程中浪费掉了很多时间成本,所以「进程/线程越多」,切换「成本就越大」,也就越「浪费」
-
在这种模式下运行CPU在切换动作上浪费的时间成本大概是40%,只有60%的时间是在执行程序
-
进程和线程对内存的占用是比较大的,在32位的操作系统中,进程占用的虚拟内存大概是4GB,现成占用内存大概是4M
协程的诞生
对于一个线程来说其实分为两部分,「用户空间」和「内核空间」。
- 内核空间主要是指操作系统底层,包括进程开辟,分配物理内存资源,磁盘资源等。
-
用户空间主要是编码业务逻辑部分
于是有人想到能不能把线程的内核空间和用户空间分开。并且让他们互相绑定在一起
对于cpu来说,只需要关注内核空间的线程就可以了
当然如果只是这样把用户空间的协程和内核空间的线程一一绑定还是没有解决问题的, 如果开启的比较多,那么对应的线程也会跟着一起增加,cpu频繁切换的问题还是没有解决,于是就引入了「调度器」的概念
引入调度器来在各个协程之间切换,cpu只需要关注内核空间的线程即可,这样「解决了cpu在各个协程之间不断切换的问题」
存在的问题
这样设计虽然解决了cpu频繁切换的问题,但是如果协程A发生了阻塞,肯定会导致协程B无法被执行。 而且如果计算机是多核,那么是无法利用到多核的优势的。显然是不合理的。
对于多核的计算机,在内核空间可以开启多个线程(具体开启几个由计算内核决定,人为无法控制), 所以问题的核心点就转移到了协程调度器上面,不管是什么语言,「协程调度器」做的越好,相对的「cpu利用率」也就越高。
go对协程的处理
内存控制和灵活调度
- 首先golang对协程改名为gorountine,并且把多余的空间都去掉,控制每个协程的内存在几KB大小,所以golang可以开启大量协程
-
golang对协程的调度非常灵活,可以经常在各个协程之间切换
go对早期调度器的处理(GM模型)
golang在早起调度器处理是比较简单的,具体流程如下:
- 首先会有一个全局的go协程队列,并且加锁,防止资源竞争
- M获取锁之后会去尝试执行gorountine,执行完毕再把gorountine重新放回队列中
GM模型存在以下问题
- 创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争
- M转移G会造成延迟和额外的系统负载
- 系统调用(cpu在M之间切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销
- 比如我再一个G中又开辟了一个G1,那么G1和G当然在一个M上执行是比较合适的,因为存在一些共享内存, 但是显然这种调度模式是无法做到的 基于以上问题,golang针对这块做了一些改进,也就是我们今天的主角,GMP模型。
GMP模型
GMP模型主要指的是GMP之间的关系
- G(gorountine协程),基于协程建立的用户态线程
- M(machine thread线程),它直接关联一个os内核线程,用于执行G
- P(processor处理器),P里面一般会存当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界),P会对自己管理的goroutine队列做一些调度
全局队列
存放等待运行的G
P的本地队列
存放等待运行的G
P的本地队列存放的G是有数量限制的,一般是不超过256G
如果创建一个G,是会优先放在p的本地队列中,如果满了则会放到全局队列中去
P列表
在程序启动的过程时创建
最多有GOMAXPROCS个(可配置)
可以通过环境变量$GOMAXPROCS来设置P的个数,也可以在程序中通过runtime.GOMAXPROCS()来设置
M列表
当前操作系统分配到当前go程序的内核线程数,
go语言本身,限制M的最大数量是10000,
可以通过runtime/debug包中的setMaxThreads来设置,
如果有一个M阻塞,则会创建一个新的M,
如果有M空闲,那么会回收或者睡眠
GMP 调度器的设计策略
尽可能复用线程M
避免频繁的线程创建和销毁
work stealing 机制 | 复用机制
M1对应的P上面G1正在执行,G2和G3处于等待中的状态,M2对应的P处于空闲状态。
这种情况下M2对应的P会从M1对应的P的本地队列中把G3偷取过来执行, 提高CPU的利用率,这种机制叫做「work stealing机制」。
获取G的机制: P的本地队列 > 全局队列 > 其他P的本地队列
hand off 机制
如果M1和M2都在正常执行,但是M1对应的G1发生了阻塞,那么势必会影响到G2的执行,那么GMP是如何解决的呢?
golang会新创建一个M3,用来接管之前的P1剩下的G(G2)
M1和G1进行绑定再继续执行,执行完毕之后把M1设置为睡眠状态等待下一次被利用,或者直接销毁
并行利用
并行利用其实比较好理解,其实也就是开启了多少个P,P的个数是有 GOMAXPROCS 来决定的,一般都会设置为 「CPU核数/2」
协作的抢占机制
对于传统的co-routine来说,如果一个C和cpu进行了绑定,那么只有他主动释放,另外一个C才能和cpu进行绑定。 但是在golang中,如果一个G和cpu进行了绑定,那么时间限制最多为10ms,另外一个G就可以直接和cpu绑定。
基于信号的真抢占机制:
协作抢占机制已经解决了绝大部分的饥饿问题,但是还是有部分情况下,Go调度器有无法被抢占的情况, 例如,for 循环或者垃圾回收长时间占用线程,为了解决这些问题, Go1.14 引入了基于信号的抢占式调度机制,能够解决 GC 垃圾回收和栈扫描时存在的问题。
全局队列
全局队列的本质是对work stealing的一种补充
如上图,M2对应的本地队列没有G,会优先从M1的本地队列中偷取
如果M1的本地队列中也没有G,那么就会从全局队列中去偷取G3
因为全局队列涉及到加锁和解锁,所以效率相对要低一些
go的启动周期(M0和G0)
要想了解go的启动周期,首先得了解M0和G0的概念
M0
- 在一个进程中是唯一的
- 启动程序后编号为0的主线程
- 在全局变量 runtime.m0 中,不需要在 heap 上分配
- 负责初始化操作和启动第一个G
- 启动第一个G之后,M0就和其他的M一样了
G0
- 在一个线程中是唯一的
- 每次启动一个M,都会第一个创建的gorountine,就是G0
- G0仅仅用于负责调度其他G,G0不指向任何可执行的函数
- 每个M都会有一个自己的G0
- 在调度或者系统调用的时候,会使用M切换到G0来调度
- M0的G0会放在全局空间
执行流程
package main
import "fmt"
func main() {
fmt.Println("Hello World")
}
在执行到main函数之前,会有一些初始化的操作,比如创建M0,创建G0等等。
当执行main函数的时候,M0已经和其他的M是一样的了,main函数会进入M0对应的p的本地队列中, 然后和M0绑定执行,如果执行超时(10ms),则会重新放到M0对应的本地队列中。一直到执行到exit或者panic为止。
go func(){} 创建过程
创建一个 go func(){}
创建多个 go func(){}
go 的状态
虽然 G 在运行时中定义的状态较多且复杂,但是我们可以将这些不同的状态聚合成三种:等待中、可运行、运行中, 分别由_Gwaiting、_Grunnable、_Grunning 三种状态表示,运行期间大部分情况是在这三种状态来回切换。
参考链接
- https://www.modb.pro/db/631394
- https://blog.csdn.net/fengxiandada/article/details/129461300
- https://zhuanlan.zhihu.com/p/586236582