Golang-Scheduler原理解析

本文主要分析Golang底层对于协程的调度原理,本文与Golang的memory allocation、garbage collection这两个主题是紧密相关的,本文scheduler作为系列的第一篇文章。
文章大体上的思路是这样的:
section1:主要图示和文字介绍scheduler的原理;
section2:主要模型的角度介绍scheduler原理;
section3:从主要调度流程介绍scheduler原理;
section4:分析scheduler与memory allocation、channel、garbage collection关联部分
基于源码 Go SDK 1.11

Section1 Scheduler原理

1.基础知识

Golang支持语言级别的并发,并发的最小逻辑单位叫做goroutine,goroutine就是Go为了实现并发提供的用户态线程,这种用户态线程是运行在内核态线程(OS线程)之上。当我们创建了大量的goroutine并且同时运行在一个或则多个内核态线程上时(内核线程与goroutine是m:n的对应关系),就需要一个调度器来维护管理这些goroutine,确保所有的goroutine都有相对公平的机会使用CPU。

这里再次强调一次,goroutine与内核OS线程的映射关系是M:N,这样多个goroutine就可以在多个内核线程上面运行。goroutine的切换大部分场景下都没有走OS线程的切换所带来的开销,这样整体运行效率相比OS线程的调度会高很多,但是这样带来的问题就是goroutine调度模型的复杂。

2.调度模型

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

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

为了解释清楚这几个实体之间的关系,我们先抽象G、M、P、schedt的关系,主要的workflow如下图所示:
scheduler 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。

Go运行时会在下面的goroutine被阻塞的情况下运行另外一个goroutine:

  • syscall、
  • network input、
  • channel operations、
  • primitives in the sync package。

3.调度核心问题

前面已经大致介绍了scheduler的一些核心调度原理,介绍的都是比较抽象的内容。听下来还有几个疑问需要分析,主要通过后面的源码来进行细致分析。

Question1:如果一个goroutine一直占有CPU又不会有阻塞或则主动让出CPU的调度,scheduler怎么做抢占式调度让出CPU?
Answer1:有一个sysmon线程做抢占式调度,当一个goroutine占用CPU超过10ms之后,调度器会根据实际情况提供不保证的协程切换机制,具体细节见后面源码分析。

Question2:我们知道P的上限是系统启动时候设定的并且一般不会更改,那么内核线程M上限是多少?遇到需要新的M时候是选取IDEL的M还是创建新的M,整体策略是怎样的?
Answer2:在golang系统启动时候会设置内核线程上限是10000,这里先解释一下,M的数量与P的数量和G的数量没有直接关系,实际情况要看调度器的执行情况。
至于具体的策略见后面的源码分析。

Question3:P、M、G的状态机运转,主要是协程对象G。
Answer3:状态机见后面的源码分析

Question4:每一个协程goroutine的栈空间是保存在哪里的?P、M、G分别维护的与内存有关的数据有哪些?
Answer4:golang scheduler use thrid-level cache,each goroutine stack space is applied in heap。

Question5:当syscall、网络IO、channel时,如果这些阻塞返回了,对应的G会被保存在哪个地方?global Queue或则是local queue? 为什么?系统初始化时候的G会被保存在哪里?为什么?
Answer5:唤醒的M首先会尝试获取一个空闲P,然后将G放到P的local queue,如果获取失败,就放进 global queue,然后M自己放进the M idle 列表。

Notice:scheduler的源码主要在两个文件:

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

Section2 主要模型的源码分析

这一部分主要结合源码进行分析,主要分为两个部分,一部分介绍主要模型G、M、P、schedt的职责、维护的数据域以及它们的联系。

2.1 实体M

实体M在模型上等同于系统内核OS线程,M运行的go代码类型有两种:

  • goroutine代码, M运行go代码需要一个实体P进行局部调度;
  • 原生代码, 例如阻塞的syscall, M运行原生代码不需要P

M会从runqueue(local or global)中抽取G并运行,如果G运行完毕或则G进入了睡眠态,就会从runqueue中取出下一个runnable状态的G运行, 循环调度。

G有时会执行一些阻塞调用(syscall),这时M会释放持有的P并进入阻塞态,其他的M会取得这个idel状态的P并继续运行队列中的G。Golang需要保证有足够的M可以运行G, 不让CPU闲着, 也需要保证M的数量不能过多。通常创建一个M的原因是由于没有足够的M来关联P并运行其中可运行的G。而且运行时系统执行系统监控的时候,或者GC的时候也会创建M。

M结构体定义在runtime2.go如下:

type m struct {
   
	/*
        1.  所有调用栈的Goroutine,这是一个比较特殊的Goroutine。
        2.  普通的Goroutine栈是在Heap分配的可增长的stack,而g0的stack是M对应的线程栈。
        3.  所有与调度相关的代码,都会先切换到g0的栈再执行。
    */
	g0      *g     // goroutine with scheduling stack
	morebuf gobuf  // gobuf arg to morestack
	divmod  uint32 // div/mod denominator for arm - known to liblink

	// Fields not known to debuggers.
	procid        uint64       // for debuggers, but offset not hard-coded
	gsignal       *g           // signal-handling g
	goSigStack    gsignalStack // Go-allocated signal handling stack
	sigmask       sigset       // storage for saved signal mask
	tls           [6]uintptr   // thread-local storage (for x86 extern register)
	// 表示M的起始函数。其实就是我们 go 语句携带的那个函数。
	mstartfn      func()
	// M中当前运行的goroutine
	curg          *g       // current running goroutine
	caughtsig     guintptr // goroutine running during fatal signal
	// 与M绑定的P,如果为nil表示空闲
	p             puintptr // attached p for executing go code (nil if not executing go code)
	// 用于暂存于当前M有潜在关联的P。 (预联)当M重新启动时,即用预联的这个P做关联啦
	nextp         puintptr
	id            int64
	mallocing     int32
	throwing      int32
	// 当前m是否关闭抢占式调度
	preemptoff    string // if != "", keep curg running on this m
	/**  */
	locks         int32
	dying         int32
	profilehz     int32
	// 不为0表示此m在做帮忙gc。helpgc等于n只是一个编号
	helpgc        int32
	// 自旋状态,表示当前M是否正在自旋寻找G。在寻找过程中M处于自旋状态。
	spinning      bool // m is out of work and is actively looking for work
	blocked       bool // m is blocked on a note
	inwb          bool // m is executing a write barrier
	newSigstack   bool // minit on C thread called sigaltstack
	printlock     int8
	incgo         bool   // m is executing a cgo call
	freeWait      uint32 // if == 0, safe to free g0 and delete m (atomic)
	fastrand      [2]uint32
	needextram    bool
	traceback     uint8
	ncgocall      uint64      // number of cgo calls in total
	ncgo          int32       // number of cgo calls currently in progress
	cgoCallersUse uint32      // if non-zero, cgoCallers in use temporarily
	cgoCallers    *cgoCallers // cgo traceback if crashing in cgo call
	park          note
	//这个域用于链接allm
	alllink       *m // on allm
	schedlink     muintptr
	mcache        *mcache
	// 表示与当前M锁定的那个G。运行时系统会把 一个M 和一个G锁定,一旦锁定就只能双方相互作用,不接受第三者。
	lockedg       guintptr
	createstack   [32]uintptr    // stack that created this thread.
	lockedExt     uint32         // tracking for external LockOSThread
	lockedInt     uint32         // tracking for internal lockOSThread
	nextwaitm     muintptr       // next m waiting for lock
	waitunlockf   unsafe.Pointer // todo go func(*g, unsafe.pointer) bool
	waitlock      unsafe.Pointer
	waittraceev   byte
	waittraceskip int
	startingtrace bool
	syscalltick   uint32
	thread        uintptr // thread handle
	freelink      *m      // on sched.freem

	// these are here because they are too large to be on the stack
	// of low-level NOSPLIT functions.
	libcall   libcall
	libcallpc uintptr // for cpu profiler
	libcallsp uintptr
	libcallg  guintptr
	syscall   libcall // stores syscall parameters on windows

	vdsoSP uintptr // SP for traceback while in VDSO call (0 if not in call)
	vdsoPC uintptr // PC for traceback while in VDSO call

	mOS
}

上面字段很多,核心的主要是以下几个字段:

g0      *g     // goroutine with scheduling stack,也是运行局部调度器的g
mstartfn      func()
curg          *g       // current running goroutine
p             puintptr // attached p for executing go code (nil if not executing go code)
nextp         puintptr
helpgc        int32
spinning      bool // m is out of work and is actively looking for work
alllink       *m // on allm
lockedg       guintptr

这些字段主要功能如下:

  • g0: Golang runtime系统在线程创建的时候创建的,g0的栈使用的是内核线程的栈,主要用于局部调度器执行调度逻辑时使用的栈,也就是执行调度逻辑时的线程栈。
  • mstartfn:表示M的起始函数。其实就是我们 go 关键字后面携带的那个函数。
  • curg:存放当前正在运行的G的指针。
  • p:指向当前与M关联的那个P。
  • nextp:用于暂存于当前M有潜在关联的P。 (预联)当M重新启动时,即用预联的这个P做关联啦
  • spinning:自旋状态标志位,表示当前M是否正在寻找G。
  • alllink:连接到所有的m链表的一个指针。
  • lockedg:表示与当前M锁定的那个G。运行时系统会把 一个M 和一个G锁定,一旦锁定就只能双方相互作用,不接受第三者。

M的状态机比较简单,因为M是golang对内核OS线程的更上一层抽象,所以M也没有专门字段来维护状态,简单来说有一下几种状态:

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

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

M在被创建之初会被加入到全局的M列表 【runtime.allm】。 接着,M的起始函数(mstartfn)和准备关联的P都会被设置。最后,runtime会为M专门创建一个新的内核线程并与之关联。这时候这个新的M就为执行G做好了准备。其中起始函数(mstartfn)仅当runtime要用此M执行系统监控或者垃圾回收等任务的时候才会被设置。【runtime.allm】的作用是runtime在需要的时候会通过它获取到所有的M的信息,同时防止M被gc。

在新的M被创建后会做一些初始化工作。其中包括了对自身所持的栈空间以及信号的初始化。在上述初始化完成后 mstartfn 函数就会被执行 (如果存在的话)。【注意】:如果mstartfn 代表的是系统监控任务的话,那么该M会一直在执行mstartfn 而不会有后续的流程。否则 mstartfn 执行完后,当前M将会与那个准备与之关联的P完成关联。至此,一个并发执行环境才真正完成。之后就是M开始寻找可运行的G并运行之。

runtime管辖的M会在GC任务执行的时候被停止,这时候系统会对M的属性做某些必要的重置并把M放置入全局调度器的空闲M列表。【很重要】因为调度器在需要一个未被使用的M时,运行时系统会先去这个空闲列表获取M。(只有都没有的时候才会创建M)

M本身是无状态的。M是否是空闲态仅根据它是否存在于调度器的空闲M列表 【runtime.sched.midle】 中来判定(注意:空闲列表不是那个全局列表)。

单个Go程序所使用的M的最大数量是可以被设置的。在我们使用命令运行Go程序时候,有一个引导程序先会被启动的。在这个引导程序中会为Go程序的运行建立必要的环境。引导程序对M的数量进行初始化设置,默认最大值是10000【一个Go程序最多可以使用10000个M,即:理想状态下,可以同时有1W个内核线程被同时运行】。可以使用 runtime/debug.SetMa

  • 17
    点赞
  • 48
    收藏
    觉得还不错? 一键收藏
  • 12
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值