上一篇我们介绍了创建一个goroutine 会经历些什么,今天我们再看下当一个goroutine
运行结束的时候,又会发生什么?
go version 1.15.6。
主要源文件为 src/runtime/proc.go
。
当一个goroutine
运行结束的时候,默认会执行一个 goexit1()
的函数,这是一个只有八行代码的函数,其中最后以通过 mcall()
调用 goexit0
函数结束。因此我们主要关注 goexit0
函数即可。这里为了更好让大家理解,以此之前需要先介绍一下 mcall()
函数。
在 stubs.go
源文件中只是对 mcall()
函数进行了声明,并无函数体,说明这个函数是使用汇编实现的。但这里我们并不需要关心它的具体实现,只要知道它的主要工作职责就可以了,所有信息我们都可以通过函数注释得知。
// mcall switches from the g to the g0 stack and invokes fn(g), // where g is the goroutine that made the call. // mcall函数用来从 g 切换到 g0 栈并调用gn(g)函数,这里的 g 指发起调用的那个goroutine。 // mcall saves g's current PC/SP in g->sched so that it can be restored later. // It is up to fn to arrange for that later execution, typically by recording // g in a data structure, causing something to call ready(g) later. // mcall 会将当前 g 的 PC/SP 信息到 g->sched,以便以后恢复执行 // 由 fn 函数来安排稍后的执行,一般是通过将g记录到一个数据结构中,以后再通过调用 ready(g) 进行唤醒 // mcall returns to the original goroutine g later, when g has been rescheduled. // fn must not return at all; typically it ends by calling schedule, to let the m // run other goroutines. // 当 g 被重新调度后,mcall将返回原来的 g // fn 不能返回,通常是在它调度结束后,直接让 m 运行其它的 goroutine // // mcall can only be called from g stacks (not g0, not gsignal). // mcall 只能从 g 栈中发起调用,不能是 g0 或 gsignal // // This must NOT be go:noescape: if fn is a stack-allocated closure, // fn puts g on a run queue, and g executes before fn returns, the // closure will be invalidated while it is still executing. // fn 必须是 go:noescape(非逃逸函数) // 如果 fn 是堆栈分配的一个闭包函数, 则 fn 函数将 g 放在一个runqueue 中,g并在fn返回之前执行,当仍在执行期间闭包将失效 func mcall(fn func(*g))
从上面的注释可看出来,mcall()
函数主要有来实现从 g
到 g0
的切换,对特殊的g0的切换过程,我们在 g0 特殊的goroutine 文章里已经讲过,每次 g
之间的切换都需要经过g0
。
在切换 g
之前,需要记录当前 g
的上下文信息(PC/SP
)保存到调度器(g->sched
),以便下次恢复运行时使用,所有这些是切换一个 g
的必要前提条件。
你可能会想 当一个g 全部执行结束后,将 g
的上下文信息保存到数据结构中好像意义不大,毕竟当前 goroutine 会被直接销毁,永远也不可能被再次调度执行。其实在golang中,当一个goroutine执行完并不是直接释放掉,为了以便后期可以直接复用(复用逻辑见 Runtime: 创建一个goroutine都经历了什么?),避免重新创建goroutine而带来的开销成本。一般会将使用完的 g
进行清理后,将其保存到一个gfree
列表中。
好了,现在我们再看看 goexit0
函数都执行了哪些操作。
// goexit continuation on g0. func goexit0(gp *g) {}
这时 goexit0
是运行在 g0
上的,原因就是它是由 mcall()
函数发起调用的,上方注释已经说明了这一点。
func goexit0(gp *g) { // 这里的 _g_ 是g0 _g_ := getg() // 状态切换 casgstatus(gp, _Grunning, _Gdead) // 系统goroutines 数据减少1 if isSystemGoroutine(gp, false) { atomic.Xadd(&sched.ngsys, -1) } // 释放相关资源,比较简单 gp.m = nil locked := gp.lockedm != 0 gp.lockedm = 0 _g_.m.lockedg = 0 gp.preemptStop = false gp.paniconfault = false gp._defer = nil // should be true already but just in case. gp._panic = nil // non-nil for Goexit during panic. points at stack-allocated data. gp.writebuf = nil gp.waitreason = 0 gp.param = nil gp.labels = nil gp.timer = nil if gcBlackenEnabled != 0 && gp.gcAssistBytes > 0 { // Flush assist credit to the global pool. This gives // better information to pacing if the application is // rapidly creating an exiting goroutines. scanCredit := int64(gcController.assistWorkPerByte * float64(gp.gcAssistBytes)) atomic.Xaddint64(&gcController.bgScanCredit, scanCredit) gp.gcAssistBytes = 0 } // 删除 m 与当前gourtine m->curg 的关联 dropg() if GOARCH == "wasm" { // no threads yet on wasm gfput(_g_.m.p.ptr(), gp) schedule() // never returns } if _g_.m.lockedInt != 0 { print("invalid m->lockedInt = ", _g_.m.lockedInt, "n") throw("internal lockOSThread error") } // 将g放入当前 P 的 gFree 列表,如果列表太长(>=64),则转移一半的g到全局列表 sched.gFree gfput(_g_.m.p.ptr(), gp) if locked { // The goroutine may have locked this thread because // it put it in an unusual kernel state. Kill it // rather than returning it to the thread pool. // Return to mstart, which will release the P and exit // the thread. if GOOS != "plan9" { // See golang.org/issue/22227. gogo(&_g_.m.g0.sched) } else { // Clear lockedExt on plan9 since we may end up re-using // this thread. _g_.m.lockedExt = 0 } } // 调度 schedule() }
对于 dropg() 函数,这里不再介绍,有兴趣的可以看一下实现源码。
这里介绍一下放入g的 gfput()
函数,看看是如何存放的。
// Put on gfree list. // If local list is too long, transfer a batch to the global list. func gfput(_p_ *p, gp *g) { if readgstatus(gp) != _Gdead { throw("gfput: bad status (not Gdead)") } stksize := gp.stack.hi - gp.stack.lo if stksize != _FixedStack { // non-standard stack size - free it. stackfree(gp.stack) gp.stack.lo = 0 gp.stack.hi = 0 gp.stackguard0 = 0 } _p_.gFree.push(gp) _p_.gFree.n++ if _p_.gFree.n >= 64 { lock(&sched.gFree.lock) for _p_.gFree.n >= 32 { _p_.gFree.n-- gp = _p_.gFree.pop() if gp.stack.lo == 0 { sched.gFree.noStack.push(gp) } else { sched.gFree.stack.push(gp) } sched.gFree.n++ } unlock(&sched.gFree.lock) } }
如果将 g
放入当前 P
的 gFree
列表中后,发现当前P的 gFree
存放 g
的数量 >= 64
,则需要转移一批 g
到全局列表(sched.gFree
)中,基本是转移走 1/2
的 g
,直到只剩下31
个为止。之所以批量转移也是为了性能考虑(全局调度器上sched.gFree
有stack
和 noStack
的区域)
其实在runtime 源码里有太多与计算相关的算法不是原来大小的两倍就是1/2倍,如map的扩容是两倍或者1/2倍,切片扩容在<1024的情况下也是两倍的扩容,大于或等于1024的情况下是1.25
倍,除此之外还有栈的扩容和收缩。
总结
当一个goroutine结果后会执行以下操作
- 调用
mcall()
函数,将当前g
切换到g0
,并保存当前g
的上下文信息(PC/SP
)到调度器 - 在
g0
中切换当前goroutine
的运行状态_Grunning
=>_Gdead
- 释放当前
goroutine
相关的资源及与其绑定的m
信息 - 将当前空闲的goroutine放在
P
的gFree
列表中,以便复用。如果列表太长,同转移一半的g到全部列表sched.gFeee
中,转移前要加锁 - 再次调度