本节摘要:channel 是 goroutine 之间类型安全的通信管道,也是 Go 并发代码正确性的主要来源。本节讲清收发阻塞规则、缓冲的语义、关闭的责任归属与单向窗口的约束力,并拆开窗口看一眼内部结构。学完本节,生产者消费者这类结构你可以在十行内写出生产级品质。
员工就位,本节开通他们之间的传话窗口。channel 之于 Go 并发,相当于签名之于合同——一切交接都过它、留痕、有时序保证。它同时也是新手事故高发区:死锁、panic、泄露多半源于没吃透"什么时候阻塞、谁负责关闭"这两条规则。本节的目标就是让这两条规则变成条件反射。
package main import "fmt" func main() { ch := make(chan string) // 无缓冲窗口:一手交、一手接 go func() { ch <- "巡检完成" // 发送:把值递进窗口 }() msg := <-ch // 接收:从窗口取值 fmt.Println(msg) } // 运行输出: // 巡检完成
channel 是带类型的:chan string 只传字符串,类型在编译期把守。发送用右箭头 ch <- v,接收用左箭头 v := <-ch,箭头方向永远表示数据流向。上面这个例子的时序值得逐帧看:子 goroutine 的发送会阻塞到主 goroutine 执行接收那一刻——两个动作像握手,谁先到谁等对方,完成即双向确认。这个" rendezvous(会合)"语义就是无缓冲窗口的全部:它传的不只是数据,还有确切的同步点。
| 窗口类型 | 发送方 | 接收方 |
|---|---|---|
| 无缓冲 | 阻塞,直到有接收者到达 | 阻塞,直到有发送者到达 |
| 有缓冲(未满) | 立即完成,值入队 | 缓冲非空则立即取;空则阻塞等发送 |
| 有缓冲(已满) | 阻塞,直到接收方腾出空间 | 同上 |
| 已关闭 | 发送引发 panic | 立即返回;缓冲排空后第二返回值为 false |
这张表是 channel 全部行为学的浓缩。三个高频推论:无缓冲窗口等价于容量为零的接力棒,交接必须当面;有缓冲窗口是容量有限的信箱,满员时寄件人排队;往已关闭的窗口发送会 panic,所以关闭永远由发送侧在确认不再发送后执行。
关闭做的事只有一件:宣告"不会再有新消息"。它给接收侧两个信号——range 循环自然结束;普通接收通过第二返回值区分"取到真值"与"窗口已空":
package main import "fmt" func producer(out chan<- int) { // 单向窗口:只能发 defer close(out) // 发送方负责关闭,铁律 for i := 1; i <= 3; i++ { out <- i * 10 } } func main() { ch := make(chan int) go producer(ch) // 写法一:range 到关闭自动停 for v := range ch { fmt.Println("收到:", v) } // 写法二:第二返回值判空 go producer(ch) for { v, ok := <-ch if !ok { fmt.Println("窗口已关且无货,收工") break } fmt.Println("收到:", v) } } // 运行输出: // 收到: 10 // 收到: 20 // 收到: 30 // 窗口已关且无货,收工 // 收到: 10 // 收到: 20 // 收到: 30 // 窗口已关且无货,收工
两条纪律请焊死:只有发送方关窗口(接收方关了会让发送方 panic);多发送方场景不关业务窗口(关了会发生发送 panic),改用额外的完成窗口广播收工,3.2 的 close 广播正是此用法。

看懂内部结构,阻塞规则就不再是需要背诵的条文,而是这幅图的必然推论:缓冲区满,发送者只能在右侧队列挂起;缓冲区空,接收者在左侧挂起;两侧都有人时,运行时直接把值从发送队列搬运到接收队列,缓冲区都不经过。
函数参数可以声明窗口的方向性:chan<- int 只能发,<-chan int 只能收。编译器会拒绝一切越权操作:
// 报表员:只发不收 func reporter(out chan<- string) { out <- "本轮巡检无异常" close(out) } // 汇总台:只收不发 func collector(in <-chan string, done chan<- struct{}) { for range in { // 逐条消化,关闭后循环退出 } done <- struct{}{} } func main() { ch := make(chan string) done := make(chan struct{}) go reporter(ch) go collector(ch, done) <-done fmt.Println("汇总完成") } // 运行输出: // 汇总完成
main 里的 ch 是双向的,传给两个函数时被各自收窄成单向视角——窗口本体没变,变的只是函数被授权的权限。这个特性让接口意图自文档化:看到签名就知道谁能发、谁能收、谁负责关。
背景:告警产生是突发的,一分钟内可能涌入上百条,下游发送通道每秒只消化几条,直接逐条转发会拖垮生产端。
操作:中间架一个有缓冲窗口当蓄水池,生产端满员时选择丢弃计数而不是无限堆积,消费端用 range 持续消化。
package main import "fmt" func main() { alerts := make(chan string, 4) // 蓄水池容量 4 dropped := 0 // 生产端:突发 8 条告警 go func() { defer close(alerts) for i := 1; i <= 8; i++ { a := fmt.Sprintf("告警 %d", i) select { case alerts <- a: fmt.Println("入队:", a) default: // 蓄水池满,降级为丢弃并计数 dropped++ fmt.Println("溢出丢弃:", a) } } }() // 消费端:range 排空到关闭为止 n := 0 for range alerts { n++ } fmt.Println("实际送达:", n, "丢弃:", dropped) } // 运行输出: // 入队: 告警 1 // 入队: 告警 2 // 入队: 告警 3 // 入队: 告警 4 // 溢出丢弃: 告警 5 // ...(5 到 8 均溢出) // 实际送达: 4 丢弃: 4
结果:容量 4 的蓄水池收下前四条,后四条被显式丢弃并计数,消费端如数送达四条。
解读:这里的 default 分支来自 3.4 的非阻塞惯用法,提前露面是为了让案例完整——缓冲解决的是速率差,不是无限堆积。背压设计必须有明确的满员策略:丢弃计数、覆盖旧值或阻塞生产端,按业务容忍度选,"不设策略"本身就是最贵的选择。另外注意 dropped 变量只在生产端 goroutine 内读写,消费端不碰它,依然无锁。
变式:把丢弃策略改成"覆盖最旧"——环形缓冲语义在标准库没有现成品,要么自己加锁实现,要么换成带容量约束的第三方队列结构。做完这个变式你会体会到:channel 适合排队传递,一旦要"随机访问历史元素",就该换回共享结构加锁的组合了。
一个窗口只能盯一路消息,值班台前往往几十路并发——下一节的 select,就是把多路监听变成一行语法的那件工具。