一个EOF引发的探索之路之五(goroutine的调度原理尝试解释篇)

前言
刚来公司时候并没有接触过golang,对于线程和进程的概念也只是从课本上了解过,对协程也只是在使用python的时候接触过,也是停留在了解概念的层次。借此了解golang网络轮询器的机会,把自己以前所有有关此类的问题都提出来。

一、进程、内核线程、轻量级进程、用户线程之间的关联与区别
先来列出大家很熟悉的几句话。

1.1只有进程的年代
在操作系统初期,只有进程的概念,当多个进程提交给系统时,只能串行的执行,当任务多了之后,这种串行执行肯定不能被接受,因为任务是有优先级的,于是有了多进程的并发调度,多个进程被系统采用某种调度算法轮替使用CPU。

1.2线程的出现
由于多进程竞争CPU会导致进程的上下文切换(对于CPU来说),进程的切换消耗的资源太大了(稍后会介绍有多大),有些任务可能是非常小的任务,总不能给这些任务单独创建进程吧,于是就有了更小粒度的并发调度的需求,线程就是为之诞生,我们把这些很小并且需要并发执行的任务作为一个线程,每个进程会有多个线程,这些线程会直接参与CPU的竞争(也就是所谓的系统调度,注意这里说的线程是内核线程,并非用户线程,稍后会详细介绍),线程的切换相对于进程对于系统来说消耗的资源要小很多,进程内部的线程的切换不会引起进程的切换,但是如果进程之间的线程的切换还是会引起进程的切换。

1.3线程模型以及内核线程与用户线程的区别
随着多核系统的出现,人们对于并发的需求越来越强烈。这就出现了三种不同的线程模型,这里的线程模型指的是内核线程与用户线程的比例,先来介绍下二者的区别:

用户线程:
用户线程是完全建立在用户空间的线程库,用户线程的创建、调度、同步和销毁全由库函数在用户空间完成,不需要内核的帮助。因此这种线程是极其低消耗和高效的。
处理器竞争:单纯的用户线程是建立在用户空间,其对内核是透明的,因此其所属进程单独参与处理器的竞争,而进程的所有线程参与竞争该进程的资源。
使用资源:与所属进程共享进程地址空间和系统资源。
调度:由在用户空间实现的线程库,在所属进程内进行调度

内核线程:
内核线程只运行在内核态,不受用户态上下文的拖累。
处理器竞争:可以在全系统范围内竞争处理器资源;
使用资源:唯一使用的资源是内核栈和上下文切换时保持寄存器的空间
调度:调度的开销可能和进程自身差不多昂贵
同步效率:资源的同步和数据共享比整个进程的数据同步和共享要低一些。

了解完二者区别之后,我们再来看下三种不同的线程模型:

N:1模型
在这里插入图片描述

所谓N:1 指的是多个用户线程对应一个内核线程或者一个进程(说法不一,总之就是多个用户线程对应一个竞争CPU的对象),而这些用户线程不会直接参与系统的CPU的竞争,他们的CPU资源都是来自它们对应的内核线程参与系统调度所得的CPU资源,而这些用户线程再去竞争内核线程提供的CPU资源,至于竞争的方式是由线程库来管理,并不需要内核进行调度。对于该进程来说,任何时刻都只有一个用户线程在执行,所以,如果某个用户线程发生阻塞,那么该进程或者改进程对应的内核线程会被阻塞,那么整个进程的其他用户线程就没有内核线程可执行(其实也就是没有CPU资源)。而且这种模型当遇到多核的处理器的时候,单个进程是无法有效的利用CPU资源的。

1:1模型:
其实对于内核线程的出现,是在最初就有还是伴随着1:1模型才出现,我也不得而知。
在这里插入图片描述

看到图中有一个LWP,它叫轻量级进程,我们先来解释下,轻量级线程(LWP)是一种由内核支持的用户线程。它是基于内核线程的高级抽象,因此只有先支持内核线程,才能有LWP。每一个进程有一个或多个LWP,每个LWP由一个内核线程支持。实际上它的建立、析构以及同步,都需要进行系统调用,系统调用的代价相对较高:需要在user mode和kernel mode中切换。其次,每个LWP都需要有一个内核线程支持,因此LWP要消耗内核资源(内核线程的栈空间)。你可以认为对于系统来说一个LWP的代价就跟一个内核线程的代价差不太多。

所谓1:1,其实就是用户线程与内核线程是1:1,我们来对比下N:1看下它的优点以及缺点:
1.)当进程中的某个线程发生阻塞,并不会影响其他的线程参与CPU的竞争。
2.)一个进程可以占用多个CPU资源(多个内核线程)
3.)一个进程中,并不能创建太多的LWP(原因就是LWP绑定的内核线程消耗内核资源),而且进程的线程切换,会引起内核的切换,消耗的资源相对于N:1模型中的用户线程的切换太大,需要从用户态转到内核态。

M:N模型
在这里插入图片描述

结合了1:1模型与N:1模型的优点,提出M:N模型,如图所示,多个用户线程对应多个LWP,最终也就是对应多个内核线程。我们来对比下其他的模型,看下其优点:

1.)相对于N:1模型,单个进程运行在了多个内核线程上,也就是说单个进程可以占用多个CPU,这对于多核CPU的利用率是高效的。
2.)当进程中的某个用户线程发生阻塞,它也只会阻塞某个LWP或者某个内核线程,不会影响其他用户线程的执行(因为还有其他的LWP)。
3.)大部分CPU切换都是用户线程的切换,而用户线程的切换是不需要系统参与的,消耗的资源也非常少。
4.)由于用户线程是在用户空间创建,并不需要消耗内核空间,所以是可以创建任意多个,更好的支持并发性。

缺点:
用户线程的调度太复杂,需要用户程序自己调度。

二、什么是上下文,进程、线程切换的开销有哪些?
2.1什么是上下文?

我们经常说进程或者线程的切换需要切换上下文,那么上下文到底是什么呢?

对于进程上下文,就是在执行时,CPU的所有的寄存器中保存值、进程的状态以及堆栈上的内容,当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的进程上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。

一个进程的上下文可以分为三个部分:用户级上下文、寄存器上下文以及系统级上下文。

用户级上下文: 正文、数据、用户堆栈以及共享存储区;
寄存器上下文: 通用寄存器、程序寄存器(IP)、处理器状态寄存器(EFLAGS)、栈指针(ESP);
系统级上下文: 进程控制块task_struct、内存管理信息(mm_struct、vm_area_struct、pgd、pte)、内核栈。

当发生进程调度时,进行进程切换就是上下文切换(context switch)。
而对于线程来说,我们知道进程内的线程的之间是共享内存等资源的,所以进程内的线程的上下文之包括了
各种寄存器的值以及内核栈信息。

2.2进程或者线程的切换开销
对于进程和线程他们都需要切换各自的上下文,但是对于进程来说,在拥有高级内存管理技术的系统中,进程的切换需要额外的消耗,我们在现代操作系统中,不一定所有的进程都会被放入进程等待执行,系统根据某个策略把部分的进程放入内存,这是为了合理的利用内存资源。当某个进程的CPU时间片使用完,或者其他原因,会被暂时从内存中换出到备份的存储中(也许是磁盘),当再需要执行的时候回被重新调回到内存中取。所以这个过程有进程从内存换出到磁盘的过程,这个消耗应该也是很大的。

注意:无论是进程的切换还是线程的切换都需要从用户态转到内核态才能进行。

2.3协程的开销呢?

协程/用户线程 切换开销方面,协程远比线程小

线程:涉及模式切换(从用户态切换到内核态)、16个寄存器、PC、SP…等寄存器的刷新等。

goroutine:只有三个寄存器的值修改 – PC / SP / DX.

注意:协程的调度是由用户程序进行的,所以不需要切换到内核态。

2.4目前的linux中的进程和线程

实际上目前的linux的进程和线程的概念是比较模糊的,对于系统的调度来说,他们都是task,我们所说的线程实际上是轻量级进程,当你通过fork创建一个进程的时候,创建的是一个我们所熟知的普通进程,这个时候它占用了一个内核线程,对于linux来说它就是一个task,但是如果在该进程中通过clone()创建一个跟当前进程共享资源的轻量级进程的时候,他们就没有进程的概念了,二者是两个轻量级的进程,他们是一个进程组,这个进程组就对应到了我们的普通进程的概念了,实际上他们都是有PID的,但是当你查看应用进程的PID的时候,返回的实际上是该进程组的ID叫PGID,这个PGID实际上是刚开始创建的时候第一个轻量级进程的PID,这些轻量级进程对于系统来说都是独立的调度单元。

总之一句话,当有共享的轻量级进程的时候,他们都叫线程(又叫轻量级进程),合在一起是一个进程组而已,当独占资源的时候,这个轻量级进程就对标到我们立即的进程的概念了。

三、goroutine的调度原理

终于要说goroutine的调度原理了,好激动。
我们上述说到,目前的线程模型有三种,其中golang在语言层面的支持了第三种模式M:N模型。goroutine习惯被我们成为协程,其实就是用户线程,下面详细说下原理,一下原理内容整理自网络并加上自己理解:

goroutine的调度有三个基本的元素,M表示系统线程,可以对标到轻量级线程,P表示调度的上下文,注意网上对P的接受有些时候不是很对,我们说的调度的上下文,是针对goroutine的调度器来说的,调度器通过判断P内存储的内容来实施不同的调度操作(稍后更详细说明),G就代表goroutine。我们如下图表示(你在网上经常能看到这个图):
在这里插入图片描述
三者之间的关系如下图所示
在这里插入图片描述
下面我们尝试清晰的解释下goroutine到底是怎么调度的。

1.)每一个M必须要有绑定一个P才能执行,其中P的数量是有限的,一般设置为系统CPU核数,这样是为了保证最多同时有CPU核数个M在跑,这样也就大大降低了CPU的切换频率,通过GOMAXPROCS()来设置P的数量(想起来第一次看go的代码,看到main函数中调用这一句话的时候一脸懵逼的样子了)

2.)P中存储着其对应的待执行的G的列表,我们来看下其对应的数据结构

type p struct {
 ...
 runnext guintptr
 ...
}

runnext是下一个执行的G的指针,网上有把P叫做处理器,我觉得不太合适,虽然P的数量的控制一般是为了控制并发数,但它毕竟不是执行体,还有人把它叫做调度器的本地版本,我个人觉得也不对,它实际不是调度器,它只是为调度器提供一个它当前的情况(包括它存储的列表中G的数量等等)来让调度器进行决策如何调度,所以把它叫做一个调度的上下文比较合适些。

3.)我们说了半天的调度,到底是谁在调度呢?这就涉及到golang程序启动的初始化了。golang程序在进入main函数之前,会进行个各种的初始化,其中就有一项是初始化调度器,runtime·schedinit(SB)。这个初始化函数干了什么呢?直接看源码吧。

func schedinit() {
 // raceinit must be the first call to race detector.
 // In particular, it must be done before mallocinit below calls racemapshadow.
 _g_ := getg()
 if raceenabled {
 _g_.racectx, raceprocctx0 = raceinit()
 }
 //系统线程个数最大数量为10000,太大会消耗过多的内核资源
 sched.maxmcount = 10000
 
 tracebackinit()
 moduledataverify()
 stackinit()
 mallocinit()
 mcommoninit(_g_.m)
 alginit()       // maps must not be used before this call
 //栈,内存分配器,调度器相关初始化
 modulesinit()   // provides activeModules
 typelinksinit() // uses maps, activeModules
 itabsinit()     // uses activeModules
 
 msigsave(_g_.m)
 initSigmask = _g_.m.sigmask
 
 goargs()
 goenvs()
 parsedebugvars()
 gcinit()
 
 sched.lastpoll = uint64(nanotime())
 procs := ncpu
 //注意这里根据用户设置的GOMAXPROCS和实际的CPU核数来确定最终的P的个数
 if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
 procs = n
 }
 if procs > _MaxGomaxprocs {
 procs = _MaxGomaxprocs
 }
 if procresize(procs) != nil {
 throw("unknown runnable goroutine during bootstrap")
 }
 
 if buildVersion == "" {
 // Condition should never trigger. This code just serves
 // to ensure runtime·buildVersion is kept in the resulting binary.
 buildVersion = "unknown"
 }
}

初始化调度器后,接着会调用runtime·newproc()创建了一个协程,这是我们的golang程序的主协程,然后把该协程放在当前线程上进行执行(这个线程何处来,这里卖个关子,稍后说),这个协程执行的内容就是runtime.main()函数,注意这个main函数并不是我们用户程序main函数入口喔,看一眼这个函数都干了什么?

func main() {
 g := getg()
 //执行栈的最大内存
 if sys.PtrSize == 8 {
 maxstacksize = 1000000000
 } else {
 maxstacksize = 250000000
 }
 //启动系统监控
 systemstack(func() {
 newm(sysmon, nil)
 })
 lockOSThread()
 
 if g.m != &m0 {
 throw("runtime.main not on m0")
 }
 //执行的是runtime包的init函数
 runtime_init() // must be before defer
 
 needUnlock := true
 defer func() {
 if needUnlock {
 unlockOSThread()
 }
 }()
 //启动垃圾回收
 gcenable()
 
 main_init_done = make(chan bool)
 if iscgo {
 if _cgo_thread_start == nil {
 throw("_cgo_thread_start missing")
 }
 if GOOS != "windows" {
 if _cgo_setenv == nil {
 throw("_cgo_setenv missing")
 }
 if _cgo_unsetenv == nil {
 throw("_cgo_unsetenv missing")
 }
 }
 if _cgo_notify_runtime_init_done == nil {
 throw("_cgo_notify_runtime_init_done missing")
 }
 cgocall(_cgo_notify_runtime_init_done, nil)
 }
 
 fn := main_init 
 //执行用户程序的所有的init函数(包括标准库和用户包)
 fn()
 close(main_init_done)
 needUnlock = false
 unlockOSThread()
 if isarchive || islibrary {
 return
 }
 fn = main_main 
 //执行用户main函数(这才是我们的经常说的main函数入口)
 fn()
 if raceenabled {
 racefini()
 }
 if panicking != 0 {
 gopark(nil, nil, "panicwait", traceEvGoStop, 1)
 }
 exit(0)
 for {
 var x *int32
 *x = 0
 }
}

这个mian函数里面执行了一些初始化操作,而且启动了一个监控线程,这个线程的任务就是承担着用户协程的全局调度任务,事实上整个调度是由“监控线程+M+P”组成,稍后我们详细说下这个监控线程是如何进行调度的。我们看到接着启动了垃圾回收、执行init包,最后开始执行main函数,注意这个main函数才是我们自己写的那个main函数。

4.)ok我们现在知道到底是谁在调度了,那么我们继续研究在调度什么。
首先来看下那个监控线程干了什么?

func sysmon() {
 //回收长时间不适用的堆栈资源
 scavengelimit := int64(5 * 60 * 1e9)
 
 if debug.scavenge > 0 {
 forcegcperiod = 10 * 1e6
 scavengelimit = 20 * 1e6
 }
 
 lastscavenge := nanotime()
 nscavenge := 0
 
 lasttrace := int64(0)
 //每轮调度的sleep时间
 idle := 0 
 delay := uint32(0)
 for {
 if idle == 0 {
 delay = 20
 } else if idle > 50 { 
 delay *= 2
 }
 if delay > 10*1000 { 
 delay = 10 * 1000
 }
 usleep(delay)
 if debug.schedtrace <= 0 && (sched.gcwaiting != 0 || atomic.Load(&sched.npidle) == uint32(gomaxprocs)) {
 lock(&sched.lock)
 if atomic.Load(&sched.gcwaiting) != 0 || atomic.Load(&sched.npidle) == uint32(gomaxprocs) {
 atomic.Store(&sched.sysmonwait, 1)
 unlock(&sched.lock)
 maxsleep := forcegcperiod / 2
 if scavengelimit < forcegcperiod {
 maxsleep = scavengelimit / 2
 }
 notetsleep(&sched.sysmonnote, maxsleep)
 lock(&sched.lock)
 atomic.Store(&sched.sysmonwait, 0)
 noteclear(&sched.sysmonnote)
 idle = 0
 delay = 20
 }
 unlock(&sched.lock)
 }
 
 lastpoll := int64(atomic.Load64(&sched.lastpoll))
 now := nanotime()
 unixnow := unixnanotime()
 //距离上一轮调用netpoll超过10ms就来一次
 if lastpoll != 0 && lastpoll+10*1000*1000 < now {
 //设置本次调度的时间
 atomic.Cas64(&sched.lastpoll, uint64(lastpoll), uint64(now))
 //已非阻塞的方式获取已经ready的goroutine
 gp := netpoll(false) 
 if gp != nil {
 incidlelocked(-1)
 //把这些goroutine重新放入待执行队列
 injectglist(gp)
 incidlelocked(1)
 }
 }
 //1.抢占式调度那些长期执行不放CPU的goroutine
 //2.把那些进入系统调用的P给抢过来,让其他M执行P里面的goroutine
 if retake(now) != 0 {
 idle = 0
 } else {
 idle++
 }
 //GC部分,不在本文关注之内
 lastgc := int64(atomic.Load64(&memstats.last_gc))
 if gcphase == _GCoff && lastgc != 0 && unixnow-lastgc > forcegcperiod && atomic.Load(&forcegc.idle) != 0 {
 lock(&forcegc.lock)
 forcegc.idle = 0
 forcegc.g.schedlink = 0
 injectglist(forcegc.g)
 unlock(&forcegc.lock)
 }
 if lastscavenge+scavengelimit/2 < now {
 mheap_.scavenge(int32(nscavenge), uint64(now), uint64(scavengelimit))
 lastscavenge = now
 nscavenge++
 }
 if debug.schedtrace > 0 && lasttrace+int64(debug.schedtrace)*1000000 <= now {
 lasttrace = now
 schedtrace(debug.scheddetail > 0)
 }
 }
}

其实监控线程有关调度的内容就做了两件事情
1.调用netpoll读取当前ready的socket所对应的goroutine,然后把他们加入到全局的等待执行的goroutine队列中,并且如果当前有空闲的P,会调用startm()直接唤醒sleep的M或者新建M来执行这些goroutine

看下injectglist的源码

func injectglist(glist *g) {
 if glist == nil {
 return
 }
 if trace.enabled {
 for gp := glist; gp != nil; gp = gp.schedlink.ptr() {
 traceGoUnpark(gp, 0)
 }
 }
 lock(&sched.lock)
 var n int
 for n = 0; glist != nil; n++ {
 gp := glist
 glist = gp.schedlink.ptr()
 casgstatus(gp, _Gwaiting, _Grunnable)
 globrunqput(gp)
 }
 unlock(&sched.lock)
 for ; n != 0 && sched.npidle != 0; n-- {
 startm(nil, false)
 }
}

2.抢占式调度那些正在执行的goroutine,如果有些goroutine需要占用大量的CPU进行执行,总不能让它一直用吧(这也类似于操作系统对进程或者内核线程的调度一样),后面的goroutine都在嗷嗷待哺,还有一些P所对应的M在执行的过程中进行了系统调用,那不行啊,后面的goroutine都等着呢,必须把它手里的P给抢过来。怎么抢的P呢,监控线程调用retake()函数,我们来看看,它是怎么做的

func retake(now int64) uint32 {
 n := 0
 //扫描所有的P
 for i := int32(0); i < gomaxprocs; i++ {
 _p_ := allp[i]
 if _p_ == nil {
 continue
 }
 pd := &pdesc[i]
 s := _p_.status
 if s == _Psyscall {
 //如果P当前的状态是系统调用
 t := int64(_p_.syscalltick)
 if int64(pd.syscalltick) != t {
 pd.syscalltick = uint32(t)
 pd.syscallwhen = now
 continue
 }
 //检查下是否需要抢P
 if runqempty(_p_) && atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 && pd.syscallwhen+10*1000*1000 > now {
 continue
 }
 incidlelocked(-1)
 if atomic.Cas(&_p_.status, s, _Pidle) {
 if trace.enabled {
 traceGoSysBlock(_p_)
 traceProcStop(_p_)
 }
 n++
 _p_.syscalltick++
 //抢P
 handoffp(_p_)
 }
 incidlelocked(1)
 } else if s == _Prunning {
 //如果是正在运行的P,看运行的时间是不是太长了
 t := int64(_p_.schedtick)
 if int64(pd.schedtick) != t {
 pd.schedtick = uint32(t)
 pd.schedwhen = now
 continue
 }
 if pd.schedwhen+forcePreemptNS > now {
 continue
 }
 //太长了,抢P
 preemptone(_p_)
 }
 }
 return uint32(n)
}

handoffp()通过调用startm会把P传给别的M(M有可能是新建的,也可能是已有的),当前的M系统调用结束后发现手里没有P了,它就没办法继续执行了,那么它又会有什么操作呢?稍后说。preemptone()抢P的方式就不太一样了,它抢P的原因是因为当前协程占着M的时间太长,它所谓的抢P其实就把当前协程给硬生生的从M中拽出来,然后把协程再重新放到P的队列中,让M继续执行下一个协程,那么它是怎么拽的呢?实际上他是通过给当前协程上设置了一个抢占的标志,一旦协程执行函数的时候就会去检查这个标志,如果发现是抢占的,它就主动的让出CPU,然后会回到全局的G队列中,代码中的体现是:调用casgstatus()把当前协程放回全局队列,最终会调用schedule()继续执行下一个协程。

5.)在4.)中我们看到的都是监控线程主动触发的调度,有没有被动的调度事件呢?
在4.)中我们说到监控线程在进行调度的时候,实际使用调用的是三个函数injectglist()、handoffp()、preemptone(),其中injectglist()和handoffp()最终都是调用startm()来分配新的M,preemptone比较特殊是通过给G插入抢占标志,不过最后会调用schedule(),我们先看前两个函数共同调用的startm()干了什么?

func startm(_p_ *p, spinning bool) {
 lock(&sched.lock)
 .........
 mp := mget()
 unlock(&sched.lock)
 if mp == nil {
 .........
 newm(fn, _p_)
 return
 }
 .........
 mp.spinning = spinning
 mp.nextp.set(_p_)
 notewakeup(&mp.park)
}

看到没,他会先调mget()获取一个M(这个获取的方式应该类似从线程池中获取一个sleep的线程),如果线程池中没有空闲的线程,会调用newm()函数新建一个,看下它干了什么

func newm(fn func(), _p_ *p) {
 mp := allocm(_p_, fn)
 mp.nextp.set(_p_)
 mp.sigmask = initSigmask
 if iscgo {
 var ts cgothreadstart
 if _cgo_thread_start == nil {
 throw("_cgo_thread_start missing")
 }
 ts.g.set(mp.g0)
 ts.tls = (*uint64)(unsafe.Pointer(&mp.tls[0]))
 //这里是重点,创建一个新的M后会执行mstart
 ts.fn = unsafe.Pointer(funcPC(mstart))
 if msanenabled {
 msanwrite(unsafe.Pointer(&ts), unsafe.Sizeof(ts))
 }
 asmcgocall(_cgo_thread_start, unsafe.Pointer(&ts))
 return
 }
 newosproc(mp, unsafe.Pointer(mp.g0.stack.hi))
}


func mstart() {
 _g_ := getg()
 .........
 mstart1()
}
func mstart1() {
 _g_ := getg()
 ...........
 //这里是重点
 schedule()
}

我们看到实际最终掉的是schedule()函数,实际上如果是用已有的M,会直接调用notewakeup()来唤醒M,最终调用的也是schedule()。

到这里我们看到injectglist()、handoffp()、preemptone()三个函数最终调用的都是schedule()来实现调度,那这个函数我们得好好分析下了。

func schedule() {
 _g_ := getg()
 
 if _g_.m.locks != 0 {
 throw("schedule: holding locks")
 }
 
 if _g_.m.lockedg != nil {
 stoplockedm()
 execute(_g_.m.lockedg, false) // Never returns.
 }
 
top:
 //等待GC
 if sched.gcwaiting != 0 {
 gcstopm()
 goto top
 }
 if _g_.m.p.ptr().runSafePointFn != 0 {
 runSafePointFn()
 }
 
 var gp *g
 var inheritTime bool
 if trace.enabled || trace.shutdown {
 gp = traceReader()
 if gp != nil {
 casgstatus(gp, _Gwaiting, _Grunnable)
 traceGoUnpark(gp, 0)
 }
 }
 //获取GC任务
 if gp == nil && gcBlackenEnabled != 0 {
 gp = gcController.findRunnableGCWorker(_g_.m.p.ptr())
 }
 //这里有意思了,每当处理61个G就会调用globrunqget从全局队列从读取G
 if gp == nil {
 if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
 lock(&sched.lock)
 gp = globrunqget(_g_.m.p.ptr(), 1)
 unlock(&sched.lock)
 }
 }
 //这里才是从该M的绑定的本地P里面存的G队列中读取一个G
 if gp == nil {
 gp, inheritTime = runqget(_g_.m.p.ptr())
 if gp != nil && _g_.m.spinning {
 throw("schedule: spinning with local work")
 }
 }
 //本地没了,调用findrunnable()实际是在通过其他方式获取可用的G
 if gp == nil {
 gp, inheritTime = findrunnable() // blocks until work is available
 }
 if _g_.m.spinning {
 resetspinning()
 }
 if gp.lockedm != nil {
 startlockedm(gp)
 goto top
 }
 //开始执行G
 execute(gp, inheritTime)
}

我们先来看看findrunnable()这个函数,通过看源码我们发现它获取的来源是其他的P、全局G队列,poll network,真是无所不用啊,他会一直等待有可执行的G,即使是sleep也在所不辞。
我们来看下

func findrunnable() (gp *g, inheritTime bool) {
 _g_ := getg()
 ........
top:
 _p_ := _g_.m.p.ptr()
 ........
 // 看本地的有没有G
 if gp, inheritTime := runqget(_p_); gp != nil {
 return gp, inheritTime
 }
 //看全局队列
 if sched.runqsize != 0 {
 lock(&sched.lock)
 //有的话就取呗
 gp := globrunqget(_p_, 0)
 unlock(&sched.lock)
 if gp != nil {
 return gp, false
 }
 }
 //从netpoll那看看有没有ready的G
 if netpollinited() && sched.lastpoll != 0 {
 if gp := netpoll(false); gp != nil { // non-blocking
 injectglist(gp.schedlink.ptr())
 casgstatus(gp, _Gwaiting, _Grunnable)
 if trace.enabled {
 traceGoUnpark(gp, 0)
 }
 return gp, false
 }
 }
 //从其他的P那搞点
 //这个地方很有意思
 for i := 0; i < 4; i++ {
 //这个地方就有意思了,一共尝试了4轮,每一轮循环结束的标志除了搞到了G,就是!enum.done(),稍后我们看下这里的代码
 for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
 if sched.gcwaiting != 0 {
 goto top
 }
 stealRunNextG := i > 2 
 //allp就是所有的P的队列
 //runqsteal如果搞成功的话,会从其他的P那里搞来一半的G
 if gp := runqsteal(_p_, allp[enum.position()], stealRunNextG); gp != nil {
 return gp, false
 }
 }
 
 }
 
stop:
 ....

实际上从其他的地方搞到G就是所谓的“work stealing”,但是我注意到它遍历P列表的方式有点意思,看起来是随机的遍历,但是为何这么复杂呢,研究下。

来分析下,这里的count是GOMAXPROCS。
在这里插入图片描述
起初我的疑问是,为什么不直接从随机数%count处开始遍历列表即可,后来想了想,go的设计者应该是想以伪随机的方式遍历出所有的列表。看官方给的解释是说,如果我们有X使得X和GOMAXPROCS互质,那么一系列(i + X)%GOMAXPROCS就会给出所需的枚举,也验证了这样的想法(话说这不就是面试的时候经常面的题吗,活生生的应用场景啊)。

6.)还有一个问题,我们创建一个协程的时候,协程是直接放在当前的P队列中

7.)我们的G是可以被复用的,P对象中有一个字段叫gfree,保存的就是运行完毕的G可以被复用,全局调度器schedt中也有gfree队列,当我们创建新的协程的时候会先找一个可复用的G,如果没有才会新建。

8.)我们的调度过程中可能会需要新的线程,但是线程的创建是需要消耗内核资源的,所以golang里面会限制线程的最大数量,所以我们一般也是会搞一个线程池,复用之前的线程,实在没有才会创建新的线程。

9.)我们之前说过,当M执行G进行系统调用的时候会被抢占P,那么当M系统调用结束后,就会没有P了,但是G可能还没有执行完毕呢,怎么办,会把G放回全局队列,然后M sleep或者叫放回线程池中。

10.)我们好像忘了一个事情,M是如何循环执行它的所绑定的P里面的G的,我们只看到了调用execute,并没有找到那个地方在遍历P的G列表,实际上我们在创建goroutine的时候有点玄机,我们知道创建goroutine的时候,代码中直接go fn()即可,最终会调用newproc1()来创建新的goroutine,这里不仅把fn当执行函数放到goroutine里面,还把一个函数goexit放进去了,当M执行完G的逻辑函数fn后还会执行这个函数,这个函数的执行过程是goexit->goexit1->goexit0,这个函数会把当前的G放入p的复用链表中,然后调用schedule(),这不就开始继续下一个goroutine了吗,注意在创建goroutine的时候,不一定都是新建,会先从P的G复用链表中拿一个G,拿不到再创建新的。

至此,算是结束了对goroutine的调度的解释。

四、一个golang程序启动的过程
结合了解的linux的线程、进程知识与golang,我们来看下一个golang程序启动的时候都干了什么?
1.)首先程序的启动一般是通过shell命令启动,例如:./test,此时shell会调用fork()为目标程序创建一个进程
2.)初始化工作:令⾏参数整理,环境变量设置,以及内存分配器、垃圾回收器和并发调度器的⼯作现场准备
3.)创建主协程
4.)把主协程交给当前线程来执行,主协程执行的内容包括:执行栈最大限制,启动监控线程,执行runtime的初始化函数,标准库和用户包的初始化函数,最后执行用户逻辑的main函数
5.)执行用户逻辑的main函数后,可能会各种创建新的协程,创建的新协程有可能是从复用队列中拿也可能是创建新的。
6.)创建的新协程会先放入本地的P中
7.)在执行过程中监控线程会进行调度,调度的目的就是为了保证各个P上都有M在执行G,也就是各个核被完全利用起来,调度可能会产生新的M(也可能是复用M)。

五、分析goroutine高效的原因
总结下golang的这种goroutine并发的方式相对于传统的多线程并发有哪些优势

1.)采用多对对的线程模型
golang采用了最新的多对多的线程模型,大大的减少了由于系统调度产生的内核线程的切换,并且设置了P的最大值,保证同时跑的线程数量不会比CPU核数大,这样最大效率的利用CPU

2.)协程的切换开销极小
golang的协程的切换只需要切换少量的寄存器,并且最主要这样的操作是不需要陷入内核态切换操作的

六.一些额外的思考
1.)实际上golang也有内核线程的切换,比如当我们创建一个线程的时候,或者唤醒一个新的线程的时候,他们必然会去重新抢占CPU。但是这相对于长时间运行中系统的时间片轮转的调度次数来说可以忽略不计了。

2.)在一个linux系统上如果跑了我们的go的应用程序,理想情况下,同时跑的M,每个M占用一个CPU,但是系统上还有其他的程序啊,比如系统进程或者其他的应用程序等,二者之间也会抢占资源引起系统的调度时间片轮转吧,这就涉及到时间片分配的原理了,我们来看看。

实际上,linux系统的调度是抢占式的,并且是基于优先级来分配时间片大小的,优先级越高分配的时间片越长,在linux中任务被分为实时任务和非实时任务,我们的普通应用进程是非实时的,在linux中实时任务的优先级普遍比非实时的优先级要高,而且实时任务的优先级是静态固定的。不过我们只关注非实时的任务,非实时的优先级是一个动态的值,会基于该任务的nice值加上或者减去5,nice值越低,优先级越高,由于系统偏爱交互性任务(sleep时间长的),所以交互性强的任务nice值就会-5,然后优先级就会被提高,相反交互性低的任务(计算密集型任务)优先级就会被降低。

上面有关时间片的分配的理论知识。我只想说明一点,golang的程序并不是说没有内核的切换,如果一台服务器只跑了你一个应用程序,那还好,但是如果混部服务的话,一个CPU上势必会长期跑着至少两个内核线程,而且跑的越多,时间片的大小会减小,引起线程切换的频率加大,消耗的无用CPU时间越多。而且如果服务混部,阻塞型服务貌似还占便宜,被分到的时间片会大点。

七、golang如何使用epoll
我们再之前介绍过socket建立起来后,我们再Read数据的时候,如果没有数据,会调用fd.pd.waitRead(),实际上它的调是runtime包下面的net_runtime_pollWait(),在netpoll.go里面,这个函数会最终调用netpollblock,

func netpollblock(pd *pollDesc, mode int32, waitio bool) bool {
 gpp := &pd.rg
 if mode == 'w' {
 gpp = &pd.wg
 }
 ....
 
 if waitio || netpollcheckerr(pd, mode) == 0 {
 //重点在这
 gopark(netpollblockcommit, unsafe.Pointer(gpp), "IO wait", traceEvGoBlockNet, 5)
 }
 ....
 return old == pdReady
}

看到没,实际上它会阻塞,但是阻塞的方式是把当前协程与CPU剥离开,再联想我们之前了解的goroutine的调度原理,我们的监控线程会不断的调用epoll_wait,ready的协程会给他们分配一个M继续执行,而实际上使用我们之前了解过的linux的epoll原理中的epoll_wait函数的那个监控线程。

还有最后一个问题:监控线程通过netpoll获取了所有的socket的之后,它是怎么找到该socket对应的goroutine呢?我们先看看netpoll拿到socket是怎么转换为goroutine的

func netpoll(block bool) *g {
 ....
 n := epollwait(epfd, &events[0], int32(len(events)), waitms)
 ....
 var gp guintptr
 for i := int32(0); i < n; i++ {
 ev := &events[i]
 if ev.events == 0 {
 continue
 }
 var mode int32
 if ev.events&(_EPOLLIN|_EPOLLRDHUP|_EPOLLHUP|_EPOLLERR) != 0 {
 mode += 'r'
 }
 if ev.events&(_EPOLLOUT|_EPOLLHUP|_EPOLLERR) != 0 {
 mode += 'w'
 }
 if mode != 0 {
 //终点在这里,貌似从pd里面获取了gb
 pd := *(**pollDesc)(unsafe.Pointer(&ev.data))
 netpollready(&gp, pd, mode)
 }
 }
 if block && gp == 0 {
 goto retry
 }
 return gp.ptr()
}

我们先来看看gp.ptr()是什么
func (gp guintptr) ptr() *g { return (*g)(unsafe.Pointer(gp)) }
看到没,它就是把gp强转为我们需要的goroutine了。ok我们继续看netpollready

func netpollready(gpp *guintptr, pd *pollDesc, mode int32) {
 var rg, wg guintptr
 if mode == 'r' || mode == 'r'+'w' {
 rg.set(netpollunblock(pd, 'r', true))
 }
 if mode == 'w' || mode == 'r'+'w' {
 wg.set(netpollunblock(pd, 'w', true))
 }
 if rg != 0 {
 rg.ptr().schedlink = *gpp
 *gpp = rg
 }
 if wg != 0 {
 wg.ptr().schedlink = *gpp
 *gpp = wg
 }
}

继续看netpollunblock

func netpollunblock(pd *pollDesc, mode int32, ioready bool) *g {
 //终重点在这,实际上,来源就是pd的rg和wg分别对应读类型的goroutine和写类型的goroutine
 gpp := &pd.rg
 if mode == 'w' {
 gpp = &pd.wg
 }
 for {
 old := *gpp
 if old == pdReady {
 return nil
 }
 ......
 var new uintptr
 if ioready {
 new = pdReady
 }
 if atomic.Casuintptr(gpp, old, new) {
 if old == pdReady || old == pdWait {
 old = 0
 }
 return (*g)(unsafe.Pointer(old))
 }
 }
}

ok我们搞明白了,goroutine其实就藏在pollDesc里面,这个玩意我们知道啊,每个socket里面都有它,看起来差不多明白了socket与goroutine的对应的关系了,那么还有一个问题,pollDesc里面的goroutine是何时设置进去的呢?这就有意思了

我们回过头看fd_unix里面的Read函数,实际上进行阻塞的地方是fd.pd.waitRead()的调用,它最终会调用,runtime/netpoll.go里面的net_runtime_pollWait函数,这个函数会最终调用gopark把当前协程给暂停执行,看下gopark函数吧

func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason string, traceEv byte, traceskip int) {
 mp := acquirem()
 gp := mp.curg
 status := readgstatus(gp)
 if status != _Grunning && status != _Gscanrunning {
 throw("gopark: bad g status")
 }
 mp.waitlock = lock
 mp.waitunlockf = *(*unsafe.Pointer)(unsafe.Pointer(&unlockf))
 gp.waitreason = reason
 mp.waittraceev = traceEv
 mp.waittraceskip = traceskip
 releasem(mp)
 // can't do anything that might move the G between Ms here.
 mcall(park_m)
}

gopark传的unlockf参数是netpollblockcommit函数,我们来看下这个参数

func netpollblockcommit(gp *g, gpp unsafe.Pointer) bool {
 return atomic.Casuintptr((*uintptr)(gpp), pdWait, uintptr(unsafe.Pointer(gp)))
}

看来这个函数是把传进来的地址gpp改为gp,看起来是赋值goroutine的。ok,我们继续看gopark函数的另外一个参数,lock是什么呢,回过头去看下netpollblock函数的源码,这个lock参数实际上是我们要给pd赋值的goroutine的地址。

继续看gopark的实现,注意我们看传进来的函数指针赋值给了mp.waitunlockf,传进来的要赋值的goroutine的地址参数lock赋值给了mp.waitlock = lock,我们来看看mcall(park_m)的实现,mcall应该就是调用该函数的意思,具体看park_m怎么做的

func park_m(gp *g) {
 _g_ := getg()
 
 if trace.enabled {
 traceGoPark(_g_.m.waittraceev, _g_.m.waittraceskip, gp)
 }
 
 casgstatus(gp, _Grunning, _Gwaiting)
 dropg()
 
 if _g_.m.waitunlockf != nil {
 //看到没,这就是那个函数指针
 fn := *(*func(*g, unsafe.Pointer) bool)(unsafe.Pointer(&_g_.m.waitunlockf))
 //调用函数进行指针的复制
 ok := fn(gp, _g_.m.waitlock)
 _g_.m.waitunlockf = nil
 _g_.m.waitlock = nil
 if !ok {
 if trace.enabled {
 traceGoUnpark(gp, 2)
 }
 casgstatus(gp, _Gwaiting, _Grunnable)
 execute(gp, true) // Schedule it back, never returns.
 }
 }
 schedule()
}

首先,这个park_m是在m的g0栈上调用,并不是我们要暂停的那个goroutine的栈上执行的,函数的参数gp即为我们要暂停的goroutine
注意这个函数传进来的gp是当前goroutine,g也是当前goroutine,g.m就是我们再gopark函数中看到的mp:= acquirem(),看到g.m.waitlock这个字段是什么了吗?它就是我们设置的要赋值的goroutine的指针地址,而这个函数park_m正好把当前的goroutine给赋值进去了,具体的其他操作我们可以不关注,我们只需要看到是这里设置的goroutine就行。实际上这里面涉及到暂停goroutine的时候,线程会切换运行栈到线程的一个特殊的g0栈上运行,这个park_m的运行栈就是指向g0。

实际上在看源码的过程中对goroutine于socket的关联总结如下:
1.)你可能还记得我们说在linux系统golang是基于epoll的原理来监控socket的,那么什么调用的ctl来注册socket呢?
我找到了netFD的init函数

func (fd *netFD) init() error {
 if err := fd.pd.init(fd); err != nil {
 return err
 }
 return nil
}



func (pd *pollDesc) init(fd *netFD) error {
 serverInit.Do(runtime_pollServerInit)
 ctx, errno := runtime_pollOpen(uintptr(fd.sysfd))
 runtime.KeepAlive(fd)
 if errno != 0 {
 return syscall.Errno(errno)
 }
 pd.runtimeCtx = ctx
 return nil
}

看到没,它调用的就是runtime_pollOpen函数,找到这个函数

//go:linkname net_runtime_pollOpen net.runtime_pollOpen
func net_runtime_pollOpen(fd uintptr) (*pollDesc, int) {
 pd := pollcache.alloc()
 lock(&pd.lock)
 if pd.wg != 0 && pd.wg != pdReady {
 throw("netpollOpen: blocked write on free descriptor")
 }
 if pd.rg != 0 && pd.rg != pdReady {
 throw("netpollOpen: blocked read on free descriptor")
 }
 pd.fd = fd
 pd.closing = false
 pd.seq++
 pd.rg = 0
 pd.rd = 0
 pd.wg = 0
 pd.wd = 0
 unlock(&pd.lock)
 
 var errno int32
 errno = netpollopen(fd, pd)
 return pd, int(errno)
}

看到这个函数基本是创建了一个pollDesc对象并分配了内存了,然后进行了初始化工作,最后调用netpollopen函数,来看看这个函数。

func netpollopen(fd uintptr, pd *pollDesc) int32 {
 var ev epollevent
 ev.events = _EPOLLIN | _EPOLLOUT | _EPOLLRDHUP | _EPOLLET
 *(**pollDesc)(unsafe.Pointer(&ev.data)) = pd
 return -epollctl(epfd, _EPOLL_CTL_ADD, int32(fd), &ev)
}

看到了吧,这里注册的socket。

2.)每当一个goroutine要读socket的时候,会先加锁,然后再调用prepareRead(写操作同样道理),这个prepareRead是干什么用的呢?

func (pd *pollDesc) prepareRead() error {
 return pd.prepare('r')
}

func (pd *pollDesc) prepare(mode int) error {
 res := runtime_pollReset(pd.runtimeCtx, mode)
 return convertErr(res)
}

这个函数最终会调用runtime_pollReset,我们去瞅瞅干了什么?

func net_runtime_pollReset(pd *pollDesc, mode int) int {
 err := netpollcheckerr(pd, int32(mode))
 if err != 0 {
 return err
 }
 if mode == 'r' {
 pd.rg = 0
 } else if mode == 'w' {
 pd.wg = 0
 }
 return 0
}

喔,很简单的意思,我要读socket的时候,我就把pd的rg重置为0,写的时候把wg重置为0。可以理解。

3.)我们重置了pd的对应的goroutine了之后,就要开始读取数据了。也就是我们刚才看到的调用waitRead或者waitWrite,然后设置pd的对应的goroutine。

4.)监控线程调用poll_wait找到对应的ready的socket,然后根据pd里面的goroutine,让其继续执行。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值