【Go 原理】GMP 设计模型

专有名词解释

内核线程(Kernel-Level Thread ,KLT) :操作系统的主线程,属于物理线程。

轻量级进程(Light Weight Process,LWP):是指我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。

线程模型

了解go中的协程与线程的之间的映射关系?什么是m:n两级线程模型?

M:N 两级线程模型其实是用户态线程(goroutine)和操作系统线程之间的映射关系。

具体理解为,M个goroutine运行在N个操作系统线程之上,内核负责对这N个操作系统线程进行调度,而这N个系统线程又负责对这M个goroutine进行调度和运行。

  • 1:1关系:1个协程绑定1个线程,这种最容易实现,协程的调度都由CPU完成了。缺点:协程的创建、删除和切换的代价都由CPU完成,有点略显昂贵了。

image-20211105164923263

  • N:1 关系:N个协程绑定1个线程,优点就是协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速。但也有很大的缺点,1个进程的所有协程都绑定在1个线程上。缺点:某个程序用不了硬件的多核加速能力。一旦某协程阻塞,造成线程阻塞,本进程的其他协程都无法执行了,根本就没有并发的能力了。

image-20211105164802822

  • M:N关系:M个协程绑定1个线程,是N:1和1:1类型的结合。

image-20211105165046924

协程跟线程是有区别的,线程由CPU调度是抢占式的,协程由用户态调度是协作式的,一个协程让出CPU后,才执行下一个协程。

GM 模型 VS GMP 模型

什么是GMP模型?与GM模型有什么区别?

GM的调度模型:M想要执行、放回G都必须访问全局G队列,并且M有多个,即多线程访问同一资源需要加锁进行保证互斥/同步,所以全局G队列是有互斥锁进行保护的。

image-20211105170122567

GM 调度模型的缺点:

  • 创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争。
  • M转移G会造成延迟和额外的系统负载。比如当G中包含创建新协程的时候,M创建了G’,为了继续执行G,需要把G’交给M’执行,也造成了很差的局部性,因为G’和G是相关的,最好放在M上执行,而不是其他M'。
  • 系统调用(CPU在M之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销。

在Go中,线程是运行goroutine的实体,调度器的功能是把可运行的goroutine分配到工作线程上

Go 线程模型属于M:N模型,主要包含三个概念:内核线程(M)、协程的上下文环境(P)、协程(G)。

image-20211105163514301

  • G (Goroutine)。本质上属于轻量级的线程,是基于协程建立的用户态线程。它拥有自己的栈、指令指针和维护其他调度相关的信息。G分为P的本地队列和全局队列G,存放的是等待运行的G,存的数量有限,本地队列不超过256个。新建G'时,G'优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列。

  • M (Machine),操作系统的主线程(物理线程)。它直接关联一个操作系统内核线程,用于执行 G。线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去。

  • P (Processor),协程的上下文环境。它包含了运行goroutine的资源,如果线程想运行goroutine,必须先获取P,P中还包含了可运行的G队列。P 是处理用户级代码逻辑的处理器,P 里面一般会存当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界),P 会对自己管理的goroutine队列做一些调度。所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个。

主线程是一个物理线程,直接作用在 cpu 上的,是重量级的,非常耗费 cpu 资源。

而协程是从主线程开启的,是轻量级的线程,是逻辑态,对资源消耗相对小。

GMP调度模型 VSGM调度模型的优势:

  • 每个 P 都有自己的本地队列,减少锁竞争。
  • 线程复用:实现hand-off机制,将阻塞的 G 转移给其他空闲的 M 执行,提高资源的利用效率。
  • 线程复用:实现 Work-Stealing 机制,减少空转时间。
  • 总体的设计思路就是将 P 引入runtime,并在 P 上实现可窃取调度。

GMP 模型的限制

关于GMP模型的限制是什么?P和M何时会被创建?

  • G:除内存外无限制,每个 G 创建需要 2-4KB 连续内存块。
  • M:程序启动时,会设置M的最大数量,最多10000个,否则panicsched.maxmcount=10000。一个M阻塞了,会唤醒一个M或者创建一个新的M。
  • P:由程序启动时环境变量$GOMAXPROCS或者是由runtime的方法GOMAXPROCS()决定。这意味着在程序执行的任意时刻都只有$GOMAXPROCSgoroutine在同时运行。

M与P的数量没有绝对关系,一个M阻塞,P就会去创建或者切换另一个M,所以,即使P的默认数量是1,也有可能会创建很多个M出来。

func GOMAXPROCS(n int) int

GOMAXPROCS设置可同时执行的最大CPU数,并返回先前的设置。 若 n < 1,它就不会更改当前设置。本地机器的逻辑CPU数可通过 NumCPU 查询。本函数在调度程序优化后会去掉。

P和M何时会被创建?

  • P何时创建:在确定了P的最大数量n后,运行时系统会根据这个数量创建n个P。
  • M何时创建:没有足够的M来关联P并运行其中的可运行的G。比如所有的M此时都阻塞住了,而P中还有很多就绪任务,就会去寻找空闲的M,而没有空闲的,就会去创建新的M。

调度器的生命周期

谈谈调度器的生命周期?

两个特殊的M0G0:

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

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

image-20211105173000879

结合一段代码来对调度器进行分析:

package main

import "fmt"

func main() {
    fmt.Println("Hello Process")
}
  • runtime创建最初的线程m0goroutine g0,并把两者关联。
  • 调度器初始化:初始化m0、栈、垃圾回收,以及创建和初始化由GOMAXPROCS个P构成的P列表。
  • 示例代码中的main函数是main.mainruntime中也有1个main函数——runtime.main,代码经过编译后,runtime.main会调用main.main,程序启动时会为runtime.main创建goroutine,称它为main goroutine,然后把main goroutine加入到P的本地队列。
  • 启动m0,m0已经绑定了P,会从P的本地队列获取G,获取到main goroutine
  • G拥有栈,M根据G中的栈信息和调度信息设置运行环境。
  • M运行G。
  • G退出,再次回到M获取可运行的G,这样重复下去,直到main.main退出,runtime.main执行DeferPanic处理,或调用runtime.exit退出程序。

调度器的生命周期几乎占满了一个Go程序的一生,runtime.maingoroutine执行之前都是为调度器做准备工作,runtime.maingoroutine运行,才是调度器的真正开始,直到runtime.main结束而结束。

相关推荐

微信扫一扫,分享到朋友圈

【Go 原理】GMP 设计模型
返回顶部

显示

忘记密码?

显示

显示

获取验证码

Close