【golang详解】go语言GMP(GPM)原理和调度

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了【golang详解】go语言GMP(GPM)原理和调度相关的知识,希望对你有一定的参考价值。

参考技术A Goroutine调度是一个很复杂的机制,下面尝试用简单的语言描述一下Goroutine调度机制,想要对其有更深入的了解可以去研读一下源码。

首先介绍一下GMP什么意思:

G ----------- goroutine: 即Go协程,每个go关键字都会创建一个协程。

M ---------- thread内核级线程,所有的G都要放在M上才能运行。

P ----------- processor处理器,调度G到M上,其维护了一个队列,存储了所有需要它来调度的G。

Goroutine 调度器P和 OS 调度器是通过 M 结合起来的,每个 M 都代表了 1 个内核线程,OS 调度器负责把内核线程分配到 CPU 的核上执行

模型图:

避免频繁的创建、销毁线程,而是对线程的复用。

1)work stealing机制

  当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。

2)hand off机制

  当本线程M0因为G0进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行。进而某个空闲的M1获取P,继续执行P队列中剩下的G。而M0由于陷入系统调用而进被阻塞,M1接替M0的工作,只要P不空闲,就可以保证充分利用CPU。M1的来源有可能是M的缓存池,也可能是新建的。当G0系统调用结束后,根据M0是否能获取到P,将会将G0做不同的处理:

如果有空闲的P,则获取一个P,继续执行G0。

如果没有空闲的P,则将G0放入全局队列,等待被其他的P调度。然后M0将进入缓存池睡眠。

如下图

GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行

在Go中一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死。

具体可以去看另一篇文章

【Golang详解】go语言调度机制 抢占式调度

当创建一个新的G之后优先加入本地队列,如果本地队列满了,会将本地队列的G移动到全局队列里面,当M执行work stealing从其他P偷不到G时,它可以从全局G队列获取G。

协程经历过程

我们创建一个协程 go func()经历过程如下图:

说明:

这里有两个存储G的队列,一个是局部调度器P的本地队列、一个是全局G队列。新创建的G会先保存在P的本地队列中,如果P的本地队列已经满了就会保存在全局的队列中;处理器本地队列是一个使用数组构成的环形链表,它最多可以存储 256 个待执行任务。

G只能运行在M中,一个M必须持有一个P,M与P是1:1的关系。M会从P的本地队列弹出一个可执行状态的G来执行,如果P的本地队列为空,就会想其他的MP组合偷取一个可执行的G来执行;

一个M调度G执行的过程是一个循环机制;会一直从本地队列或全局队列中获取G

上面说到P的个数默认等于CPU核数,每个M必须持有一个P才可以执行G,一般情况下M的个数会略大于P的个数,这多出来的M将会在G产生系统调用时发挥作用。类似线程池,Go也提供一个M的池子,需要时从池子中获取,用完放回池子,不够用时就再创建一个。

work-stealing调度算法:当M执行完了当前P的本地队列队列里的所有G后,P也不会就这么在那躺尸啥都不干,它会先尝试从全局队列队列寻找G来执行,如果全局队列为空,它会随机挑选另外一个P,从它的队列里中拿走一半的G到自己的队列中执行。

如果一切正常,调度器会以上述的那种方式顺畅地运行,但这个世界没这么美好,总有意外发生,以下分析goroutine在两种例外情况下的行为。

Go runtime会在下面的goroutine被阻塞的情况下运行另外一个goroutine:

用户态阻塞/唤醒

当goroutine因为channel操作或者network I/O而阻塞时(实际上golang已经用netpoller实现了goroutine网络I/O阻塞不会导致M被阻塞,仅阻塞G,这里仅仅是举个栗子),对应的G会被放置到某个wait队列(如channel的waitq),该G的状态由_Gruning变为_Gwaitting,而M会跳过该G尝试获取并执行下一个G,如果此时没有可运行的G供M运行,那么M将解绑P,并进入sleep状态;当阻塞的G被另一端的G2唤醒时(比如channel的可读/写通知),G被标记为,尝试加入G2所在P的runnext(runnext是线程下一个需要执行的 Goroutine。), 然后再是P的本地队列和全局队列。

系统调用阻塞

当M执行某一个G时候如果发生了阻塞操作,M会阻塞,如果当前有一些G在执行,调度器会把这个线程M从P中摘除,然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P。当M系统调用结束时候,这个G会尝试获取一个空闲的P执行,并放入到这个P的本地队列。如果获取不到P,那么这个线程M变成休眠状态, 加入到空闲线程中,然后这个G会被放入全局队列中。

队列轮转

可见每个P维护着一个包含G的队列,不考虑G进入系统调用或IO操作的情况下,P周期性的将G调度到M中执行,执行一小段时间,将上下文保存下来,然后将G放到队列尾部,然后从队列中重新取出一个G进行调度。

除了每个P维护的G队列以外,还有一个全局的队列,每个P会周期性地查看全局队列中是否有G待运行并将其调度到M中执行,全局队列中G的来源,主要有从系统调用中恢复的G。之所以P会周期性地查看全局队列,也是为了防止全局队列中的G被饿死。

除了每个P维护的G队列以外,还有一个全局的队列,每个P会周期性地查看全局队列中是否有G待运行并将其调度到M中执行,全局队列中G的来源,主要有从系统调用中恢复的G。之所以P会周期性地查看全局队列,也是为了防止全局队列中的G被饿死。

M0

M0是启动程序后的编号为0的主线程,这个M对应的实例会在全局变量rutime.m0中,不需要在heap上分配,M0负责执行初始化操作和启动第一个G,在之后M0就和其他的M一样了

G0

G0是每次启动一个M都会第一个创建的goroutine,G0仅用于负责调度G,G0不指向任何可执行的函数,每个M都会有一个自己的G0,在调度或系统调用时会使用G0的栈空间,全局变量的G0是M0的G0

一个G由于调度被中断,此后如何恢复?

中断的时候将寄存器里的栈信息,保存到自己的G对象里面。当再次轮到自己执行时,将自己保存的栈信息复制到寄存器里面,这样就接着上次之后运行了。

我这里只是根据自己的理解进行了简单的介绍,想要详细了解有关GMP的底层原理可以去看Go调度器 G-P-M 模型的设计者的文档或直接看源码

参考: (https://www.cnblogs.com/X-knight/p/11365929.html)

(https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-goroutine/)

理解go中goroutine的GPM

01 

介绍

 

Golang 语言与其他编程语言之间比较,最大的亮点就是 goroutine,使 Golang 语言天生支持并发,可以高效使用 CPU 的多个核心,而并发执行需要一个调度器来协调。

Golang 语言的调度器是基于协程调度模型 GMP,即 goroutine(协程)、processor(处理器)、thread(线程),通过三者的相互协作,实现在用户空间管理和调度并发任务。

 

 

 

其中 thread(M) 是由操作系统分配的执行 Go 程序的线程,一个 thread(M) 和一个 processor(P) 绑定,M 是实际执行体,以调度循环的方式执行 G 并发任务。M 通过修改寄存器,将执行栈指向 G 自带的栈内存,并在此空间内分配堆栈帧,执行任务函数。M 只负责执行,不再持有状态,这时并发任务跨线程调度,实现多路复用的根本所在。每个 processor(P) 维护一个本地 goroutine(G) 队列,此外还有一个全局 goroutine(G) 队列。

其中,M 的数量由操作系统分配,并且如果有 M 阻塞,操作系统会创建新的 M,如果有 M 空闲,操作系统会回收 M 或将空闲 M 睡眠,此外,Golang 语言还可以限定 M 的最大数量为 10000, runtime/debug 包中的 SetMaxThread 函数也可以设置 M 的数量。

Go 程序启动时,会创建 P 列表,P 的数量由环境变量 GOMAXPROCS 的值设置,也可以在 Go 程序中通过调用 runtime 包的 GOMAXPROCS 函数来设置。自 Go1.5 开始,GOMAXPROCS 的默认值为 CPU 的核心数,但是 GOMAXPROCS 的默认值在某些情况下也不是最优值。

P 的作用类似 CPU 的核心,用来控制可同时并发执行的任务数量,每个内核的线程 M 必须绑定到一个 P 上,M 才可以被允许执行任务,否则被操作系统将其睡眠或回收。M 独享所绑定的 P 的资源(对象分配内存、本地任务队列等),可以在无锁状态下执行高效操作。

虽然一个 P 绑定一个 M,但是 P 和 M 的数量并不一致。原因是当 M 因陷入系统调用而长时间阻塞时,P 就会被监控线程抢占,去唤醒睡眠的 M 或新建 M 去执行 P 的本地任务队列,这样 M 的数量就会增长。

每个 P 维护一个本地 G 队列,此外,还有一个全局 G 队列,那么新创建的 G 会放在哪里?新创建的 G 优先放在有空闲空间的 P 中(每个 P 的最大存储数量是 256个),如果所有 P 的存储空间都满了,则存放在全局 G 队列中。

 

02 

调度器的发展历史

 

单进程的操作系统

多进程之间按照先后顺序执行,同一时间只能执行其中一个进程。如果在执行过程中,正在执行的进程如果发生阻塞,CPU 需要等待进程执行完成后,再执行下一个进程,会造成对 CPU 执行时间的浪费。

多线程/多进程的操作系统

CPU 调度器轮询调度多个进程,固定时间内轮询执行其中一个进程,不关心进程在固定时间内是否执行完毕。视觉效果是并发执行,实际上是 CPU 调度器的轮询调度。避免了因为正在执行的进程阻塞,浪费 CPU 的执行时间。

但是带来了另外一些问题,CPU 切换执行多个线程/进程也需要一定成本,并且随着任务的增加,线程/进程的数量也会增加,同时就会增加 CPU 切换成本。即 CPU 使用率一部分被切换成本消耗。另外,多线程/进程之间,还会有数据竞争。

关于内存占用方面,在 32 位操作系统中,进程大概占用 4G,线程大概占用 4M。

因为多线程/进程在 CPU 调度上和内存占用上消耗较大,并且优化难度较大,所以出现了协程,也叫做用户线程。

一个线程分为内核空间和用户空间两个部分,其中内核态负责线程创建和销毁,分配物理内存空间和磁盘空间;用户态负责承担应用开销。

协程(用户线程)

协程,也叫做用户线程,位于用户态。用户线程和内核态的线程之间是绑定关系。用户线程负责应用开销,内核线程负责系统调用的开销。CPU 只需负责系统调用,无需关心应用开销,提升了 CPU 使用率。

为了进一步提升内核态线程的使用率,所以出现了协程调度器,协程调度器与内核态的线程绑定,负责轮询调度多个协程。CPU 无需关心多个协程之间的调度,降低了 CPU 切换成本。通过协程调度器,线程和任务之间的关系由1 比 1,变为 1 比 N,但是仍然存在协程阻塞和无法利用 CPU 的多个核心的问题。

协程调度器又做了改进,由一个内核的线程对一个协程调度器,改为多个内核的线程对一个协程调度器,即线程和任务之间由 1 比 M,变为 N 比 M。这样的好处是可以有效利用多核 CPU,每个 CPU 的核心处理一个内核的线程。

将 CPU 切换消耗成本转换到协程调度器的切换消耗成本,可以通过进一步优化协程调度器,提升操作系统的性能。相比优化操作系统,优化协程调度器更加容易。

Golang 语言的协程调度

关于内存占用方面,创建一个 Golang 的协程(goroutine)初始栈仅有 2K,并且创建操作只在用户空间简单地分配对象,比在内核态分配线程要简单的多,所以可以创建成千上万的 goroutine,并且可以通过协程调度器循环调度 goroutine。

关于协程调度方面,Golang 语言早期是通过多个线程去轮询调度一个全局的 goroutine 队列,存在锁竞争和 CPU 切换线程的成本。

 

03 

Golang 语言的 goroutine 调度器模型 GMP 的设计思想

 

复用线程:

我们已经知道,一个 M 和一个 P 绑定,M 和 P 的关系是 1 比 1,如果 M 阻塞,操作系统会唤醒睡眠的 M,如果没有睡眠的 M,操作系统会创建新的 M,并把阻塞的 M 上的 P 绑定到唤醒的睡眠的 M 或新创建的 M,该操作被称为 hand off 机制。阻塞的 M 达到操作系统最大时间就会被操作系统销毁或将其睡眠,未被执行的 G 会加入到其他 P 的队列中。

如果一个 P 的 G 被 M 处理完,会怎么样的?M 会等待 P 去获取新的 G,P 优先去其他 P 上获取(偷)它们的待处理的 G,该操作被称为 work stealing 机制。

使用 CPU 的多个核心:

我们已经知道,P 的数量由环境变量 GOMAXPROCS 的值设置, Go1.5 之前,GOMAXPROCS 的默认值为 1,自 Go1.5 开始,GOMAXPROCS 的默认值为 CPU 的核心数,可以有效使用 CPU 的多个核心,并行执行。

抢占调度:

co-routine 时代,CPU 轮询执行每个协程,每个协程执行完,主动释放 CPU 资源。

goroutine 时代,CPU 规定每个协程的最大执行时间为 10ms,超过最大执行时间,即便该协程未执行完,其它协程也会抢占 CPU 资源。

全局 G 队列:

如果一个 P 的 G 被 M 处理完,并且其他所有的 P 都没有待处理的 G,那么 P 会去全局 G 队列获取 G,此时会触发锁机制。

也许有的读者会说,那如果全局 G 队列也没有待处理的 G 呢?如果这样,该 M 达到最大空闲时间,就会被操作系统回收或将其睡眠。

 

04 

m0 和 g0

 

什么是 m0?

m0 表示进程启动的第一个线程,也叫主线程。它和其他的 m 没有什么区别,要说区别的话,它是进程启动通过汇编直接复制给 m0 的,m0 是个全局变量,而其他的 m 都是 runtime 内自己创建的。m0 的赋值过程,可以看前面 runtime/asm_amd64.s 的代码。一个 go 进程只有一个 m0。

什么是 g0?

首先要明确的是每个 m 都有一个 g0,因为每个线程有一个系统堆栈,g0 虽然也是 g 的结构,但和普通的 g 还是有差别的,最重要的差别就是栈的差别。g0 上的栈是系统分配的栈,在 linux 上栈大小默认固定 8M,不能扩展,也不能缩小。而普通 g 一开始只有 2K 大小,可扩展。在 g0 上也没有任何任务函数,也没有任何状态,并且它不能被调度程序抢占。因为调度就是在 g0 上跑的。

proc.go 中的全局变量 m0和g0

在 runtime/proc.go 的文件中声明了两个全局变量,m0 表示主线程,这里的 g0 表示和 m0 绑定的 g0,也可以理解为 m0 线程的堆栈,这两个变量的赋值是汇编实现的。

到这里我们应该知道了 g0 和 m0 是什么了?m0 代表主线程、g0 代表了线程的堆栈。调度都是在系统堆栈上跑的,也就是一定要跑在 g0 上,所以 mstart1 函数才检查是不是在 g0 上, 因为接下来就要执行调度程序了。

 

05 

调度器跟踪调试

 

Go 允许跟踪运行时调度器。这是通过 GODEBUG 环境变量完成的:

GODEBUG=scheddetail=1,schedtrace=1000 ./program

输出示例:

SCHED 0ms: gomaxprocs=8 idleprocs=7 threads=2 spinningthreads=0 idlethreads=0 runqueue=0 gcwaiting=0 nmidlelocked=0 stopwait=0 sysmonwait=0
  P0: status=1 schedtick=0 syscalltick=0 m=0 runqsize=0 gfreecnt=0
  P1: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0
  P2: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0
  P3: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0
  P4: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0
  P5: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0
  P6: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0
  P7: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0
  M1: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=1 dying=0 helpgc=0 spinning=false blocked=false lockedg=-1
  M0: p=0 curg=1 mallocing=0 throwing=0 preemptoff= locks=1 dying=0 helpgc=0 spinning=false blocked=false lockedg=1
  G1: status=8() m=0 lockedm=0

请注意,输出结果使用了 G、M 和 P 的概念及其状态,比如 P 的 queue 大小。通常,您不需要那么多细节,因此只需使用:

GODEBUG=schedtrace=1000 ./program

此外,Golang 还有一个高级工具,名为 go tool trace,它具有 UI,允许您浏览程序以及运行时正在做什么。

 

以上是关于【golang详解】go语言GMP(GPM)原理和调度的主要内容,如果未能解决你的问题,请参考以下文章

通俗易懂的Go协程的引入及GMP模型简介

通俗易懂的Go协程的引入及GMP模型简介

GO高阶: 调度器 GMP 原理与调度全分析

go语言调度gmp原理

面试问题总结(一)Golang

go语言之行--golang核武器goroutine调度原理channel详解