GO 运行时内部原理 · 延续 channel 一篇

Go select 内部原理

上一篇讲了 select 在多个 case 都就绪时怎么公平随机挑一个。这一篇讲的是更少人知道的部分:如果一个 case 都没就绪呢? 答案不是"随便挑一个先阻塞",而是同时注册到所有相关 channel 的等待队列上,谁先来就跟谁走,再从其它 channel 上悄悄"退订"。这篇配了一个和真实 go run -race 输出比对过的模拟场景。

· 引用 Go 源码 commit 72aa6db7,文件 src/runtime/select.go · 源码遵循 BSD-3-Clause 协议,版权归 The Go Authors 所有,下文均为简短引用并附原文链接
两阶段 · 先查一遍有没有现成的,没有就全部注册排队 两种顺序 · 轮询顺序随机,加锁顺序却必须固定 退订 · 谁先来选谁,其它排队全部撤销

01 · select 的最小单位

scase:一个 channel + 一个数据槽

每个 case 在运行时就是这样一个极简的描述符:

select.go · L20-L23在 GitHub 上查看 ↗
type scase struct {
	c    *hchan         // chan
	elem unsafe.Pointer // data element
}

一个 select 语句在编译期被翻译成一个 scase 数组传给 selectgo。有意思的是空 select{}——没有任何 case,直接调用一个更简单的函数,永远park 这个 goroutine:

select.go · L103-L105在 GitHub 上查看 ↗
func block() {
	gopark(nil, nil, waitReasonSelectNoCases, traceBlockForever, 1) // forever
}

这就是 select{} 能"永久阻塞当前 goroutine"这个经典技巧的全部原理——没有任何 channel 能把它唤醒。如果这个 goroutine 恰好是最后一个还活着的,Go 的死锁检测器会立刻发现"所有 goroutine 都睡着了":

go run 真实输出(select{} 且是唯一 goroutine)已验证
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [select (no cases)]:
main.main()

但只要还有别的 goroutine 在跑,select{} 就会老老实实一直等,死锁检测不会误报——这也是"用 select{} 让 main 永远阻塞、把程序交给后台 goroutine"这个写法能成立的原因(实测:后台 goroutine 跑完退出之后,只剩 select{} 一个,这时才会立刻报死锁)。

02 · 三个核心概念

两阶段 / 两种顺序 / 退订

两阶段

第一阶段(pass 1)挨个检查每个 case 是否已经就绪(有等待的对家,或者缓冲区可用);全都没有,又没有 default,才进入第二阶段(pass 2):把自己同时注册到所有相关 channel 的等待队列上,然后真正休眠。

两种顺序

检查用的轮询顺序每次都随机打乱,保证公平;但真正加锁用的顺序必须固定(按 channel 内存地址排序)——同时锁多个 channel 时,顺序不固定会有死锁风险。

退订

一旦某个 channel 先把这个 goroutine 唤醒,它就要立刻从其它所有还注册着的 channel 队列里把自己摘掉——否则那些 channel 上会挂着一个再也不会被真正使用的"僵尸"等待者。

03 · 现场直播

两个 case 都没就绪时,select 做了什么

下面这段场景先用 go run -race 跑出真实结果,再逐项比对生成的轨迹:一个 goroutine 同时 select 两个空的无缓冲 channel(chAchB),都没有人发送;之后另一个 goroutine 往 chB 发送——看 select 具体经历了哪几步。

selector goroutine S
state: idle
点击"下一步"或"播放"开始。
0 / 0
S 注册在这个 channel 的等待队列上 这个 case 赢了,S 被唤醒 S 已经从这里退订(pass 3 清理)

04 · nil channel 的 case 永远不会被选中

生成轮询顺序时,直接跳过它

轮询顺序(pollorder)是在遍历所有 case 时现场生成的,生成的第一步就是把 c == nil 的 case 直接排除在外:

select.go · L167-L177在 GitHub 上查看 ↗
	// generate permuted order
	norder := 0
	allSynctest := true
	for i := range scases {
		cas := &scases[i]

		// Omit cases without channels from the poll and lock orders.
		if cas.c == nil {
			cas.elem = nil // allow GC
			continue
		}

一个指向 nil 的 channel 变量,它对应的 case 从始至终都不会出现在轮询顺序里,也就永远不可能被选中——这正是"把某个 case 的 channel 变量设成 nil 来临时禁用它"这个技巧的原理:不需要重写 select 语句本身,只要在外面把变量置 nil,这个分支就从"可能被选中"变成"逻辑上不存在"。实测验证过:

go run 真实输出已验证
OK: nil channel case was skipped, got 99 from the real channel

05 · 两种顺序,两个目的

轮询顺序为了公平,加锁顺序为了不死锁

上一篇讲过轮询顺序(pollorder)每次都会被随机打乱,保证多个 case 同时就绪时机会均等。但 select 要同时操作好几个 channel,每个 channel 都有自己的锁——加锁的顺序如果也跟着随机,会有一个真实的死锁风险:两个 goroutine 各自 select 同一组 channel,如果一个按 A→B 加锁、另一个按 B→A 加锁,就可能互相等待对方释放锁。Go 的解法是把加锁顺序(lockorder)按 channel 的内存地址重新排序——地址是固定的,所以无论哪个 goroutine、无论轮询顺序怎么随机,最终加锁的顺序永远一致:

select.go · L206-L218在 GitHub 上查看 ↗
	// sort the cases by Hchan address to get the locking order.
	// simple heap sort, to guarantee n log n time and constant stack footprint.
	for i := range lockorder {
		j := i
		// Start with the pollorder to permute cases on the same channel.
		c := scases[pollorder[i]].c
		for j > 0 && scases[lockorder[(j-1)/2]].c.sortkey() < c.sortkey() {
			k := (j - 1) / 2
			lockorder[j] = lockorder[k]
			j = k
		}
		lockorder[j] = pollorder[i]
	}

这是经典的"锁排序"(lock ordering)death-avoidance 手法——只要所有参与者都按同一个全局顺序申请锁,就不可能出现循环等待。select 恰好需要一次性申请好几把锁,是这个技巧在 Go 运行时里少数几个直接现身的地方。

06 · 没有一个就绪,select 会注册在所有 channel 上

pass 2:同时排队,不是排在某一个上

如果 pass 1 一个 case 都没找到就绪的,又没有 default,selectgo 会按(刚刚排好序的)加锁顺序,把这个 goroutine 包成一个 sudog,逐个注册到每一个 case 对应的 channel上,然后才真正 gopark 休眠:

select.go · L309-L342(pass 2 节选)在 GitHub 上查看 ↗
	// pass 2 - enqueue on all chans
	if gp.waiting != nil {
		throw("gp.waiting != nil")
	}
	nextp = &gp.waiting
	for _, casei := range lockorder {
		casi = int(casei)
		cas = &scases[casi]
		c = cas.c
		sg := acquireSudog()
		sg.g = gp
		sg.isSelect = true
		// No stack splits between assigning elem and enqueuing
		// sg on gp.waiting where copystack can find it.
		sg.elem.set(cas.elem)
		sg.releasetime = 0
		if t0 != 0 {
			sg.releasetime = -1
		}
		sg.c.set(c)
		// Construct waiting list in lock order.
		*nextp = sg
		nextp = &sg.waitlink

		if casi < nsends {
			c.sendq.enqueue(sg)
		} else {
			c.recvq.enqueue(sg)
		}

		if c.timer != nil {
			blockTimerChan(c)
		}
	}

对应模拟器里的那一步:chAchBrecvq同时出现了 S。这意味着任何一个 channel 只要有人发送,都能把 S 唤醒——select 不是"押注在某一个 channel 上等",而是把所有可能性都占住,谁先来都能接住。

07 · 谁先来,就跟谁走,其它的退订

pass 3:清理掉没被选中的注册

某个 channel(模拟器里是 chB)一旦有人发送,会走前几篇讲过的正常 chanrecv/send 流程,把 S 从 那一个 channel 的队列里摘下来并唤醒。但 S 当初是同时注册在两个 channel 上的——S 醒来之后,必须回头把自己从另一个(没被选中的)channel 队列里也摘掉:

select.go · L360-L401(pass 3 节选)在 GitHub 上查看 ↗
	// pass 3 - dequeue from unsuccessful chans
	// otherwise they stack up on quiet channels
	// record the successful case, if any.
	// We singly-linked up the SudoGs in lock order.
	casi = -1
	cas = nil
	caseSuccess = false
	sglist = gp.waiting
	// Clear all elem before unlinking from gp.waiting.
	for sg1 := gp.waiting; sg1 != nil; sg1 = sg1.waitlink {
		sg1.isSelect = false
		sg1.elem.set(nil)
		sg1.c.set(nil)
	}
	gp.waiting = nil

	for _, casei := range lockorder {
		k = &scases[casei]
		if k.c.timer != nil {
			unblockTimerChan(k.c)
		}
		if sg == sglist {
			// sg has already been dequeued by the G that woke us up.
			casi = int(casei)
			cas = k
			caseSuccess = sglist.success
			if sglist.releasetime > 0 {
				caseReleaseTime = sglist.releasetime
			}
		} else {
			c = k.c
			if int(casei) < nsends {
				c.sendq.dequeueSudoG(sglist)
			} else {
				c.recvq.dequeueSudoG(sglist)
			}
		}
		sgnext = sglist.waitlink
		sglist.waitlink = nil
		releaseSudog(sglist)
		sglist = sgnext
	}

代码里那句注释说得很直白:"otherwise they stack up on quiet channels"——如果不清理,一个很少被用到的 channel 上会渐渐堆积一堆早就不需要的僵尸等待者,内存泄漏且拖慢这个 channel 之后所有的操作。真实验证过:退订之后,原来那个没被选中的 channel(chA)上确实一个等待者都不剩:

go run -race 真实输出已验证
main: about to send on chB (chA has nobody sending)
selector: got 7 from chB
main: selector picked branch 2
confirmed: nobody is waiting on chA anymore (pass-3 cleanup worked)

08 · 两个容易忽略的点

顺手提两个问题

09 · 参考与说明

引用与这个模拟器做了哪些简化

☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电