Go语言的调度模型(GPM)

GPM模型

定义于src/runtime/runtime2.go

  • G: Gourtines(携带任务), 每个Goroutine对应一个G结构体,G保存Goroutine的运行堆栈,即并发任务状态。G并非执行体,每个G需要绑定到P才能被调度执行。
  • P: Processors(分配任务), 对G来说,P相当于CPU核,G只有绑定到P(在P的local runq中)才能被调度。对M来说,P提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等
  • M: Machine(寻找任务), OS线程抽象,负责调度任务,和某个P绑定,从P的runq中不断取出G,切换堆栈并执行,M本身不具备执行状态,在需要任务切换时,M将堆栈状态写回G,任何其它M都能据此恢复执行。

G-P-M模型示意图:

PS:

  1. P的个数由GOMAXPROCS指定,是固定的,因此限制最大并发数
  2. M的个数是不定的,由Go Runtime调整,默认最大限制为10000个

基本调度过程:

  1. 创建一个 G 对象;
  2. 将 G 保存至 P中;
  3. P 去唤醒(告诉)一个 M,然后继续执行它的执行序(分配下一个 G);
  4. M 寻找空闲的 P,读取该 P 要分配的 G;
  5. 接下来 M 执行一个调度循环,调用 G → 执行 → 清理线程 → 继续找新的 G 执行。

各自携带的信息:

  • G

    • 需执行函数的指令(指针)
    • 线程上下文的信息(goroutine切换时,用于保存 g 的上下文,例如,变量、相关信息等)
    • 现场保护和现场恢复(用于全局队列执行时的保护)
    • 所属的函数栈
    • 当前执行的 m
    • 被阻塞的时间
  • P,P/M需要进行绑定,构成一个执行单元。P决定了同时可以并发任务的数量,可通过GOMAXPROCS限制同时执行用户级任务的操作系统线程。可以通过runtime.GOMAXPROCS进行指定。

    • 状态(空闲、运行...)
    • 关联的 m
    • 可运行的 goroutine 的队列
    • 下一个 g
  • M,所有M是有线程栈的。如果不对该线程栈提供内存的话,系统会给该线程栈提供内存(不同操作系统提供的线程栈大小不同)。

    • 所属的调度栈
    • 当前运行的 g
    • 关联的 p
    • 状态

基础知识:

普通栈:普通栈指的是需要调度的 goroutine 组成的函数栈,是可增长的栈,因为 goroutine 可以越开越多。

线程栈:线程栈是由需要将 goroutine 放置线程上的 m 们组成,实质上 m 也是由 goroutine 生成的,线程栈大小固定(设置了 m 的数量)。所有调度相关的代码,会先切换到该goroutine的栈中再执行。也就是说线程的栈也是用的g实现,而不是使用的OS的。

全局队列:该队列存储的 G 将被所有的 M 全局共享,为保证数据竞争问题,需加锁处理。

本地队列:该队列存储数据资源相同的任务,每个本地队列都会绑定一个 M ,指定其完成任务,没有数据竞争,无需加锁处理,处理速度远高于全局队列。

上下文切换:对于代码中某个值说,上下文是指这个值所在的局部(全局)作用域对象。相对于进程而言,上下文就是进程执行时的环境,具体来说就是各个变量和数据,包括所有的寄存器变量、进程打开的文件、内存(堆栈)信息等。

线程清理:

由于每个P都需要绑定一个 M 进行任务执行,所以当清理线程的时候,只需要将 P 释放(解除绑定)(M就没有任务),即可。P 被释放主要由两种情况:

  • 主动释放:最典型的例子是,当执行G任务时有系统调用,当发生系统调用时M会处于阻塞状态。调度器会设置一个超时时间,当超时时会将P释放。
  • 被动释放:如果发生系统调用,有一个专门监控程序,进行扫描当前处于阻塞的P/M组合。当超过系统程序设置的超时时间,会自动将P资源抢走。去执行队列的其它G任务。
阻塞是正在运行的线程没有运行结束,暂时让出 CPU。

抢占式调度:

runtime.main中会创建一个额外m运行sysmon函数,抢占就是在sysmon中实现的。

sysmon会进入一个无限循环, 第一轮回休眠20us, 之后每次休眠时间倍增, 最终每一轮都会休眠10ms. sysmon中有netpool(获取fd事件), retake(抢占), forcegc(按时间强制执行gc), scavenge heap(释放自由列表中多余的项减少内存占用)等处理。

抢占条件:

  1. 如果 P 在系统调用中,且时长已经过一次 sysmon 后,则抢占;

调用 handoffp 解除 M 和 P 的关联。

  1. 如果 P 在运行,且时长经过一次 sysmon 后,并且时长超过设置的阻塞时长,则抢占;

设置标识,标识该函数可以被中止,当调用栈识别到这个标识时,就知道这是抢占触发的, 这时会再检查一遍是否要抢占。

流程:

每创建出一个 g,优先创建一个 p 进行存储,当 p 达到限制后,则加入状态为 waiting 的队列中。

如果 g 执行时需要被阻塞,则会进行上下文切换,系统归还资源后,再返回继续执行。

当一个G长久阻塞在一个M上时,runtime会新建一个M,阻塞G所在的P会把其他的G 挂载在新建的M上。当旧的G阻塞完成或者认为其已经死掉时 回收旧的M(抢占式调度)。

P会对自己管理的goroutine队列做一些调度(比如把占用CPU时间较长的goroutine暂停、运行后续的goroutine等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务(所以需要单独存储下一个 g 的地址,而不是从队列里获取)。

 

总结:


  Go比较优势的设计就是P上下文这个概念的出现,如果只有G和M的对应关系,那么当G阻塞在IO上的时候,M是没有实际在工作的,这样造成了资源的浪费,没有了P,那么所有G的列表都放在全局,这样导致临界区太大,对多核调度造成极大影响。

  保护现场的抢占式调度和G被阻塞后传递给其他m调用的核心思想,使得goroutine的产生。

  从线程调度讲,Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的,goroutine则是由Go运行时(runtime)自己的调度器调度的,这个调度器使用一个称为m:n调度的技术(复用/调度m个goroutine到n个OS线程)。 其一大特点是goroutine的调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。 另一方面充分利用了多核的硬件资源,近似的把若干goroutine均分在物理线程上, 再加上本身goroutine的超轻量,以上种种保证了go调度方面的性能。

 

 

 

———————————————————————————————————————————————————————————————————————————

 

源码附注:

调度流程

  在M与P绑定后,M会不断从P的Local队列(runq)中取出G(无锁操作),切换到G的堆栈并执行,当P的Local队列中没有G时,再从Global队列中返回一个G(有锁操作,因此实际还会从Global队列批量转移一批G到P Local队列),当Global队列中也没有待运行的G时,则尝试从其它的P窃取(steal)部分G来执行,源代码如下:

// go1.9.1  src/runtime/proc.go
// 省略了GC检查等其它细节,只保留了主要流程

// g:       G结构体定义
// sched:   Global队列
// 获取一个待执行的G
func findrunnable() (gp *g, inheritTime bool) {
    // 获取当前的G对象
    _g_ := getg()

top:
    // 获取当前P对象
    _p_ := _g_.m.p.ptr()

    // 1. 尝试从P的Local队列中取得G 优先_p_.runnext 然后再从Local队列中取
    if gp, inheritTime := runqget(_p_); gp != nil {
        return gp, inheritTime
    }

    // 2. 尝试从Global队列中取得G
    if sched.runqsize != 0 {
        lock(&sched.lock)
        // globrunqget从Global队列中获取G 并转移一批G到_p_的Local队列
        gp := globrunqget(_p_, 0)
        unlock(&sched.lock)
        if gp != nil {
            return gp, false
        }
    }

    // 3. 检查netpoll任务
    if netpollinited() && sched.lastpoll != 0 {
        if gp := netpoll(false); gp != nil { // non-blocking
            // netpoll返回的是G链表,将其它G放回Global队列
            injectglist(gp.schedlink.ptr())
            casgstatus(gp, _Gwaiting, _Grunnable)
            if trace.enabled {
                traceGoUnpark(gp, 0)
            }
            return gp, false
        }
    }

    // 4. 尝试从其它P窃取任务
    procs := uint32(gomaxprocs)
    if atomic.Load(&sched.npidle) == procs-1 {
        goto stop
    }
    if !_g_.m.spinning {
        _g_.m.spinning = true
        atomic.Xadd(&sched.nmspinning, 1)
    }
    for i := 0; i < 4; i++ {
        // 随机P的遍历顺序
        for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
            if sched.gcwaiting != 0 {
                goto top
            }
            stealRunNextG := i > 2 // first look for ready queues with more than 1 g
            // runqsteal执行实际的steal工作,从目标P的Local队列转移一般的G过来
            // stealRunNextG指是否steal目标P的p.runnext G
            if gp := runqsteal(_p_, allp[enum.position()], stealRunNextG); gp != nil {
                return gp, false
            }
        }
    }
    ...
}

  当无G可执行时,M会与P解绑,进入休眠状态

用户态阻塞/唤醒

  当Goroutine因为Channel操作而阻塞(通过gopark)时,对应的G会被放置到某个wait队列(如channel的waitq),该G的状态由_Gruning变为_Gwaitting,而M会跳过该G尝试获取并执行下一个G。

  当阻塞的G被G2唤醒(通过goready)时(比如channel可读/写),G会尝试加入G2所在P的runnext,然后再是P Local队列和Global队列。

SYSCALL

  当G被阻塞在某个系统调用上时,此时G会阻塞在_Gsyscall状态,M也处于block on syscall状态,此时仍然可被抢占调度: 执行该G的M会与P解绑,而P则尝试与其它idle的M绑定,继续执行其它G。如果没有其它idle的M,但队列中仍然有G需要执行,则创建一个新的M。

  当系统调用完成后,G会重新尝试获取一个idle的P,并恢复执行,如果没有idle的P,G将加入到Global队列。

  

系统调用能被调度的关键有两点:

    runtime/syscall包中,将系统调用分为SysCall和RawSysCall,前者和后者的区别是前者会在系统调用前后分别调用entersyscall和exitsyscall(位于src/runtime/proc.go),做一些现场保存和恢复操作,这样才能使P安全地与M解绑,并在其它M上继续执行其它G。某些系统调用本身可以确定会长时间阻塞(比如锁),会调用entersyscallblock在发起系统调用前直接让P和M解绑(handoffp)。

  另一个是sysmon,它负责检查所有系统调用的执行时间,判断是否需要handoffp。

sysmon

  sysmon是一个由runtime启动的M,也叫监控线程,它无需P也可以运行,它每20us~10ms唤醒一次,主要执行:

  1. 释放闲置超过5分钟的span物理内存;
  2. 如果超过2分钟没有垃圾回收,强制执行;
  3. 将长时间未处理的netpoll结果添加到任务队列;
  4. 向长时间运行的G任务发出抢占调度;
  5. 收回因syscall长时间阻塞的P;

抢占式调度

  当某个goroutine执行超过10ms,sysmon会向其发起抢占调度请求,由于Go调度不像OS调度那样有时间片的概念,因此实际抢占机制要弱很多: Go中的抢占实际上是为G设置抢占标记(g.stackguard0),当G调用某函数时(更确切说,在通过newstack分配函数栈时),被编译器安插的指令会检查这个标记,并且将当前G以runtime.Goched的方式暂停,并加入到全局队列。

NETPOLL

  G的获取除了p.runnext,p.runq和sched.runq外,还有一中G从netpoll中获取,netpoll是Go针对网络IO的一种优化,本质上为了避免网络IO陷入系统调用之中,这样使得即便G发起网络I/O操作也不会导致M被阻塞(仅阻塞G),从而不会导致大量M被创建出来。

G创建:

  G结构体会复用,对可复用的G管理类似于待运行的G管理,也有Local队列(p.gfree)和Global队列(sched.gfree)之分,获取算法差不多,优先从p.gfree中获取(无锁操作),否则从sched.gfree中获取并批量转移一部分(有锁操作),源代码参考src/runtime/proc.go:gfget函数。

  从Goroutine的角度来看,通过go func()创建时,会从当前闲置的G队列取得可复用的G,如果没有则通过malg新建一个G,然后:

  1. 尝试将G添加到当前P的runnext中,作为下一个执行的G
  2. 否则放到Local队列runq中(无锁)
  3. 如果以上操作都失败,则添加到Global队列sched.runq中(有锁操作,因此也会顺便将当P.runq中一半的G转移到sched.runq)

G的几种暂停方式:

  1. gosched: 将当前的G暂停,保存堆栈状态,以_GRunnable状态放入Global队列中,让当前M继续执行其它任务。无需对G进行唤醒操作,因为总会有M从Global队列取得并执行该G。抢占调度即使用该方式。
  2. gopark: 与goched的最大区别在于gopark没有将G放回执行队列,而是位于某个等待队列中(如channel的waitq,此时G状态为_Gwaitting),因此G必须被手动唤醒(通过goready),否则会丢失任务。应用层阻塞通常使用这种方式。
  3. notesleep: 既不让出M,也不让G和P重新调度,直接让线程休眠直到被唤醒(notewakeup),该方式更快,通常用于gcMark,stopm这类自旋场景
  4. notesleepg: 阻塞G和M,放飞P,P可以和其它M绑定继续执行,比如可能阻塞的系统调用会主动调用entersyscallblock,则会触发 notesleepg
  5. goexit: 立即终止G任务,不管其处于调用堆栈的哪个层次,在终止前,确保所有defer正确执行。

 

  

posted @ 2020-11-14 16:59  布尔先生  阅读(4366)  评论(0编辑  收藏  举报