上一篇我们介绍了当创建一个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
中,转移前要加锁 - 再次调度