美文网首页
GO学习笔记(5)runtime中的调度机制(GMP模型)

GO学习笔记(5)runtime中的调度机制(GMP模型)

作者: 温岭夹糕 | 来源:发表于2022-07-20 21:17 被阅读0次

前言

知识搬运工,不生成新知识
前文我们介绍了runtime对于go的重要性,实际上GO语言的runtime包括4个模块:

  • scheduler
    调度管理所有GMP,在后台执行调度循环
  • netpoll
    网络轮询负责管理网络FD相关的读写,就绪和事件
  • memory
    当代码需要内存时,负责内存分配工作
  • garbage
    垃圾回收
    go的调度模型GMP我们也是常常听到的,现在来仔细了解一下

1.GMP模型

runtime/proc.go文件中对GMP的描述

// Goroutine scheduler
// The scheduler's job is to distribute ready-to-run goroutines over worker threads.
//
// The main concepts are:
// G - goroutine.
// M - worker thread, or machine.
// P - processor, a resource that is required to execute Go code.
这里也了解到P是一个抽象的概念
// M must have an associated P to execute Go code, however it can be
// blocked or in a syscall w/o an associated P.

GMP
  • G(gorutine)
    每个gorutine都有自己的栈空间,定时器,初始化的栈空间在2K左右,表示一个用户的代码执行流(下面代码仅截取部分,go/src/runtime/runtime2.go)
type g struct {
    stack       stack   // offset known to runtime/cgo
    stackguard0 uintptr // offset known to liblink
    stackguard1 uintptr // offset known to liblink

    _panic       *_panic // innermost panic - offset known to liblink
    _defer       *_defer // innermost defer
    m            *m      // current m; offset known to arm liblink
    sched        gobuf

stack是什么样的呢?是由两个uintpr组成的结构体(uintptr是golang的内置类型,是能存储指针的整型,补充一个知识点,CPU描述栈是利用栈段寄存器SS和栈顶偏移指针SP寄存器来寻找一个栈地址的,即栈顶和栈底)

type stack struct {
    lo uintptr
    hi uintptr
}
//实际是无符号长整形
typedef unsigned long long int  uint64;
typedef uint64          uintptr;

回到G数据结构,结构体成员stack,stackguard0、stackguard1都是描述goroutine的栈的,在GO的术语里,G的stack叫做user stack用户栈,而M的叫做system stack系统栈。上面提到过栈初始空间为2KB,一个go进程的G是可以有上万个的,如果空间太大那么内存空间就会很快消耗完毕,但如果G的调用链很深则用户栈会动态增加(像slice一样根据cap动态增加)。
G的定义里还包括等待被执行的_panic和_defer函数链表,链表节点里有指向下一个节点的指针(这里以defer为例)

type _defer struct {
        started bool
        heap    bool
        openDefer bool
        sp        uintptr // sp at time of defer
        pc        uintptr // pc at time of defer
        fn        func()  // can be nil for open-coded defers
        _panic    *_panic // panic that is running defer
        link      *_defer // next defer on G; can point to either heap or stack!
        varp uintptr      
        framepc uintptr
}

还包括了sp和pc寄存器的值

  • M(machine)
    代表内核线程,记录内核线程栈信息,当gorutine调度到线程时,使用该gorutine自己的栈信息
type m struct {
        g0      *g     // goroutine with scheduling stack
        
        curg          *g       // current running goroutine
        p             puintptr // attached p for executing go code (nil if not executing go code)
        nextp         puintptr
        oldp          puintptr // the p that was attached before executing a syscall
        id            int64

curg表示正在运行的G,p表示当前使用的P,每一个m在创建时都会生成一个g0(这个后文再说,先留个印象)

  • P(process)
    代表调度器,负责调度gorutine,M从P上获取G执行
type p struct {
    m           muintptr   // back-link to associated m (nil if idle)

    // Queue of runnable goroutines. Accessed without lock.
    runqhead uint32
    runqtail uint32
    runq     [256]guintptr
    schedtick   uint32 

一个p对应一个m

简化GMP
P的主要作用是从一个队列里调度G,其中这个队列(local run queue,LRQ)里的G是“准备好”的(即G没有在执行I/O操作等阻塞操作,而是可以继续执行计算,处于等待中的G被存放在netpoll中)。其中队列除了有本地队列LRQ外还有全局队列(Global run queue,GRQ),P的存在很大程度上是为了有多个LRQ,我们想一想CPU有多个Core意味着有多个M(一个线程对应一个核),如果M都从GRQ中取G来执行,那么就需要同步机制如mutex来保护,M在取G的时候就需要等待mutex,那么每个M从自己对应的P中的LRQ来取G执行就不要这个mutex。P为M从LRQ提供G,来减少争抢锁消耗的时间,即为生产消费流程少加锁,那LRQ有了后,GRQ不就没有存在的意义了?LRQ是有长度限制的,GRQ是没有的,新创建的G会默认被放进LRQ(满了就排挤出一个老G),被挤出的则放进GRQ,GRQ的调度机会?
runtime/proc.go里的 schedule函数
func schedule(){
if gp == nil {
    // Check the global runnable queue once in a while to ensure fairness.
    // Otherwise two goroutines can completely occupy the local runqueue
    // by constantly respawning each other.
    if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
        lock(&sched.lock)
        gp = globrunqget(_g_.m.p.ptr(), 1)
        unlock(&sched.lock)
    }
}
if gp == nil {
    gp, inheritTime = runqget(_g_.m.p.ptr())
    // We can see gp != nil here even if the M is spinning,
    // if checkTimers added a local goroutine via goready.
}
}

schedtick%61 == 0,调度器每执行61次,执行globrunqget(g.m.p.ptr(), 1),从GRQ中取一个G(runqget是LRQ即全局队列没有的话回去本地队列取)

小结

至此介绍了GMP的大致数据结构、LRQ和GRQ

1.1M的创建

M是核心线程,创建需要调用系统调用,在GO中他的创建是调用了runtime/proc.go的newm函数

func newm(fn func(), _p_ *p, id int64) {
    mp := allocm(_p_, fn, id)
    mp.nextp.set(_p_)
    mp.sigmask = initSigmask
    ...
    newm1(mp)
}
func newm1(mp *m)  {...newosproc(mp)...}

fn是线程要启动后执行的GO函数如果为nil则默认执行scheduler,继续看runtime/os_linux.go的newosproc函数

func newosproc(mp *m) {
        ...
    ret := clone(cloneFlags, stk, unsafe.Pointer(mp), 
                     unsafe.Pointer(mp.g0), 
                     unsafe.Pointer(funcPC(mstart)))

它调用了一个叫 clone 的函数,来创建线程,并且执行 mstart 函数(当 fn 是 nil时)。clone定义在汇编文件sys_linux_amd64.s里面,通过SYSCALL指令发起系统调用

#define SYS_clone  56

TEXT runtime·clone(SB),NOSPLIT,$0
        ...
    MOVL    $SYS_clone, AX
    SYSCALL

    // In parent, return.
        ...
    RET

    // In child, on new stack.
        MOVQ    fn+32(FP), R12
    // Call fn
    CALL    R12

汇编懂得也不多,大意就是往AX寄存器写入56,SYSCALL指令发起对56号操作系统功能的调用,创建新线程,新线程执行clone再创建子线程,父线程RET返回,新线程MOVQ执行参数fn,fn是nil时默认为mastart函数,调用schedule函数(上文M的执行函数P从队列拉取G)。至此我们大致了解了M的创建流程,当然M必须获得P才能执行代码,否则休眠(P相当于一种token,具有在CPU CORE上执行的权力,m结构体上有一个p指针)

1.2g0-M执行G

上文当M被创建时,他会执行schedule代码(从LRQ或GRQ获取G执行),我们就可以把这件事情(M执行schedule)抽象成一个gorutine在执行(这个G叫做M上的g0,见上文m的数据结构体g0成员),那如何做到G的切换呢?即调用其他G函数(下面摘自王爽老师的比喻)
函数(一个函数就是一堆指令的集合)的调用在汇编中通过Call指令实现,在stack(ss:sp)上新建一个frame(帧)(栈顶指针SP上挪frame size空间),拷贝进参数,CPU执行被调用函数就是PC寄存器变成函数起始地址。(Call指令在修改SP栈顶指针和PC寄存器前都会保存,供RET指令返回使用,这样CPU就能执行CALL指令之后的指令即继续执行调用函数),gorutine的切换和函数调用类似,修改CPU的SP和PC寄存器值,但是SP不再在当前stack挪动,而是另一个goroutine的stack,那么当Ret指令返回要恢复上下文时,就回到之前的gorutine。
回到GMP,当schedule函数取得G发起gorutine切换时,M不再执行g0,而是执行取得的新G,G执行的是一个GO函数,函数最后指令是RET,当G执行完后SP和PC值恢复继续执行g0(g0是schedule函数里的无线循环,负责寻找G,然后切过去)


调度

实际上是4个函数循环执行,还记得P结构体上有一个成员是schedtick它记录的是调度循环次数,4个函数循环一次+1

1.3G的创建

go func(){}创建G的过程
G的创建在runtime/proc.go的newproc中

func newproc(fn *fncval){
   gp :=getg()
   pc:=getcallerpc()
   systemstack(func(){
   ... newproc1(fn,gp,pc)...
   runqput(_p_, newg, true)

   if mainStarted {
          wakep()
   }

})}

尝试获取参数位置和函数执行完跳回的位置,再调用systemstack--调用newproc1(切换到g0上执行并使用g0的栈),newproc1是真正负责初始化G的,之后runqput进入队列

1.3.1G入队流程

看runqput代码可以发现还有一个p.runnext,其实新G进入LRQ之前还会先进入runnext(优先级更高,runnext只是一个指针可以理解为容量为1的队列,越是新G被调度几率越大)

入队
当LRQ满的时候会携带一半塞GRQ(链表,无限大)

1.4P找G的机制

按照优先级:
从runnext获取任务
从本地队列获取任务
从全局队列获取任务
从网络轮询器获取任务
从其它的处理器的本地队列窃取任务,

当LRQ和GRQ没有g时, 会先看看网络轮询器有没有需要处理的g.如果也没有,才会进行work-stealing

1.5处理阻塞

前文在谈P的时候我们知道LRQ,GRQ存放的是没有阻塞操作的、“准备好”的、可继续执行计算的G,那么有阻塞任务的G怎么办?
阻塞情况(内中断):

  • 没有缓冲区的channel
var ch = make(chan int)
ch<- 1
  • 休眠
sleep(time.Second)
  • 等待锁
  • I/O
  • 事件循环
    M遇到阻塞任务会将G挂起,放入netpoll等,待其ready后继续执行,此时M进入schedule继续执行其他G
    以上是可以接管的阻塞,如果是不可接管的阻塞呢如syscall,cgo和长时间运行等需要sysmon将其剥离P执行(一般10ms),sysmon是一个后台高优先级循环,会发信号抢占执行时间长的G

总结

介绍了GMP模型,go函数实际是向runtime提交一个计算任务(G),这个新G会被送入runnext,老G会被送入某一个P的LRQ,队伍满时LRQ踢出的G会被送入GRQ,M启动时默认执行schedule函数来不断从P中获取G(GO的调度流程本质上是一个生产消费流程)

参考

1.runtime调度机制概览
2.深度解读scheduler
3.GO并发模型
4.汇编:函数调用
5.GMP模型
6.uinptr原理
7.万字长文goruntime

相关文章

  • go 调度器实现

    GO 语言的调度器 目录 GMP 模型简介 调度器实现机制 GMP 模型简介 先来一张经典的GMP 关系图 G 是...

  • GO学习笔记(5)runtime中的调度机制(GMP模型)

    前言 知识搬运工,不生成新知识前文我们介绍了runtime[https://www.jianshu.com/p/2...

  • golang协程调度面试总结

    1.go的GMP模型 goroutine运行在用户态,是由runtime来控制调度的,调度过程中主要涉及到三个对象...

  • 【go笔记】goroutine调度器的GMP模型简介

    说起go语言,离不开goroutine。之前使用go语言开发的时候,也没多少机会用到goroutine。趁这些天了...

  • go 的并发调度(一) GMP 模型

    协程和线程的历史关系? 抢占式和协同式抢占式就是线程无法决定自己执行多久,由操作系统(或其他分配系统)来分配一个线...

  • 调度器——GMP 调度模型

    调度器——GMP 调度模型 Goroutine 调度器,它是负责在工作线程上分发准备运行的 goroutines。...

  • 一文入门 Go 的性能分析

    Go 为了实现更高的并发,自己实现了用户态的调度器,称之为 GMP 模型,在上一篇文章中,我们已经简单分析了它的实...

  • runtime

    1.runtime的gc机制2.runtime的调度机制

  • go并发的那些事

    思考:go为什么那么擅长并发? 答:从设计理解上来讲我觉着golang的CSP并发模型与GMP调度器是基石。你看虽...

  • Go - GMP模型

    简述 G — 表示 Goroutine,它是一个待执行的任务; M — 表示操作系统的线程,它由操作系统的调度器调...

网友评论

      本文标题:GO学习笔记(5)runtime中的调度机制(GMP模型)

      本文链接:https://www.haomeiwen.com/subject/oetpirtx.html