channel:用通信代替共享内存
goroutine 解决了「同时跑」,channel 解决「安全地传数据」。它是一根类型化的管道:一个 goroutine 往里 ch <- x 送,另一个 <-ch 收。最妙的是无缓冲 channel——它是一次握手:送的人必须等到有人来收,两个 goroutine 在这一刻碰头、交接、各自继续。这一章用真调度器带你看清 channel 怎么阻塞、唤醒、close,以及缓冲大小怎么改变一切。
channel 的基本操作
ch := make(chan int) // 造一个传 int 的 channel(无缓冲) ch <- 42 // 发送:把 42 送进去(箭头指向 channel) x := <-ch // 接收:从 channel 取出来(箭头从 channel 出来) close(ch) // 关闭:告诉大家「不会再有数据了」
记箭头方向的窍门:<- 就是数据流动的方向。ch <- 42 数据流进 ch(发送),x := <-ch 数据从 ch 流出(接收)。channel 是类型化的 —— chan int 只能传 int,编译器帮你把关。
无缓冲 channel:一次握手
无缓冲 channel(make(chan int),不给容量)是理解 channel 的核心。它没有存东西的地方,所以:发送方 ch <- x 会一直阻塞,直到有一个接收方 <-ch 出现;接收方也会一直阻塞,直到有发送方。两边必须同时到场,数据当面交接 —— 这叫「握手」(rendezvous)。
跑下面这台真调度器的第一个场景「无缓冲 cap 0」,一步步看这个握手:
观察第一个场景:生产者每 send 一个数,就卡在那里(阻塞),直到消费者来 recv 把它接走,生产者才能继续送下一个。两个 goroutine 严格地一送一收、交替推进。这个「送的人必须等收的人」的特性,其实是一种天然的同步 —— 你不用额外加锁、不用 WaitGroup,channel 本身就让两个 goroutine 步调一致了。
「不要通过共享内存来通信;要通过通信来共享内存。」
Don't communicate by sharing memory; share memory by communicating.
这是 Go 并发哲学的总纲。传统做法(Java 的默认)是「共享内存 + 加锁」:几个线程访问同一个变量,用 synchronized / 锁保护它别被同时改。这容易出错 —— 忘了加锁、锁的粒度、死锁、竞态,全是坑。
Go 提倡反过来:让数据在 goroutine 之间「流动」而不是「共享」。与其让两个 goroutine 抢一个共享变量,不如让一个 goroutine 持有数据,用完通过 channel「交给」下一个 —— 同一时刻只有一个 goroutine 碰这份数据,天然没有竞态。channel 把「谁现在拥有这份数据」这件事,变成了看得见的数据流动。不是没有共享,是把共享变成了有序的传递。
缓冲 channel:解耦节奏
给 channel 一个容量,它就成了缓冲 channel:make(chan int, 2) 能存 2 个。这时发送方不用每次都等接收方 —— 只要缓冲没满,送进去就走;缓冲满了才阻塞。切 Demo 的第二、三个场景对比看:
- cap 0(无缓冲):生产者每送一个都得等消费者收,严格交替。
- cap 2:生产者能抢先塞满 2 个再阻塞,和消费者拉开了 2 个的节奏差。
- cap 4:生产者一口气把 4 个全塞进去、close 掉就跑完了,消费者慢慢收。生产和消费完全解耦。
新手常误以为「加缓冲 = 更快」,然后随手给个大容量。缓冲的真正作用是解耦生产者和消费者的节奏 —— 允许生产者短暂地跑在消费者前面,吸收速度的波动。它是一个队列,不是一个性能开关。
容量该给多少,取决于你对「生产和消费的速度差」的理解,而不是「越大越快」。给太大只是把问题(消费跟不上)藏起来、拖延暴露,还吃内存。默认从无缓冲开始(它逼你面对同步),确实需要吸收突发时再给一个有意义的容量。
close 与 range:优雅地说「没了」
close(ch) 宣告「这个 channel 不会再有新数据」。它配合两个东西用。一是接收时的 comma-ok(又是它!)判断关没关:
v, ok := <-ch // ok 为 false 表示 channel 已关闭且被取空了,v 是零值
二是更常用的 —— 用 range 遍历一个 channel,它会不断接收,直到 channel 被关闭:
// 生产者
go func() {
for i := 1; i <= 4; i++ {
ch <- i
}
close(ch) // 发完了,关掉。这是给消费者的「结束」信号
}()
// 消费者:range 会一直收,直到 close,然后自动退出循环
for v := range ch {
fmt.Println("消费", v)
}
fmt.Println("range 结束(channel 已关闭)")
Demo 的所有场景生产者都以 close(ch) 收尾、消费者用 range 收 —— 你能看到 range 收完最后一个后自动结束。close 是「发送方告诉接收方:活干完了」的标准方式,比用一个额外的信号 channel 优雅得多。
只有发送方能 close,接收方不能(谁生产谁负责关)。向已关闭的 channel 发送 → panic(第 14 章说的「程序 bug」级错误)。close 已关闭的 channel → panic。而从已关闭的 channel 接收是安全的 —— 取到零值、ok 为 false。还有一条:向 nil channel 收发会永远阻塞(不是 panic,是死等,常导致 Demo 第四个场景那种 deadlock)。这几条是 channel bug 的高发区,遇到 send on closed channel 的 panic,就去查「谁在别人关了之后还在发」。
单向 channel:把意图写进类型
你可以把 channel 限制成只能发或只能收,通常用在函数参数上,表达「我这个函数只会往里发 / 只会从里收」:
// chan<- int 只能发送;<-chan int 只能接收
func produce(out chan<- int) { // 我只往 out 发
out <- 42
}
func consume(in <-chan int) { // 我只从 in 收
fmt.Println(<-in)
}
这是又一处「把意图摊到类型上」的 Go 式设计 —— 看函数签名 chan<- int 就知道它只发不收,编译器还会拦住你在只发的 channel 上误写接收。小细节,但很 Go。
Kotlin 对照
Kotlin 其实也有 Channel,而且概念高度相似(Kotlin 的协程 Channel 就是受 Go 和 CSP 启发的):send / receive、缓冲 / 无缓冲(Rendezvous)、close、for x in channel。你从 Kotlin 过来,channel 的心智模型几乎能直接搬。更常用的 Flow 则是「冷流 + 一堆操作符(map/filter/collect)」,那是 Go channel 没有的高层抽象。
但 Go 把 channel 放在了一个中心得多的位置。在 Kotlin 里,Channel 是并发工具箱里的一件;日常你更多用 Flow、suspend 函数、结构化并发。在 Go 里,channel 就是并发的主干 —— goroutine + channel 是默认的并发范式,「用通信代替共享内存」是写在文化基因里的第一选择。Go 没有 Flow 那种流操作符生态,它的哲学是「用小小的 channel + goroutine + select(下一章)手工搭出你要的数据流」,更原始,也更透明。
迁移心法:你在 Kotlin 里习惯先想「用哪个 Flow 操作符」,在 Go 里要换成先想「让数据在哪些 goroutine 之间怎么流动」。channel 是那根流动的管子,而不是一个待变换的流。习惯了这个「画数据流水线」的思路,你就摸到 Go 并发的门道了。
这一章的一句话
channel 是 goroutine 间传数据的类型化管道:无缓冲 channel 是一次握手(send 必须等 recv,天然同步),缓冲 channel 解耦生产消费的节奏(是队列不是提速开关),close + range 是发送方说「没了」的优雅方式;核心哲学是「用通信共享内存」——让数据有序流动,而不是抢一个加锁的共享变量。
有了 channel,还差一件事:如果我要同时等好几个 channel、哪个先来就处理哪个、还要能超时、能取消,怎么办?那是 select 的舞台 —— 下一章。