前言

GoLang有一个特性,就是原生支持协程,协程是一种用户态的线程,它的调度和切换是在用户态去做的,也就是由Go的调度器来实现,但是最终的执行工作还是由内核线程去完成,也就是一批协程通过相应进程的lwp与一批内核线程进程映射。

Go调度器组成

Go程序通过调度器来调度Goroutine在内核级线程上执行,但是并不直接绑定os线程M-Machine运行,而是由Goroutine Scheduler中的 P-processor作获取内核线程资源的【中介】。

Go的调度器通常被称为G-M-P模型

G

G:Goroutine,每个Gotoutine对应一个G结构体,G存储Goroutine的栈,指令指针,还有一些对于调用goroutines很重要的其它信息,比如阻塞它的任何channel,以及任务函数,可重用(函数实体)G需要保存到P才能被调度执行,简单的说,G就是一个协程,里面包含了相应的栈,寄存器,执行的函数指令等,可以看成是一个任务体。

type g struct {
  stack       stack   // 描述了真实的栈内存,包括上下界

  m              *m     // 当前的m
  sched          gobuf   // goroutine切换时,用于保存g的上下文      
  param          unsafe.Pointer // 用于传递参数,睡眠时其他goroutine可以设置param,唤醒时该goroutine可以获取
  atomicstatus   uint32
  stackLock      uint32 
  goid           int64  // goroutine的ID
  waitsince      int64 // g被阻塞的大体时间
  lockedm        *m     // G被锁定只在这个m上运行
}

Goroutine的栈在发展过程中出现了两种处理策略,详细可看:https://zhuanlan.zhihu.com/p/352537561

其中最主要的当然是sched了,保存了goroutine的上下文。goroutine切换的时候不同于线程有OS来负责这部分数据,而是由一个gobuf对象来保存,这样能够更加轻量级,再来看看gobuf的结构:

type gobuf struct {
    sp   uintptr
    pc   uintptr
    g    guintptr
    ctxt unsafe.Pointer
    ret  sys.Uintreg
    lr   uintptr
    bp   uintptr // for GOEXPERIMENT=framepointer
}

其实就是保存了当前的栈指针,计数器,当然还有g自身,这里记录自身g的指针是为了能快速的访问到goroutine中的信息。

M

M:Machine,os内核线程抽象,所有M是有线程栈的。如果不对该线程栈提供内存的话,系统会给该线程栈提供内存(不同操作系统提供的线程栈大小不同)。当指定了线程栈,则M.stack→G.stack,M的PC寄存器指向G提供的函数,然后去执行

type m struct {
    g0      *g     // 带有调度栈的goroutine

    gsignal       *g         // 处理信号的goroutine
    tls           [6]uintptr // thread-local storage
    mstartfn      func()
    curg          *g       // 当前运行的goroutine
    caughtsig     guintptr 
    p             puintptr // 关联p和执行的go代码
    nextp         puintptr
    id            int32
    mallocing     int32 // 状态

    spinning      bool // m是否out of work
    blocked       bool // m是否被阻塞
    inwb          bool // m是否在执行写屏蔽

    printlock     int8
    incgo         bool // m在执行cgo吗
    fastrand      uint32
    ncgocall      uint64      // cgo调用的总数
    ncgo          int32       // 当前cgo调用的数目
    park          note
    alllink       *m // 用于链接allm
    schedlink     muintptr
    mcache        *mcache // 当前m的内存缓存
    lockedg       *g // 锁定g在当前m上执行,而不会切换到其他m
    createstack   [32]uintptr // thread创建的栈
}

结构体M中有两个G是需要关注一下的,一个是curg,代表结构体M当前绑定的结构体G。另一个是g0,是带有调度栈的goroutine,这是一个比较特殊的goroutine。普通的goroutine的栈是在堆上分配的可增长的栈,而g0的栈是M对应的线程的栈。所有调度相关的代码,会先切换到该goroutine的栈中再执行。也就是说线程的栈也是用的是g实现,而不是使用的OS的。

P

代表一个处理器,每一个运行的M都必须绑定一个P,就像线程必须在么一个CPU核上执行一样,由P来调度G在M上的运行,P的个数就是GOMAXPROCS(最大256),启动时固定的,一般不修改;M的个数和P的个数不一定一样多(会有休眠的M或者不需要太多的M)(M最大10000);每一个P保存着本地G任务队列,也有一个全局G任务队列。P的数据结构:

type p struct {
    lock mutex

    id          int32
    status      uint32 // 状态,可以为pidle/prunning/...
    link        puintptr
    schedtick   uint32     // 每调度一次加1
    syscalltick uint32     // 每一次系统调用加1
    sysmontick  sysmontick 
    m           muintptr   // 回链到关联的m
    mcache      *mcache
    racectx     uintptr

    goidcache    uint64 // goroutine的ID的缓存
    goidcacheend uint64

    // 可运行的goroutine的队列
    runqhead uint32
    runqtail uint32
    runq     [256]guintptr

    runnext guintptr // 下一个运行的g

    sudogcache []*sudog
    sudogbuf   [128]*sudog

    palloc persistentAlloc // per-P to avoid mutex

    pad [sys.CacheLineSize]byte

其中P的状态有Pidle, Prunning, Psyscall, Pgcstop, Pdead;在其内部队列runqhead里面有可运行的goroutine,P优先从内部获取执行的g,这样能够提高效率。

除此之外,还有一个数据结构需要在这里提及,就是schedt,可以看做是一个全局的调度者:

type schedt struct {
   goidgen  uint64
    lastpoll uint64

    lock mutex

    midle        muintptr // idle状态的m
    nmidle       int32    // idle状态的m个数
    nmidlelocked int32    // lockde状态的m个数
    mcount       int32    // 创建的m的总数
    maxmcount    int32    // m允许的最大个数

    ngsys uint32 // 系统中goroutine的数目,会自动更新

    pidle      puintptr // idle的p
    npidle     uint32
    nmspinning uint32 

    // 全局的可运行的g队列
    runqhead guintptr
    runqtail guintptr
    runqsize int32

    // dead的G的全局缓存
    gflock       mutex
    gfreeStack   *g
    gfreeNoStack *g
    ngfree       int32

    // sudog的缓存中心
    sudoglock  mutex
    sudogcache *sudog
}

大多数需要的信息都已放在了结构体M、G和P中,schedt结构体只是一个壳。可以看到,其中有M的idle队列,P的idle队列,以及一个全局的就绪的G队列。schedt结构体中的Lock是非常必须的,如果M或P等做一些非局部的操作,它们一般需要先锁住调度器。

GMP模型简介

image-20210601161647243

本地队列:存放等待运⾏的G,一个本地队列存放的G数量一般不超过256个,优先将新创建的G放在P的本地队 列中,如果满了会放在全局队列中。

全局队列:存放等待运行的G,读写要加锁,所以拿取效率在多线程竞争的情况下相比于本地队列来说要低。

调度器的设计策略

复用线程

避免频繁的创建、销毁线程,⽽是对线程的复⽤

work stealing机制

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

全局的Goroutine队列,当Work Stealing失败,M可以从这个队列获取G任务。

hand off机制

当本线程因为G进⾏系统调⽤阻 塞时,线程释放绑定的P,把P转 移给其他空闲的线程执⾏。

利⽤并⾏

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

抢占

在coroutine中要等待⼀个协程主动让出CPU才执⾏下⼀个协程,在Go 中,⼀个goroutine最多占⽤CPU 10ms,防⽌其他goroutine被饿死.

“go func()” 经历了什么过程

image-20210601162506214

  1. 我们通过 go func()来创建⼀个goroutine;

  2. 有两个存储G的队列,⼀个是局部调度器P的本地队列、⼀个是全局G队列。新创建的G会先 保存在P的本地队列中,如果P的本地队列已经满了就会保存在全局的队列中;

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

  4. ⼀个M调度G执⾏的过程是⼀个循环机制;

  5. 当M执⾏某⼀个G时候如果发⽣了syscall或则其余阻塞操作,M会阻塞,如果当前有⼀些G在执⾏, runtime会把这个线程M从P中摘除(detach),然后再创建⼀个新的操作系统的线程(如果有空闲的线程可 ⽤就复⽤空闲线程)来服务于这个P;

  6. 当M系统调⽤结束时候,会尝试获取⼀个空闲的P执⾏,并把G放⼊到这个P的本地队列。如果获取不到P,那么这个线程M变成休眠状态, 加⼊到空闲线程中,然后这个G会被放⼊全局队列中。

调度器的⽣命周期

M0

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

G0

GOMAXPROCSg0

img

看下面的例子,

package main
import "fmt"

func main() {
    fmt.Println("Hello world")
}

上面代码的流程图:

GOMAXPROCSruntime.mainmain.mainruntime.maindeferpanicruntime.exit

注意点

局部性原则

image-20210601163853191

P拥有G1,M1获取P后开始运⾏G1,G1使⽤go func()创建了G2,为了局部性G2优先加⼊到P1的本地队列。

在创建G时,运⾏的G会尝试唤醒其他空闲的P和M组合去执⾏

image-20210601164257998

假定G2唤醒了M2,M2绑定了P2,并运⾏G0,但P2本地队列没有G,M2此时为⾃旋线程(没有G但为运⾏ 状态的线程,不断寻找G)。

M2尝试从全局队列(简称“GQ”)取⼀批G放到P2的本地队列(函数:findrunnable())。M2从全局队列取 的G数量符合下⾯的公式: n = min(len(GQ)/GOMAXPROCS + 1, len(GQ/2))

这里看起来会和之前的先从别的P的本地队列偷再从全局队列偷有冲突。其实是没冲突的,情况1是当某个M把自己本地队列的G执行完了,那么先去别的M的本地队列取偷,情况2是从休眠中被唤醒的M,处于自旋状态,先从全局偷

G发生阻塞的系统调用后

image-20210601164646572

G8创建了G9, G8进⾏了阻塞的系统调⽤,M2和P2⽴即解绑,P2会执⾏以下判断:如果P2本地队列有G、全局队列有G 或有空闲的M,P2都会⽴⻢唤醒1个M和它绑定,否则P2则会加⼊到空闲P列表,等待M来获取可⽤的p(这里我觉得和上面历程的第5点有冲突,不应该让p空闲,而是会立刻创建一个m,然后让p去偷g执行,这样减少了p的空闲时间)。 本场景中,P2本地队列有G9,可以和其他空闲的线程M5绑定。

G发生非阻塞的系统调用后

image-20210601165813201

M2和P2会解绑,但M2会记住P2,然后G8和M2进⼊系统调⽤状态。当G8和M2退出系统调⽤时,会尝试获 取P2,如果⽆法获取,则获取空闲的P,如果依然没有,G8会被记为可运⾏状态,并加⼊到全局队列,M2因为没 有P的绑定⽽变成休眠状态(⻓时间休眠等待GC回收销毁)