GMP模型

一、由来

1、单进程:一个运行完执行下一个

  • 串行
  • 进程阻塞浪费CPU

2、多进程、多线程:一个进程阻塞CPU可以立刻切换到其他进程中进行,保证在运行的进程都可以被分到时间片

  • 频繁创建销毁切换成本大
  • CPU很大程度都被用来进行进程调度了

3、如何提高CPU利用率

  • 高CPU占用:进程虚拟内存占用4GB(32位),线程大约4MB
  • 调度的高消耗:切换成本

内核态线程(线程thread) & 用户态线程(协程co-routine)

4、goroutine

  • 线程是由CPU调度是抢占式的,协程是由用户态调度协作式的,一个协程让出CPU(P)后才执行下一个协程。
  • 在go语言中,协程被称作go-routine。只占几KB,支持伸缩。切换成本低。

二、GMP调度模型

1、概念

Golang的调度模型主要有几个主要的实体:G、M、P、schedt。

  • G:代表一个goroutine实体,它有自己的栈内存,instruction pointer和一些相关信息(比如等待的channel等等),是用于调度器调度的实体。
  • M:代表一个真正的内核OS线程,和POSIX里的thread差不多,属于真正执行指令的人。
  • P:代表M调度的上下文,可以把它看做一个局部的调度器,代表M运行G所需要的资源,调度协程go代码在一个内核线程上跑。P是实现协程与内核线程的N:M映射关系的关键。P的上限是通过系统变量runtime.GOMAXPROCS (numLogicalProcessors)来控制的。golang启动时更新这个值,一般不建议修改这个值。P的数量也代表了golang代码执行的并发度,即有多少goroutine可以并行的运行。默认等于cpu核心数,但可以通过环境变量GOMAXPROC修改,在实际运行时P跟cpu核心并无任何关联。
  • schedt:runtime全局调度时使用的数据结构,这个实体其实只是一个壳,里面主要有M的全局idle队列,P的全局idle队列,一个全局的就绪的G队列以及一个runtime全局调度器级别的锁。当对M或P等做一些非局部调度器的操作时,一般需要先锁住全局调度器。

为了解释清楚这几个实体之间的关系,我们先抽象G、M、P、schedt的关系,主要的workflow如下图所示:

  1. 通过 go func()来创建一个goroutine;
  2. 有两个存储goroutine的队列,一个是局部调度器P的local queue、一个是全局调度器数据模型schedt的global queue。新创建的goroutine会先保存在local queue,如果local queue已经满了就会保存在全局的global queue;
  3. goroutine只能运行在M中,一个M必须持有一个P,M与P是1:1的关系。M会从P的local queue弹出一个Runable状态的goroutine来执行,如果P的local queue为空,就会执行work stealing;
  4. 一个M调度goroutine执行的过程是一个loop;
  5. 当M执行某一个goroutine时候如果发生了syscall或则其余阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P;
  6. 当M系统调用结束时候,这个goroutine会尝试获取一个空闲的P执行,并放入到这个P的local queue。如果获取不到P,那么这个线程M会park它自己(休眠), 加入到空闲线程中,然后这个goroutine会被放入schedt的global queue。

2、GM模型

  

 缺点:

  1. 创建、销毁、调度 G 都需要每个 M 获取锁,这就形成了激烈的锁竞争。
  2. M 转移 G 会造成延迟和额外的系统负载。比如当 G 中包含创建新协程的时候,M 创建了 G’,为了继续执行 G,需要把 G’交给 M’执行,也造成了很差的局部性,因为 G’和 G 是相关的,最好放在 M 上执行,而不是其他 M’。

3、GMP模型

  1. 全局队列(Global Queue):存放等待运行的 G。
  2. P 的本地队列:同全局队列类似,存放的也是等待运行的 G,存的数量有限,不超过 256 个。新建 G’时,G’优先加入到 P 的本地队列,如果队列满了,则会把本地队列中一半的 G 移动到全局队列。
  3. P 列表:所有的 P 都在程序启动时创建,并保存在数组中,最多有 GOMAXPROCS(可配置) 个。
  4. M:线程想运行任务就得获取 P,从 P 的本地队列获取 G,P 队列为空时,M 也会尝试从全局队列拿一批 G 放到 P 的本地队列,或从其他 P 的本地队列偷一半放到自己 P 的本地队列。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。

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

G的状态

  • 空闲中(_Gidle): 表示G刚刚新建, 仍未初始化
  • 待运行(_Grunnable): 表示G在运行队列中, 等待M取出并运行
  • 运行中(_Grunning): 表示M正在运行这个G, 这时候M会拥有一个P
  • 系统调用中(_Gsyscall): 表示M正在运行这个G发起的系统调用, 这时候M并不拥有P
  • 等待中(_Gwaiting): 表示G在等待某些条件完成, 这时候G不在运行也不在运行队列中(可能在channel的等待队列中)
  • 已中止(_Gdead): 表示G未被使用, 可能已执行完毕(并在freelist中等待下次复用)
  • 栈复制中(_Gcopystack): 表示G正在获取一个新的栈空间并把原来的内容复制过去(用于防止GC扫描)

M的状态

M并没有像G和P一样的状态标记, 但可以认为一个M有以下的状态:

  • 自旋中(spinning): M正在从运行队列获取G, 这时候M会拥有一个P
  • 执行go代码中: M正在执行go代码, 这时候M会拥有一个P
  • 执行原生代码中: M正在执行原生代码或者阻塞的syscall, 这时M并不拥有P
  • 休眠中: M发现无待运行的G时会进入休眠, 并添加到空闲M链表中, 这时M并不拥有P

自旋中(spinning)这个状态非常重要, 是否需要唤醒或者创建新的M取决于当前自旋中的M的数量

P的状态

  • 空闲中(_Pidle): 当M发现无待运行的G时会进入休眠, 这时M拥有的P会变为空闲并加到空闲P链表中
  • 运行中(_Prunning): 当M拥有了一个P后, 这个P的状态就会变为运行中, M运行G会使用这个P中的资源
  • 系统调用中(_Psyscall): 当go调用原生代码, 原生代码又反过来调用go代码时, 使用的P会变为此状态
  • GC停止中(_Pgcstop): 当gc停止了整个世界(STW)时, P会变为此状态
  • 已中止(_Pdead): 当P的数量在运行时改变, 且数量减少时多余的P会变为此状态

空闲M链表(重点)

当M发现无待运行的G时会进入休眠, 并添加到空闲M链表中, 空闲M链表保存在全局变量sched.
进入休眠的M会等待一个信号量(m.park), 唤醒休眠的M会使用这个信号量.

go需要保证有足够的M可以运行G, 是通过这样的机制实现的:

  • 入队待运行的G后, 如果当前无自旋的M但是有空闲的P, 就唤醒或者新建一个M
  • 当M离开自旋状态并准备运行出队的G时, 如果当前无自旋的M但是有空闲的P, 就唤醒或者新建一个M
  • 当M离开自旋状态并准备休眠时, 会在离开自旋状态后再次检查所有运行队列, 如果有待运行的G则重新进入自旋状态

因为"入队待运行的G"和"M离开自旋状态"会同时进行, go会使用这样的检查顺序:

  • 入队待运行的G => 内存屏障 => 检查当前自旋的M数量 => 唤醒或者新建一个M
  • 减少当前自旋的M数量 => 内存屏障 => 检查所有运行队列是否有待运行的G => 休眠

这样可以保证不会出现待运行的G入队了, 也有空闲的资源P, 但无M去执行的情况

协程是如何实现的(重点) 

        它和线程的原理是一样的,当a线程切换到b线程的时候,需要将a线程的相关执行进度压入,然后将b线程的执行进度出機,进入b线程的执行序列。协程只不过是在应用层实现这一点。

        但是,协程并不是由操作系统调度的,而且应用程序也没有能力和权限执行cpu调度。怎么解决这个问题?

        答案是,协程是基于线程的。内部实现上,维护了一组数据结构和n个线程,真正的执行还是线程,协程执行的代码被扔进一个待执行队列中,由这n个线程从队列中拉出来执行。这就解決了协程的执行问題。

        那么协程是怎么切换的呢?

        答案是:galang对各种io函数进行了封装,这些封装的函数提供给应用程序使用,而其内部调用了操作系统的异步0函数,当这些异步函数返回busy或 bloking时, galang利用这个时机将现有的执行序列压钱,让线程去拉另外一个协程的代码来执行,基本原理就是这样,利用并封装了操作系统的异步函数。包括 linux的 epoll、 select和 windows的iocp、 event等

4、有关 P 和 M 的个数问题

1)P 的数量:

  • 由启动时环境变量 $GOMAXPROCS 或者是由 runtime 的方法 GOMAXPROCS() 决定。这意味着在程序执行的任意时刻都只有 $GOMAXPROCS 个 goroutine 在同时运行。

2)M 的数量:

  • go 语言本身的限制:go 程序启动时,会设置 M 的最大数量,默认 10000. 但是内核很难支持这么多的线程数,所以这个限制可以忽略。
  • runtime/debug 中的 SetMaxThreads 函数,设置 M 的最大数量
  • 一个 M 阻塞了,会创建新的 M。

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

5、P 和 M 何时会被创建

1)P 何时创建:在确定了 P 的最大数量 n 后,运行时系统会根据这个数量创建 n 个 P。

2)M 何时创建:没有足够的 M 来关联 P 并运行其中的可运行的 G。比如所有的 M 此时都阻塞住了,而 P 中还有很多就绪任务,就会去寻找空闲的 M,而没有空闲的,就会去创建新的 M。

6、GMP调度策略

Golang设计调度的理念,应该是类似并发的设计理念,GM模型类比锁的竞争,GPM模型类比channel的通道,希望共享通信,而非竞争!

三、Hello world

1、一个简单的Go程序如何启动执行?

什么是runtime----任何一种高级语言都提供给用户写程序的形式。调用用户写的main函数就是这种语言的runtime。

2、runtime 为啥有机会调用用户写的 main 函数呢?

因为高级语言编译器在把用户写的程序翻译成可执行文件的过程中,把 runtime 代码塞进了可执行文件。

Go 的 runtime 也需要初始化全局变量,还需要调用每个 module 里定义的 init 函数,还需要初始化 GC,以及初始化 Go scheduler,启动一个 goroutine,并且让这个 goroutine 执行用户定义的 main 函数 —— 是为 Go runtime 的初始化。

简单来说,用户启动 Go 程序;操作系统执行 runtime 里的入口函数;runtime 执行初始化过程,最后调用用户写的 main 函数 。

3、举个栗子

经典的hello world:

package main

import (

    "fmt"

)

func main() {

    go hello()

}

func hello() {

    fmt.Println("hello world")

}

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

1)osinit

         在 Linux 系统上,这个函数唯 一做的事就是初始化 ncpu 变量,这个变量存储了当前系统的 CPU 的数量。这是通过一个系统调用来实现的。   

2)M0

  • 负责执行初始化和启动第一个G(编号为0的主线程,进程中唯一)
  • 在全局变量runtime.m0中,不需要在head上分布

3)G0

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

4)schedinit(调度器初始化)

    在这个函数内做了许多预操作,他会获取运行参数,环境变量,全局变量初始化。

    Go语言内幕(6):启动和内存分配初始化 - Go语言中文网 - Golang中文社区

    Go的隐秘世界:Goroutine调度机制概览 - 知乎

5)mstart(开启调度循环)

  • 所有工作线程的入口
  • 执行调度循环--schedule()

6)协程创建 newproc → newproc1

 newproc()--切换到g0栈去调用newproc1函数

// Create a new g running fn with siz bytes of arguments.
// Put it on the queue of g's waiting to run.
// The compiler turns a go statement into a call to this.
// Cannot split the stack because it assumes that the arguments
// are available sequentially after &fn; they would not be
// copied if a stack split occurred.
//go:nosplit 不支持栈增长
func newproc(siz int32, fn *funcval) {	//size:传递给协程入口函数的参数占多少字节;fn:协程入口函数对应funcval指针
   argp := add(unsafe.Pointer(&fn), sys.PtrSize)	//argp:用来定位到协程入口函数的参数
   gp := getg()	//gp:当前协程的指针
   pc := getcallerpc()	//pc:调用newproc函数时,由call指令入栈的那个返回地址
   systemstack(func() {
      newproc1(fn, argp, siz, gp, pc)
   })
}

newproc1() --创建一个协程

// Create a new g running fn with narg bytes of arguments starting
// at argp. callerpc is the address of the go statement that created
// this. The new g is put on the queue of g's waiting to run.
func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) {
   _g_ := getg()

   if fn == nil {
      _g_.m.throwing = -1 // do not dump full stacks
 throw("go of nil func value")
   }
   acquirem() // disable preemption because it can be holding p in a local var。禁止当前M被抢占
 siz := narg
   siz = (siz + 7) &^ 7

 // We could allocate a larger initial stack if necessary.
 // Not worth it: this is almost always an error.
 // 4*sizeof(uintreg): extra space added below
 // sizeof(uintreg): caller's LR (arm) or return address (x86, in gostartcall).
 if siz >= _StackMin-4*sys.RegSize-sys.RegSize {
      throw("newproc: function arguments too large for new goroutine")
   }

   _p_ := _g_.m.p.ptr()
   newg := gfget(_p_) //获取一个空闲的G,或创建一个并添加到allgs中,状态是_Gdead,拥有自己的协程栈
   if newg == nil {
      newg = malg(_StackMin)
      casgstatus(newg, _Gidle, _Gdead)
      allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
 }
   if newg.stack.hi == 0 {
      throw("newproc1: newg missing stack")
   }

   if readgstatus(newg) != _Gdead {
      throw("newproc1: new g is not Gdead")
   }

   totalSize := 4*sys.RegSize + uintptr(siz) + sys.MinFrameSize // extra space in case of reads slightly beyond frame
 totalSize += -totalSize & (sys.SpAlign - 1)                  // align to spAlign
 sp := newg.stack.hi - totalSize
   spArg := sp
   if usesLR {
      // caller's LR
 *(*uintptr)(unsafe.Pointer(sp)) = 0
 prepGoExitFrame(sp)
      spArg += sys.MinFrameSize
 }
   if narg > 0 { //校验函数是否有参数,有的话移动到自己的协程栈上
      memmove(unsafe.Pointer(spArg), argp, uintptr(narg))
      // This is a stack-to-stack copy. If write barriers
 // are enabled and the source stack is grey (the
 // destination is always black), then perform a
 // barrier copy. We do this *after* the memmove
 // because the destination stack may have garbage on
 // it.
 if writeBarrier.needed && !_g_.m.curg.gcscandone {
         f := findfunc(fn.fn)
         stkmap := (*stackmap)(funcdata(f, _FUNCDATA_ArgsPointerMaps))
         if stkmap.nbit > 0 {
            // We're in the prologue, so it's always stack map index 0.
 bv := stackmapdata(stkmap, 0)
            bulkBarrierBitmap(spArg, spArg, uintptr(bv.n)*sys.PtrSize, 0, bv.bytedata)
         }
      }
   }

   //sched用来保存现场(伪装执行现场,通过恢复sched现场,直接从startpc入口处 开始执行)
   memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
   newg.sched.sp = sp		//置为协程栈指针
   newg.stktopsp = sp
   newg.sched.pc = funcPC(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same function goexit地址+1压入栈,指向协程入口函数的起始地址
 newg.sched.g = guintptr(unsafe.Pointer(newg))
   gostartcallfn(&newg.sched, fn)
   newg.gopc = callerpc		//置为父协程调用newproc后的返回地址
   newg.ancestors = saveAncestors(callergp)
   newg.startpc = fn.fn		//置为协程入口函数的起始地址
   if _g_.m.curg != nil {
      newg.labels = _g_.m.curg.labels
   }
   if isSystemGoroutine(newg, false) {
      atomic.Xadd(&sched.ngsys, +1)
   }
   casgstatus(newg, _Gdead, _Grunnable)

   if _p_.goidcache == _p_.goidcacheend {
      // Sched.goidgen is the last allocated id,
 // this batch must be [sched.goidgen+1, sched.goidgen+GoidCacheBatch].
 // At startup sched.goidgen=0, so main goroutine receives goid=1.
 _p_.goidcache = atomic.Xadd64(&sched.goidgen, _GoidCacheBatch)
      _p_.goidcache -= _GoidCacheBatch - 1
 _p_.goidcacheend = _p_.goidcache + _GoidCacheBatch
 }
   newg.goid = int64(_p_.goidcache)	//唯一协程ID,状态置为_Gunnable(可以进入runq中)
   _p_.goidcache++
   if raceenabled {
      newg.racectx = racegostart(callerpc)
   }  
   if trace.enabled {
      traceGoCreate(newg, newg.startpc)
   }
   runqput(_p_, newg, true)	//把心创建的G放到当前P的本地队列中

   if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 && mainStarted { //有空闲的P && 所有的M都在忙 && 主协程已经开始执行了
      wakep()	//启动一个新的M
   }
   releasem(_g_.m)	//允许M被抢占
}


7)协程调度 schedule

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

  • work stealing 偷取
    • 无可运行G时,尝试从其他绑定线程的P偷取G
  • hand off 分离
    • 当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行

2)利用并行

  • 利用GOMAXPROCS限定P的个数

3)抢占

  • 主动让出

  • 设置stackPreempt表示,通知让出

  • 异步抢占(最多是10ms)

  • 调度程序,系统调用前m-g绑定,解除p的关联

4)全局G队列

  • 当M执行work stealing从其他P偷不到G时,可以从全局G队列获取G

8)协程监控 sysmon

  • 不需要依赖P,也不是GPM模型
  • 保障timer正常执行
  • 按需主动轮询(netpoll >10ms)
  • 对运行时间过长的P进行抢占(schedwhen上一次调用的时间+forcePreemptNS运行时间差值<now当前时间)
  • 强制执行GC

Notice

  • runtime/runtime2.go 主要是实体G、M、P、schedt的数据模型
  • runtime/proc.go 主要是调度实现的逻辑部分

go tool trace    适合于找出程序在一段时间内正在做什么(不是总体上的开销)

GODEBUG=schedtrace=1000 ./可执行程序

[Golang三关-典藏版] Golang 调度器 GMP 原理与调度全分析 | Go 技术论坛

go tool pprof    如果您想跟踪运行缓慢的函数,或者找到大部分CPU时间花费在哪里

golang系列—性能评测之pprof+火焰图+trace - 知乎

Golang合辑

【幼麟实验室】Golang合辑_哔哩哔哩_bilibili

The Go scheduler - Morsing's blog

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值