Menu

Go select 语句:超时、default 与退出 channel

select 如何同时等待多个 channel 操作:在就绪的 case 中选择、用 default 实现非阻塞的发送和接收、用 time.After 实现超时,以及用退出 channel 或 context 停止循环。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

同时等待多个 channel

select 看起来像 switch,但它的每个 case 都是一个 channel 发送或接收操作。它会阻塞,直到某个 case 可以进行,然后执行那个 case。

在这样的延迟下,第一个 select 得到快的结果,第二个得到慢的结果。两次接收都不需要等待另一个 channel。如果直接写 <-slow 再写 <-fast,无论哪个先到,都会按固定的顺序处理。

select 的求值过程:

  1. select 开始时,所有 channel 表达式和要发送的值都会按源代码顺序求值一次。
  2. 如果有一个或多个 case 就绪,就随机选择其中一个。
  3. 如果没有 case 就绪,而存在 default,就执行 default。
  4. 否则 goroutine 会阻塞,直到某个 case 就绪。

空的 select {} 会永远阻塞。在真正的工作都由其他 goroutine 完成的程序里,偶尔能在 main 的末尾看到它。

在就绪的 case 中随机选择

当多个 case 同时就绪时,select 并不会偏向排在最前面的那个。下面这个程序先填满两个带缓冲的 channel,然后执行 1000 次 select:

countA 和 countB 的比例每次运行都不同,大致各占 500 左右。随机选择是有意为之:它能防止一个繁忙的 channel 让其他 channel 饿死。如果需要优先级,参见下文的写法。

用 default 实现非阻塞操作

有了 default case,select 就永远不会阻塞。这会把一次发送或接收变成一次“尝试”操作:

在缓冲区满时丢弃任务,是在不阻塞调用方的前提下削减负载或上报指标的方法。

不要为了反复“检查” channel,就在 for 循环里的 select 中加上 default。没有任何 case 就绪时,这个循环会空转,占满 100% 的 CPU。应该让它阻塞;如果需要定期唤醒,就加一个超时 case。

超时

time.After(d) 返回一个 channel,它会在 d 之后接收到一次值。让它和真正的工作赛跑:

第一次调用返回 "data"。第二次调用在 50 毫秒后返回超时错误,远早于 worker 完成的时间。结果 channel 的缓冲区大小特意设为 1。当超时获胜时,没有人会再从 result 接收;如果是无缓冲 channel,worker goroutine 会永远阻塞在发送操作上,造成泄漏。

在循环中,time.After 每次迭代都会创建一个新的计时器,这正适合按消息计算的空闲超时(“1 秒内没有消息”)。如果要为许多操作设置一个总的截止时间,就在循环之前创建一个计时器或 context。从 Go 1.23 起,不再被引用的计时器即使还没有触发也会被垃圾回收,所以循环中的 time.After 不会再像旧版本那样,在每个计时器触发之前一直占用内存(这需要 go.mod 中写明 go 1.23 或更高版本)。

for-select 循环与退出 channel

一个一直运行到被通知停止的 goroutine,就是在 for 循环中包一个 select,一个 case 处理工作,一个 case 负责停止:

惯用的做法是关闭 quit,而不是向它发送值:关闭操作能被所有接收方看到,无论现在还是以后,所以一次 close 就能停止任意数量的 worker。done channel 让 main 可以等到 worker 真正返回。

在实际代码中,退出 channel 通常是一个 context.Context:case <-ctx.Done():。它的工作方式相同(Done() 返回一个在取消时被关闭的 channel),同时还携带截止时间和停止的原因。context 一文对此有详细介绍。

select 中的 break

在 select 的 case 中,break 退出的是 select,而不是外层的 for。这是循环永远不结束的常见原因。使用 return,或者给循环加上标签:

channel 之间的优先级

由于 select 是随机选择的,你无法在一条语句内给 case 排定优先级。要让某个 channel 只要有值就优先,就先单独检查它:

for {
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}

	select {
	case <-ctx.Done():
		return ctx.Err()
	case job := <-jobs:
		handle(job)
	}
}

如果取消已经发生,第一个 select 会立即返回。没有它的话,在 ctx 被取消之后的一段时间里,源源不断的任务可能一直在随机选择中胜出。

nil channel 会禁用 case

在 nil channel 上的发送或接收永远不会就绪,所以针对 nil channel 的 case 实际上就被关掉了。把一个已关闭的输入设为 nil,就能停止在它上面 select,同时继续处理其他 channel;channel 一文展示了用这种方式构建的合并循环。同样的技巧也可以开关超时:先让 var timeout <-chan time.Time 保持为 nil,需要时再赋值为 time.After(d)。

常见错误

  • 以为会按源代码顺序选择。 排在最前面的 case 并没有优先权。
  • 带 default 的忙循环。 无事可做的 for { select { ... default: } } 会白白耗尽一个 CPU 核心。
  • 让落败的一方泄漏。 当超时获胜时,本应发送结果的 goroutine 仍然必须能够结束。给它的 channel 设置大小为 1 的缓冲区。
  • break 只退出了 select。 使用标签或 return。
  • 把每次循环迭代中的 time.After 当作总的截止时间。 它每次迭代都会重新开始;应在循环外创建一次截止时间。

常见问题

Go 中的 select 是做什么的?

select 会等待,直到它的某个 channel 操作(发送或接收)可以进行,然后执行那个 case。如果有多个同时就绪,它会随机选一个。没有 default case 时,它会一直阻塞到有 case 就绪;有 default 时,它永远不会阻塞。

Go 中如何给 channel 接收加上超时?

把接收操作和一个计时器放进同一个 select:select { case v := <-ch: use(v); case <-time.After(2 * time.Second): return errTimeout }。哪个先发生,就执行哪个。在循环中,或者调用方已经有截止时间时,改用带 context.WithTimeout 的 context.Context,并在 ctx.Done() 上 select。

Go 的 select 会按顺序选择 case 吗?

不会。当多个 case 就绪时,Go 会均匀随机地选择,所以没有哪个 case 会让其他 case 饿死。如果需要优先级,先在单独的 select 中带 default 检查高优先级的 channel,然后再进入一个包含所有 channel 的 select。

为什么 break 没有退出我的 for-select 循环?

在 select 中,break 只会退出 select 语句,而不会退出外层的 for。使用 return,或者给循环加上标签(loop: for { select { case <-done: break loop } })。

Coddy 编程语言插图

和 Coddy 一起学编程

开始