Runtime: 当一个goroutine 运行结束后会发生什么
By admin
- 4 minutes read - 716 words上一篇我们介绍了 创建一个goroutine 会经历些什么,今天我们再看下当一个goroutine
运行结束的时候,又会发生什么?
go version 1.15.6。
主要源文件为 [src/runtime/proc.go](https://github.com/golang/go/blob/go1.15.6/src/runtime/proc.go)
。
当一个goroutine
运行结束的时候,默认会执行一个 [goexit1()](https://github.com/golang/go/blob/go1.15.6/src/runtime/proc.go#L2941-L2950)
的函数,这是一个只有八行代码的函数,其中最后以通过 [mcall()](https://github.com/golang/go/blob/go1.15.6/src/runtime/stubs.go#L34)
调用 [goexit0](https://github.com/golang/go/blob/go1.15.6/src/runtime/proc.go#L2952-L3011)
函数结束。因此我们主要关注 goexit0
函数即可。这里为了更好让大家理解,以此之前需要先介绍一下 mcall()
函数。
在 [stubs.go](https://github.com/golang/go/blob/go1.15.6/src/runtime/stubs.go#L20-L34)
源文件中只是对 [mcall()](https://github.com/golang/go/blob/go1.15.6/src/runtime/stubs.go#L34)
函数进行了声明,并无函数体,说明这个函数是使用汇编实现的。但这里我们并不需要关心它的具体实现,只要知道它的主要工作职责就可以了,所有信息我们都可以通过函数注释得知。
// 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 栈并调用 fn(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
与 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()](https://github.com/golang/go/blob/go1.15.6/src/runtime/proc.go#L3710-L3743)
函数,看看是如何存放的。
// 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
列表中,以便复用(如果栈大小为非标准的大小容量,则释放栈信息stackfree()
)如果列表太长,则转移一半的g到全部列表sched.gFeee
中,转移前要加锁 - 再次调度