Go:channel 与 select —— 用通信来共享内存
channel 是 Go 最有辨识度的东西,也是最容易被用错的东西。最常见的误解是把它当成「一个线程安全的队列」—— 它确实可以当队列用,但那样你就错过了它真正的价值。一个无缓冲的 channel 不是队列,它是一次「会合」(rendezvous):发送方和接收方必须同时到场,一手交钱一手交货,然后各自走人。而这次会合,恰好是 Go 内存模型里最强的一次 happens-before 保证。理解了这一点,你就理解了「用通信来共享内存」这句话的全部技术含义。
无缓冲 channel:一次会合,不是一个队列
ch := make(chan int) // 无缓冲!
go func() {
ch <- 42 // 【阻塞在这里】,直到有人来接
fmt.Println("发出去了") // 这一行,发生在接收方拿到之后
}()
time.Sleep(time.Second) // 让发送方在那儿等一秒
v := <-ch // 接收 —— 这一刻,双方【同时】被唤醒
fmt.Println("收到", v)
① 它是一次数据传递。(显然)
② 它是一次同步点。发送方和接收方在这一刻会合。发送方知道「对方一定收到了」,接收方知道「对方一定发出了」。这个保证,一个队列给不了你。
③ 它是一次 happens-before 的建立。Go 内存模型明确规定:
「对一个无缓冲 channel 的接收,happens-before 对应的发送完成。」
「对一个 channel 的发送,happens-before 对应的接收完成。」
意思是:发送方在 ch <- x 之前做的所有内存写,接收方在 <-ch 之后全都看得见。
这就是为什么 channel 能替代锁。它不只是传了个值 —— 它把整个内存的可见性一起传过去了(回到第 6 章)。
所以:ch := make(chan struct{}) 是一个常见的惯用法 —— 它不传任何数据(struct{} 占 0 字节),它只传「时间」:
done := make(chan struct{})
go func() {
doWork()
close(done) // 「我干完了」
}()
<-done // 等它干完(收到零值,但我不关心值,我关心的是时机)
缓冲 channel:一个减震器,不是一个蓄水池
ch := make(chan int, 3) // 缓冲 3 个 ch <- 1 // 不阻塞(buf: 1) ch <- 2 // 不阻塞(buf: 2) ch <- 3 // 不阻塞(buf: 3) ch <- 4 // 【阻塞!】缓冲满了 —— 等有人取走一个
下面这个弹珠台把它跑起来。先把「缓冲区容量」拖到 0,看会合的样子;再拖到 3,把「消费者耗时」拖到 60ms —— 看缓冲区怎么被填满,然后生产者怎么被顶住。
你刚才应该看到了:只要消费者长期比生产者慢,缓冲区一定会满,生产者一定会被阻塞。
把 cap 从 3 调到 1000 也救不了你 —— 只是让崩溃晚 997 个元素、内存多涨一点。
缓冲区的正确用途:吸收突发(burst)。生产速率平均低于消费速率,但偶尔有尖峰 —— 缓冲区把尖峰摊平。
缓冲区的错误用途:解决「消费者太慢」。那不是缓冲能解决的问题(第 21 章会用 Little 定律证明这一点)。
而 ch <- x 的阻塞,正是背压 —— 而且是白送的。压力沿着管道往回传,一路传到源头。你什么都不用做。
close、nil、以及那些必须背下来的语义
channel 的行为是一张真值表,而且里面有几格会 panic。这张表值得贴在工位上:
| 操作 | nil channel | 正常的(未关闭) | 已关闭的 |
|---|---|---|---|
发送 ch <- v |
永远阻塞 | 成功 / 阻塞(满了) | panic! 「send on closed channel」 |
接收 <-ch |
永远阻塞 | 成功 / 阻塞(空了) | 立刻返回零值 (而且可以无限次返回) |
关闭 close(ch) |
panic! | 成功 | panic! 「close of closed channel」 |
① 只有发送方可以 close。(接收方 close 会让发送方 panic。)
② 多个发送方时,不要 close —— 或者用一个额外的信号 channel 来协调。
// ❌ 多个发送方,谁来 close?
// 谁 close 都可能让别人 panic("send on closed channel")
// ✅ 方案:用一个独立的 done channel 通知发送方停止
func producer(ch chan<- int, done <-chan struct{}) {
for {
select {
case ch <- produce():
case <-done: // 有人喊停
return // 【不 close ch】,只是自己退出
}
}
}
// 然后由一个 WaitGroup 等所有发送方退出后,再由【协调者】close(ch)
「怎么优雅地关闭一个 channel」是 Go 里被写过最多博客的问题之一。而它的根本原因是:close 的语义是「发送方说:没有更多数据了」,不是「大家散了吧」。把它当成后者用,就会踩坑。
for range 和「逗号 ok」
// 检测 channel 是否关闭
v, ok := <-ch
if !ok {
// channel 已关闭且已排空
}
// for range:自动在 close 后退出循环
for v := range ch { // ← 【必须有人 close(ch)】,否则这个循环永远不结束
process(v) // (然后这个 goroutine 就泄漏了 —— 下一章)
}
select:channel 的多路复用
select {
case v := <-ch1:
fmt.Println("从 ch1 收到", v)
case ch2 <- x:
fmt.Println("发到 ch2 了")
case <-time.After(time.Second): // 超时!
fmt.Println("等了一秒,不等了")
case <-ctx.Done(): // 取消!(下一章)
return ctx.Err()
default: // 【有 default = 非阻塞】
fmt.Println("什么都没准备好,我不等")
}
| 规则 | 说明 |
|---|---|
| 随机选择 | 如果多个 case 同时就绪,随机挑一个。不是从上到下!(这是故意的:防止某个 case 饿死。) |
有 default = 不阻塞 | 没有 case 就绪时,立刻走 default。没有 default 就会阻塞等待。 |
空 select{} | 永远阻塞。(用来「让 main 永远不退出」。) |
| nil channel 的 case 会被忽略 | 这是个非常好用的技巧!见下。 |
技巧:用 nil channel 动态禁用一个 case
// 目标:从 in 读,写到 out。但 in 关闭后,要把剩下的写完再退出。
var out chan int // 先是 nil —— select 会【忽略】这个 case
var pending int
for {
select {
case v, ok := <-in:
if !ok {
in = nil // ← 输入没了,【把这个 case 关掉】
continue // (否则关闭的 channel 会一直返回零值,死循环!)
}
pending = v
out = realOut // ← 有货了,【启用】发送 case
case out <- pending:
out = nil // ← 发完了,【再关掉】发送 case,等下一个输入
case <-ctx.Done():
return
}
if in == nil && out == nil { return } // 两头都关了 → 收工
}
这是 Go 里一个非常地道的模式(Rob Pike 在他的「Go 并发模式」演讲里推广的):把 channel 变量设成 nil,就等于把那个 select 分支临时删掉了。
// ❌ CPU 100% 的死循环
for {
select {
case v := <-ch: // ch 关闭后,这个 case 【永远立刻就绪】,返回零值!
process(v) // 于是 select 疯狂空转,烧光一个核
case <-done:
return
}
}
// ✅ 必须检查 ok,并且把 channel 设成 nil
for {
select {
case v, ok := <-ch:
if !ok { ch = nil; continue } // ← 关掉这个 case
process(v)
case <-done:
return
}
}
这是一个真实的、常见的、症状是「CPU 莫名跑满」的 bug。而且它很隐蔽 —— 只在上游关闭 channel 之后才发作。
那句 fatal error: all goroutines are asleep
func main() {
ch := make(chan int)
ch <- 1 // 阻塞:没有接收方
fmt.Println(<-ch)
}
fatal error: all goroutines are asleep - deadlock! goroutine 1 [chan send]: main.main() /tmp/main.go:5 +0x37
Go runtime 有一个全局死锁检测器:如果所有 goroutine 都在睡(没有一个是 runnable 的),它知道没有任何东西能再唤醒它们了,于是直接崩掉。
这个检测器在真实项目里几乎从来不会触发。
因为你的程序里总有某个 goroutine 是活的 —— HTTP server 的 accept 循环、一个 ticker、一个后台的健康检查。
于是那两个真的卡死的 goroutine,会静静地泄漏。没有任何报错,没有任何日志。它们只是永远地站在那里,占着内存,直到你的服务 OOM。
这就是下一章的主题:goroutine 泄漏 —— Go 最常见的生产事故。
什么时候用 channel,什么时候用锁
这是 Go 社区争论最多的问题。Go 官方 wiki 给了一个很务实的答案:
| 用 channel,当你要 | 用 sync.Mutex,当你要 |
|---|---|
| 传递数据的所有权 (一个东西从 A 交给 B,之后 A 不再碰它) |
保护一块共享的内部状态 (一个缓存、一个计数器、一个连接池) |
| 分发工作 (worker pool、流水线) |
保护一个结构体的字段 (把 mutex 和它保护的字段放在一起) |
| 传递信号 / 事件 (done、取消、通知) |
性能敏感的简单临界区 (channel 有额外开销:一次 channel 操作 ≈ 100ns,一次无竞争的 mutex ≈ 20ns) |
|
Rob Pike 的原话:「Use whichever is most expressive and/or most simple.」 用哪个更能表达意图,或者更简单,就用哪个。 |
|
- Go:channel。提倡但不强制 —— 标准库里到处是
sync.Mutex。 - Kotlin:
Channel/Flow。Kotlin 也有 channel(kotlinx.coroutines.channels.Channel),语义几乎一样(Channel.RENDEZVOUS就是无缓冲)。但 Kotlin 社区更常用Flow(因为它是冷的、可组合的)。 - Python:
asyncio.Queue。但因为 asyncio 是单线程的,Python 里根本没有数据竞争,所以「用通信来共享内存」在 Python 里失去了它最大的卖点。它在 Python 里只是一个「解耦生产者和消费者」的工具。 - Rust:把它变成了编译期规则。所有权系统 +
Send/Synctrait —— 你不「提倡」用通信共享内存,你是被迫的(第 24 章)。
1. 把上面那个弹珠台的缓冲拖到 0,看会合的样子。然后想一想:为什么一个「零容量的队列」反而是最强的同步原语?
2. 写一个「关闭的 channel 疯狂空转」的 bug,然后用 top 看它把一个核烧满。亲眼见一次,以后 code review 时你会一眼认出它。
3. 搜你项目里所有的 for range ch。问:谁负责 close(ch)?如果答案是「没人」或者「不确定」—— 那是一个 goroutine 泄漏。
4. 用那个 nil channel 技巧重写一个 select 循环。这是 Go 里最优雅的模式之一,值得掌握。
下一章:Go 的代价。go f() 让控制流跳出去,再也不回来 —— 于是「取消」和「生命周期」这两件事,Go 完全甩给了你。而这就是 goroutine 泄漏成为 Go 头号生产事故的原因。