Skip to content

31|select 与超时

select 是 Go 并发编程里的多路复用器,能同时等待多个 channel 操作,哪个先就绪就执行哪个。它最常见的用途是:等待任务结果的同时设置超时,防止某个慢操作无限阻塞整个程序。配合 time.Aftertime.NewTimerselect 能给 channel 通信加上时间边界。

一、select 基础

select 同时等待多个 channel 操作:

go
package main

import (
	"fmt"
	"time"
)

func main() {
	result := make(chan string, 1)

	go func() {
		time.Sleep(2 * time.Second)
		result <- "ok"
	}()

	select {
	case value := <-result:
		fmt.Println("result:", value)
	case <time.After(1 * time.Second):
		fmt.Println("timeout")
	}
}

select 会阻塞到某个 case 可以执行。上面例子里,如果 1 秒内 result 没有值,就会走 time.After 对应的超时分支;如果 1 秒内结果先回来,就会打印结果。

多个 case 同时就绪时,select 随机选择一个执行。这个随机性是有意设计的,避免某个 channel 永远被优先处理而导致饥饿。

二、time.After 的坑

time.After 适合小示例。长期运行的循环里频繁创建 time.After 会产生额外计时器对象,定时循环通常用 time.NewTimer 更好控制:

go
	timer := time.NewTimer(1 * time.Second)
	defer timer.Stop()

	select {
	case value := <-result:
		fmt.Println("result:", value)
	case <-timer.C:
		fmt.Println("timeout")
	}

time.NewTimertime.After 在 select 里的行为一样,区别是前者可以手动 StopReset,避免循环中不断创建新计时器对象。

如果需要重复超时判断(比如每隔 5 秒检查一次任务状态),用 time.NewTicker

go
	ticker := time.NewTicker(5 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case value := <-result:
			fmt.Println("result:", value)
			return
		case <-ticker.C:
			fmt.Println("still waiting...")
		}
	}

三、default 分支

selectdefault 后变成非阻塞:

go select
	case value := <-result:
		fmt.Println("result:", value)
	default:
		fmt.Println("no result yet")
	}

没有 case 就绪时,执行 default 然后立刻返回。这个模式常用于轮询或尝试发送而不阻塞。

四、用 nil channel 禁用 case

把某个 channel 设为 nil,对应的 select case 永远不会就绪,相当于禁用了这个分支:

go
var ch1 chan int = make(chan int)
var ch2 chan int

select {
	case <-ch1:
		fmt.Println("ch1")
	case <-ch2: // ch2 是 nil,这个 case 永远不会执行
		fmt.Println("ch2")
	}

这个技巧在动态调整监听对象时很有用。比如有多个任务通道,某个任务完成后把对应通道设为 nil,后续 select 不再监听它。

五、完整超时模式

批量巡检时,每个任务都要有独立的超时控制:

go
func checkWithTimeout(host string, timeout time.Duration) Result {
	result := make(chan Result, 1)

	go func() {
		result <- check(host)
	}()

	select {
	case r := <-result:
		return r
	case <time.After(timeout):
		return Result{Host: host, OK: false, Message: "timeout"}
	}
}

每个任务启动一个 goroutine,用独立的 select 控制超时。这样一批任务里,个别目标卡住不会影响其他目标。

六、常见错误

select 里只有一个 case

go
select {
	case v := <-ch:
		fmt.Println(v)
	}

只有一个 case 的 select 和直接 <-ch 等价,但多了一个不必要的语法层。除非是为了和 default 搭配,否则不要这样写。

忘记停止 timer

go
for {
	select {
	case <-time.After(1 * time.Second):
		// 每次循环都创建新计时器
	}
}

循环里用 time.After 会堆积大量未释放的计时器对象。改用 time.NewTimerReset

go
	timer := time.NewTimer(1 * time.Second)
	defer timer.Stop()
	for {
		select {
		case <-timer.C:
			timer.Reset(1 * time.Second)
		}
	}

超时后 goroutine 泄漏

go
func bad() {
	result := make(chan int)
	go func() {
		time.Sleep(10 * time.Second)
		result <- 42 // 超时后没人接收,goroutine 永远阻塞
	}()
	select {
	case <-result:
	case <time.After(1 * time.Second):
		return // result 通道无缓冲,发送方永远阻塞
	}
}

超时退出后,发送方 goroutine 卡在 result <- 42 上。用有缓冲 channel(make(chan int, 1))或 context 取消来避免泄漏。