幕 III · 三种活法CH 17进度 17/25

Go:channel 与 select —— 用通信来共享内存

channel 是 Go 最有辨识度的东西,也是最容易被用错的东西。最常见的误解是把它当成「一个线程安全的队列」—— 它确实可以当队列用,但那样你就错过了它真正的价值。一个无缓冲的 channel 不是队列,它是一次「会合」(rendezvous):发送方和接收方必须同时到场,一手交钱一手交货,然后各自走人。而这次会合,恰好是 Go 内存模型里最强的一次 happens-before 保证。理解了这一点,你就理解了「用通信来共享内存」这句话的全部技术含义。

CSP无缓冲 = 会合selectclose 语义背压

无缓冲 channel:一次会合,不是一个队列

ch := make(chan int)      // 无缓冲!

go func() {
    ch <- 42              // 【阻塞在这里】,直到有人来接
    fmt.Println("发出去了")  // 这一行,发生在接收方拿到之后
}()

time.Sleep(time.Second)   // 让发送方在那儿等一秒

v := <-ch                 // 接收 —— 这一刻,双方【同时】被唤醒
fmt.Println("收到", v)
◆ 定论 · 无缓冲 channel 的三个身份

① 它是一次数据传递。(显然)

② 它是一次同步点。发送方和接收方在这一刻会合。发送方知道「对方一定收到了」,接收方知道「对方一定发出了」。这个保证,一个队列给不了你。

③ 它是一次 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。(接收方 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 分支临时删掉了。

⚠ 陷阱 · 那个「关闭的 channel 会一直返回零值」的坑
// ❌ 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
  • KotlinChannel / FlowKotlin 也有 channel(kotlinx.coroutines.channels.Channel),语义几乎一样(Channel.RENDEZVOUS 就是无缓冲)。但 Kotlin 社区更常用 Flow(因为它是冷的、可组合的)。
  • Pythonasyncio.Queue但因为 asyncio 是单线程的,Python 里根本没有数据竞争,所以「用通信来共享内存」在 Python 里失去了它最大的卖点。它在 Python 里只是一个「解耦生产者和消费者」的工具。
  • Rust:把它变成了编译期规则。所有权系统 + Send/Sync trait —— 你不「提倡」用通信共享内存,你是被迫(第 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 头号生产事故的原因。