编程技术分享

  • 首页
  1. 首页
  2. Go
  3. 正文

Go调度模型

2022年6月15日 2699点热度 0人点赞 18条评论

导语

本文基于go1.16.7的源码,详细介绍MPG调度模型的实现。

1、程序启动引导

1.1 程序入口地址

我们写一个最简单的示例程序main.go来看看程序的入口:

package main
import “fmt”
func main() {
    fmt.Println(“jemuel”)
}

在Linux机器上编译:go build -o main main.go。编译后会生成可执行文件main,该文件为ELF格式。

可以通过以下方式查看程序入口地址(Entry point address):

1、通过readelf

执行readelf -h main,得到如下结果:

可以看到Entry point address为:0x465740,其中0x400000是ELF装载到内存的起始地址,0x65740为入口函数在ELF中的偏移量。

2、通过objdump

执行objdump -f main,得到如下结果:

3、通过gdb

执行gdb main、info files,得到如下结果:

1.2 程序启动流程

知道了程序的入口地址,可以通过gdb跟踪程序的启动引导流程。

gdb指令如下:

>gdb main //启动
>b *0x465740 //设置断点
>r //运行
>layout src //显示源码
>n //单步跟踪

gdb单步跟踪:

rt0_linux_amd64.s/_rt0_amd64_linux

asm_amd64.s/_rt0_amd64

asm_amd64.s/rt0_go

从上面我们可以看到启动入口在目录src/runtime中,该目录包含了go的运行时代码,其中包括许多与操作系统、机器架构、内存管理以及任务调度等相关的代码。整个启动调用链如下:

程序启动时的主线程可以看作M0,在M0中进行系统相关初始化、调度器初始化、创建main函数的协程、执行调度任务等。关键流程如下:

其中各部分详细流程会在后面介绍。

2、MPG调度模型

相信你听说过go的MPG调度模型,那MPG到底是什么,它是如何实现的?接下来我们将详细介绍MPG。

  • M:Machine,对应内核线程,初始化时设置的最大数量为10000个(sched.maxmcount)。
  • P:Processor,逻辑处理器,负责调度协程,M需要绑定P来执行G代码,默认数量为CPU数量,可通过环境变量GOMAXPROCS调整。
  • G:Goroutine,协程,一种轻量级用户线程,通过go关键字创建的执行体。

M、P、G之间的大致关系如下:

2.1 M模型

2.1.1 M结构

// runtime/runtime2.go
type m struct {
    g0 *g //提供系统栈空间,主要用于执行调度任务
    curg *g //当前运行的G
    gsignal *g //信号处理G
    p puintptr //执行代码关联的P(没有执行则为nil)
    spinning bool //自旋状态,M当前没有任务并且正在查找任务
    mstartfn func() //线程启动后执行函数(在mstart中调用)
    park note //休眠锁
    ...
}

2.1.2 M运行情况

实际运行过程中,会创建许多物理线程,其中有些物理线程会运行特殊的任务,比如:调度、监控等。如下所示:

M0是程序启动的主线程,每个新创建的M都会绑定一个G0,该G0栈空间用于执行任务调度或系统监控,除了G0外,其他G要想执行任务,需要通过P绑定M。M与P是一对一关系,P与G是一对多关系。

2.1.3 唤醒M

当创建协程时,会尝试唤醒M执行任务,如果没有空闲M,则会新建M,唤醒流程如下:

 

函数调用链:

  • startm:获取M、P,准备执行任务。
  • pidleget:从全局空闲P队列中获取P。
  • mget:从全局空闲M队列中获取M。
  • newm:创建新M。
  • notewakeup:唤醒M。
  • allocm:分配M实例,并初始化。
  • newm1:创建新M。
  • mcommoninit:初始化M部分属性。
  • malg:创建G,此处为M的G0。
  • newosproc:根据不同OS创建物理线程,线程启动函数为mstart。

2.1.4 启动函数

从上面流程可以看到创建物理线程时传入的启动函数为mstart,查看mstart的源码可以看到最关键的步骤是启动调度,流程如下:

流程中mstartfn是创建M时传入的函数,会根据不同情况传入不同参数,例如:sysmon(监控函数,后面会介绍)、mspinning(设置M自旋)等。

2.2 P模型

2.2.1 P结构

// runtime/runtime2.go
type p struct {
    id int32 //P id
    status uint32 //P状态
    m muintptr //关联的M(idle时为nil)
    runqhead uint32 //可运行G队列的起始游标,获取G
    runqtail uint32 //可运行G队列的终止游标,增加G
    runq [256]guintptr //可运行G队列
    runnext guintptr //下一个可运行G
    gFree struct {
        gList //空闲G队列
        n int32 //空闲G数量
    }
    ...
}

2.2.2 创建P

要运行G的任务,需要绑定到一个P,P的数量决定了并行任务的数量,P的数量默认设置为机器处理器的数量,可以通过环境变量GOMAXPROCS进行调整。创建P的函数入口为procresize,该函数的调用有两个途径:程序启动引导阶段在schedinit中调用、GC后在startTheWorld中调用。执行流程如下:

函数调用链如下:

  • acquirep:绑定当前M和指定P。
  • pidleput:将指定P加入调度器的空闲P队列sched.pidle中。

2.3 G模型

2.3.1 G结构

// runtime/runtime2.go
type g struct {
    stack stack //执行栈空间,[stack.lo, stack.hi)
    sched gobuf //用于保存执行现场
    goid int64 //协程id
    gopc uintptr //调用者PC/IP
    startpc uintptr //任务函数
    m *m //当前M
    preempt bool //抢占信号
    ...
}

2.3.2 创建G

G为函数执行的载体,提供了运行时栈空间,是任务调度的单元,可以理解为一种轻量级的用户线程。当我们在代码中通过go关键字修饰函数调用时,编译器会转换为调用runtime.newproc的汇编指令,该函数会使用被调用函数的指针、参数来创建一个G,流程如下:

函数调用链:

  • newproc:创建指定函数的新G。
  • runqput:将新G放入当前P的可运行G队列。
  • runqputslow:将P中本地G队列前一半放入全局G队列,因为全局队列要加锁,所以叫slow。
  • gfget:从空闲G队列获取G(包括P本地、全局)。
  • malg:实例化G对象,并分配栈空间,G0栈为8K,其他G栈为2k。
  • wakep:唤醒一个M执行任务(新唤醒的M、P会查找可运行G,不一定会执行新G)。

2.3.3 空闲G交换

G在执行完任务后,会进入空闲G队列等待下一次复用,空闲G队列包括:P的本地空闲G队列p.gFree、全局空闲G队列sched.gFree。当P的本地空闲G队列数量超过限制时(64),会取出一半放入全局空闲G队列。

P本地空闲G队列的存在主要是在多线程并发场景下减少锁的消耗。获取P本地空闲G队列时,由于是在同一个M中,因此无需加锁。获取全局空闲G队列时,存在多线程竞争访问,因此需要加锁。

空闲G的交换关系如下:

  • gfget:从P本地空闲G队列或全局空闲G队列获取空闲G。
  • gfpurge:将P本地空闲G队列全部放入全局空闲G队列。
  • gfput:将G放入P本地空闲G队列,如果P本地空闲G队列超过数量限制,会放入一半到全局空闲G队列。

2.3.4 可运行G交换

可运行G是所有等待运行的任务,可运行G队列包括:P的本地可运行G队列p.runq、全局可运行G队列sched.runq。P的本地可运行G队列最大数量为256,当超过限制时,会取出一半放入全局可运行G队列。

P本地可运行G队列的存在主要是在多线程并发场景下减少锁的消耗。获取P本地可运行G队列时,由于是在同一个M中,因此无需加锁,提高调度效率。获取全局可运行G队列时,存在多线程竞争访问,因此需要加锁。

可运行G的交换关系如下:

  • injectglist:将G列表放入全局可运行G队列或P的本地可运行G队列。
  • globrunqget:将全局可运行G队列放入一批到P本地可运行G队列。
  • runqput:将G放入P的本地可运行G队列。
  • runqputslow:将P的本地可运行G队列放入一半到全局可运行G队列。
  • runqputbatch:将G列表放入P的本地可运行G队列,如果满了,则放入全局可运行G队列。
  • globrunqputbatch:将G列表放入全局可运行G队列。
  • runqget:从P本地可运行G队列获取一个G。
  • runqsteal:将P1本地可运行G队列放入一半到P2本地可运行G队列。
  • runqgrab:从P的本地可运行G队列获取获取一半G列表。
  • globrunqput:将G放入全局可运行G队列尾部。
  • globrunqputhead:将G放入全局可运行G队列头部。

3、系统监控

监控任务由runtime.main创建一个单独的线程启动,该线程不需要绑定P,执行函数为sysmon,该函数循环执行任务,主要包括:

  1. 将长时间未处理的netpoll结果添加到任务队列
  2. 超过2分钟没有GC,强制执行
  3. 收回因syscall长时间阻塞的P
  4. 向运行超过10ms的G任务发出抢占调度(go1.14引入抢占信号,之前版本是通过设置栈空间,在函数调用时判断栈溢出来实现抢占,这种方式无法抢占纯逻辑任务)。

执行流程如下:

4、任务调度

任务调度主要负责查找可运行G来执行任务,可运行G的来源主要包括:当前线程关联的P的本地可运行G队列、全局可运行G队列、网络连接中已就绪的可运行G、其他P中偷取的可运行G等。

除监控线程外,每个M都会运行一个调度任务,该调度任务在G0栈空间中运行,G0是在每个M初始化时创建的,栈空间大小默认为8K(其他G栈空间大小默认为2k)。

4.1 调度初始化

在执行调度任务之前需要完成一系列的初始化工作,包括栈空间池、处理器架构属性、创建P数组、绑定M0和P等。初始化由函数schedinit完成,调用关系如下所示:

  • stackinit:初始化全局栈空间池。
  • mallocinit:初始化内存管理。
  • mcommoninit:初始化当前M(此处是M0)相关属性,并加入全局M队列allm中。
  • cpuinit:初始化处理器架构相关属性。
  • gcinit:初始化GC相关属性。
  • procresize:调整P数量。
  • acquirep:绑定当前M和指定P,此处是M0和一个P。
  • pidleput:将指定P加入调度器的空闲P列表sched.pidle中。

4.2 调度流程

调度任务是由函数schedule完成,其执行流程如下:

调度函数schedule是由线程启动函数mstart触发,调用链如下:

  • mstart:线程启动的入口函数,系统创建新线程M时传入的就是该函数。
  • minit:初始化信号处理。
  • mstartm0:初始化M0的信号处理。
  • acquirep:绑定当前M和指定P。
  • schedule:调度函数,查找可运行G并运行。
  • traceReader:返回负责追踪的G(调用ReadTrace)。
  • findRunnableGCWorker:查找可运行GC的G。
  • globrunqget:从全局可运行G队列头部获取指定数量的G加入指定P的本地G队列中(schedule中调用globrunqget只是从全局队列获取一个G运行,避免全局队列产生饥饿)。
  • runqget:从指定P的本地可运行G队列中获取一个G。
  • findrunnable:竭尽所有可能查找可运行G,查不到会进入休眠状态。
  • wakep:唤醒一个M执行任务(针对找到了GC和trace的G的情况,唤醒的M、P不是执行找到的G )。
  • execute:在当前M中执行找到的G。
  • runqput:将G放入当前P的可运行G队列。
  • runqputslow:将P中本地G队列前一半放入全局G队列。
  • netpoll:从网络连接中获取已就绪的G列表。
  • injectglist:将可运行G列表加入运行队列(全局或当前P)。
  • runqsteal:从其他P的本地G队列获取一半的G到当前P的本地G队列。
  • globrunqputbatch:将可运行G列表加入全局可运行G队列中。
  • startm:获取M、P,准备执行任务。
  • runqputbatch:将可运行G列表加入指定P的本地G队列中。
  • runqgrab:从指定P的本地G队列获取一半G。
  • releasep:解绑当前M和P。
  • pidleput:将当前P放入全局P队列sched.pidle中。
  • stopm:停止当前M,进入休眠。
  • mput:将当前M放入全局空闲M队列sched.midle中。
  • mPark:当前M进入休眠。
  • gogo:汇编跳转到G的函数执行。

4.3 查找任务

在schedule中,最关键的部分是调用findrunnable,该函数主要用于竭尽所能的查找可运行的任务,执行流程如下:

4.4 调度循环

查看schedule的源码可以看到,该函数找到可运行G后调用execute函数,execute又调用汇编实现的gogo函数,该函数从调度运行的G0栈空间切换到找到的G栈空间,并通过JMP指令跳转到G绑定的函数执行,由于JMP并不会像CALL一样将下一条指令地址压栈,因此G函数执行完成后RET指令如何保证返回schedule?要搞清楚这个问题,可以看下创建G的函数newproc1,其中有这样一段代码:

newg.sched.sp = sp
newg.stktopsp = sp
newg.sched.pc = funcPC(goexit) + sys.PCQuantum
newg.sched.g = guintptr(unsafe.Pointer(newg))
gostartcallfn(&newg.sched, fn)
newg.gopc = callerpc
newg.ancestors = saveAncestors(callergp)
newg.startpc = fn.fn

关键部分在于函数gostartcallfn,该函数会将newg.sched.pc的初始值goexit地址压入G的栈顶,G执行完成后RET指令返回的就是goexit地址。查看goexit源码可以发现该函数主要完成如下事情:

  1. 将运行完的G放入空闲G队列等待复用(P本地队列或全局队列);
  2. 切换到G0栈空间调用schedule执行调度任务。

goexit调用链如下:

  • mcall:切换到G0栈空间,汇编实现。
  • dropg:移除M和当前G的关联。
  • gfput:将G放入P本地空闲G队列,如果P本地空闲G队列超过数量限制,会放入一半到全局空闲G队列。
  • schedule:任务调度。

 

标签: 暂无
最后更新:2022年8月12日

jemuel

这个人很懒,什么都没留下

点赞
< 上一篇
下一篇 >

文章评论

您需要 登录 之后才可以评论
文章目录
  • 导语
  • 1、程序启动引导
    • 1.1 程序入口地址
    • 1.2 程序启动流程
  • 2、MPG调度模型
    • 2.1 M模型
      • 2.1.1 M结构
      • 2.1.2 M运行情况
      • 2.1.3 唤醒M
      • 2.1.4 启动函数
    • 2.2 P模型
      • 2.2.1 P结构
      • 2.2.2 创建P
    • 2.3 G模型
      • 2.3.1 G结构
      • 2.3.2 创建G
      • 2.3.3 空闲G交换
      • 2.3.4 可运行G交换
  • 3、系统监控
  • 4、任务调度
    • 4.1 调度初始化
    • 4.2 调度流程
    • 4.3 查找任务
    • 4.4 调度循环
最新 热点 随机
最新 热点 随机
Volcano源码分析系列—调度篇 K8S源码分析系列1—搭建K8S调试集群 K8S Controller开发 6.5840 Lab 1: MapReduce MongoDB源码分析系列1——编译环境搭建 GraphQL介绍及使用
K8S Controller开发 MySQL源码分析系列4——MDL子系统 Go内存管理 GraphQL介绍及使用 MongoDB源码分析系列1——编译环境搭建 Golang优先级调度

COPYRIGHT © 2021 www.miaozhouguang.com. ALL RIGHTS RESERVED.

THEME KRATOS MADE BY VTROIS

粤ICP备2022006024号

粤公网安备 44030602006568号