卷 V · 并发CH 19深度 19/24

select、超时与常见模式

本章卸下回调地狱Flow 操作符依赖

channel 让你等一个数据。但真实并发里你常要同时等好几件事:这个 channel 来了处理这个、那个来了处理那个、都没来就先干别的、等太久就超时放弃。这就是 select 的舞台——它是 channel 版的 switch。这一章讲清 select、超时、以及几个你会反复用到的并发模式,最后直面 Go 并发最隐蔽的 bug:goroutine 泄漏。

select超时扇出扇入worker poolgoroutine 泄漏

select:同时等多个 channel

select 长得像 switch,但每个 case 是一次 channel 收发操作。它同时盯着所有 case,哪个 channel 先就绪就执行哪个

select {
case msg := <-ch1:
    fmt.Println("ch1 来了:", msg)
case msg := <-ch2:
    fmt.Println("ch2 来了:", msg)
case ch3 <- 42:
    fmt.Println("成功往 ch3 发了 42")
}

三个 case:等 ch1、等 ch2、往 ch3 发。select 会阻塞,直到其中任意一个能进行,然后执行那一个。如果多个同时就绪,Go 会随机挑一个 —— 这个随机是故意的,防止你写出「总是偏向第一个 case」而饿死其它 channel 的代码。

◆ select 是 channel 世界的「多路等待」

没有 select,一个 goroutine 一次只能 <-ch 等一个 channel,卡死在那。select 让它能「同时把耳朵支在好几个 channel 上」,谁先响应谁。这是构建复杂并发的关键积木 —— 超时、取消、多路复用、优先级,全都建立在 select 之上。你可以把它理解成 channel 版的「同时 await 多个 future,取第一个完成的」。

default:非阻塞地试一下

给 select 加一个 default,它就不再阻塞 —— 如果没有任何 case 立刻就绪,就走 default:

select {
case msg := <-ch:
    fmt.Println("收到:", msg)
default:
    fmt.Println("现在没数据,我不等,先干别的")
}

这叫「非阻塞接收 / 发送」—— 试一下,能收就收,不能就立刻走人,绝不卡住。常用于「轮询一下有没有活、没有就继续循环」的场景。

超时:select + time.After

这是 select 最经典、最实用的用法。time.After(d) 返回一个 channel,它会在 d 时间后自动送来一个值。把它当成 select 的一个 case,就实现了超时:

select {
case result := <-ch:
    fmt.Println("按时拿到结果:", result)
case <-time.After(2 * time.Second):
    fmt.Println("等超过 2 秒了,放弃")   // ch 一直没来,这个 case 先响应
}

逻辑:同时等「结果 channel」和「2 秒定时器 channel」。谁先来走谁 —— 结果在 2 秒内来了,走第一个;2 秒到了结果还没来,定时器 channel 先响应,走超时分支。就这么几行,你给任何 channel 操作加上了超时。没有专门的超时 API,全是 channel + select 拼出来的 —— 这就是 Go 「用小积木搭出你要的东西」的风格。

几个你会反复用的模式

扇出 / 扇入(fan-out / fan-in)

扇出:一批活,起多个 goroutine 并行处理(从同一个 channel 抢任务)。扇入:多个 goroutine 的结果汇到同一个 channel 收集。这是并行处理一批任务的标准骨架:

jobs := make(chan int, 100)
results := make(chan int, 100)

// 扇出:起 5 个 worker,都从 jobs 抢活
for w := 0; w < 5; w++ {
    go func() {
        for j := range jobs {       // 从同一个 channel 抢,天然负载均衡
            results <- process(j)   // 结果都送进 results(扇入)
        }
    }()
}

// 派活
for _, j := range myJobs {
    jobs <- j
}
close(jobs)   // 关掉,worker 的 range 会自然结束

妙处:多个 worker 从同一个 jobs channel 抢任务,谁空谁抢,负载天然就均衡了 —— 不用你手动分配。这就是 Go 版的「线程池并行处理」,但用 goroutine + channel 写出来,短小得多,也没有线程池的配置负担(因为 goroutine 便宜,第 17 章)。

⚠ Go 并发最隐蔽的 bug:goroutine 泄漏

goroutine 便宜,但不是免费 —— 一个永远阻塞、再也不会结束的 goroutine,会一直占着内存,越积越多,这叫 goroutine 泄漏,是 Go 生产事故的头号来源之一。最常见的泄漏:

func leak() {
  ch := make(chan int)
  go func() { val := <-ch; use(val) }() // 等 ch
  // 但函数返回了,再没人往 ch 发 —— 这个 goroutine 永远卡在 <-ch
}

那个 goroutine 永远等一个不会来的数据,永远不结束,泄漏了。铁律:每起一个 goroutine,你都要能回答「它在什么情况下一定会退出」。如果答案是「等某个 channel,但可能没人发」,你就需要一个退出机制 —— 超时、或者下一章的 context 取消。这也是第 17 章说的「Go 把组织 goroutine 的责任交回给你」的具体代价:没有结构化并发替你兜底,goroutine 的生命周期得你自己管。

用 select + done 优雅退出

防泄漏的一个基础模式:给长期运行的 goroutine 一个「退出信号 channel」,用 select 同时等「干活」和「该退了」:

func worker(jobs <-chan int, done <-chan struct{}) {
    for {
        select {
        case j := <-jobs:
            process(j)
        case <-done:          // 收到退出信号
            return           // goroutine 干净地结束,不泄漏
        }
    }
}

// 要停的时候,close(done) —— 所有 worker 的 <-done 同时收到,全退出

close(done) 是个惯用小技巧:关闭一个 channel 会让所有<-done 上等待的 goroutine 同时被唤醒(第 18 章:从关闭的 channel 接收立刻返回)。所以一个 close(done) 就能一次性通知任意多个 goroutine「收工」。那个 chan struct{} 是惯用法 —— struct{} 是零大小的空类型,表示「这个 channel 只用来传信号、不传数据」。

这个「done channel」模式很好用,但当调用层级一深、要取消的 goroutine 一多,手动传 done 就繁琐了。所以 Go 把它标准化、升级成了一个专门的东西 —— 那就是下一章的主角 context

Kotlin 对照

⇄ select vs Flow 操作符 / select 表达式

Kotlin 的高层做法:你要「多路等待 + 超时」,通常用 Flow 的操作符(mergetimeoutcombine)或 withTimeout { },或协程的 select { } 表达式(Kotlin 也有 select,同样受 Go 启发)。这些是现成的高层抽象,写「若干数据流合并、带超时」很省事。

Go 的做法是低层、手工的:没有 Flow 那套操作符生态。要超时?select + time.After 自己拼。要合并多个流?自己写 fan-in 的 goroutine。要取消?传 done channel 或 context。Go 给你的是造这些东西的原材料(channel + select + goroutine),而不是造好的成品。

这有得有失。失:写常见模式比 Kotlin 啰嗦,没有一行 .timeout() 的爽快。得:没有黑箱,每个数据流的走向都是你亲手用 channel 画出来的,出问题时没有隐藏的操作符行为要猜。还有一个 Kotlin 帮你、Go 要你自己操心的大事:goroutine 泄漏。Kotlin 的结构化并发让「scope 结束、里面的协程自动取消」,泄漏很难发生。Go 没有这层保护,「每个 goroutine 怎么退出」是你必须主动设计的 —— 这是从协程迁过来最需要提高警惕的一点。

这一章的一句话

select 让一个 goroutine 同时等多个 channel、谁先就绪走谁(多个就绪则随机、防饿死),配 default 做非阻塞、配 time.After 做超时;扇出扇入是并行处理一批活的标准骨架;而 Go 并发最隐蔽的 bug 是 goroutine 泄漏——每起一个 goroutine 都要能答出「它一定在什么情况下退出」,靠 done channel 或下一章的 context 兜住。

done channel 模式手动传起来很累。下一章我们看 Go 怎么把「取消一个操作、连带取消它扇出的所有下游」这件事标准化 —— context,以及它和 Kotlin 的 Job 树有多像。