Runtime: 当一个goroutine 运行结束后会发生什么

上一篇我们介绍了创建一个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() 函数主要有来实现从 gg0 的切换,对特殊的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 放入当前 PgFree 列表中后,发现当前P的 gFree 存放 g 的数量 >= 64,则需要转移一批 g 到全局列表(sched.gFree)中,基本是转移走 1/2g,直到只剩下31个为止。之所以批量转移也是为了性能考虑(全局调度器上sched.gFreestacknoStack 的区域)

其实在runtime 源码里有太多与计算相关的算法不是原来大小的两倍就是1/2倍,如map的扩容是两倍或者1/2倍,切片扩容在<1024的情况下也是两倍的扩容,大于或等于1024的情况下是1.25倍,除此之外还有栈的扩容和收缩

总结

当一个goroutine结果后会执行以下操作

  1. 调用 mcall() 函数,将当前 g 切换到 g0,并保存当前 g 的上下文信息(PC/SP)到调度器
  2. g0 中切换当前 goroutine 的运行状态 _Grunning => _Gdead
  3. 释放当前 goroutine 相关的资源及与其绑定的m信息
  4. 将当前空闲的goroutine放在 PgFree 列表中,以便复用。如果列表太长,同转移一半的g到全部列表 sched.gFeee 中,转移前要加锁
  5. 再次调度