Go Channel 通道 — 无缓冲、缓冲、select 与超时
上一节我们用 sync.Mutex 保护共享变量,这其实是"通过共享内存来通信"的思路。Go 还提供另一条更优雅的路——"通过通信来共享内存",这就是 Channel(通道)。Channel 是 goroutine 之间传递数据的类型安全管道:一头发送、一头接收,自带同步语义,大幅减少显式加锁。Go 团队认为这是写出清晰并发代码的首选姿势。
一、创建与基本操作
用 make(chan 元素类型) 创建,三种核心操作:
- 发送
ch <- v:把v塞进通道。 - 接收
v := <-ch:从通道取一个值。 - 关闭
close(ch):告诉接收方"不会再发了"。
无缓冲通道 make(chan int) 是同步的:发送方必须等到有接收方接住,这个发送才能完成——所以又叫会合(rendezvous)。下面这个例子如果去掉接收方的 goroutine,主 goroutine 会一直阻塞在 ch <- 42 上,最终死锁。
package main
import "fmt"
func main() {
// 无缓冲通道:make(chan T),没有容量
ch := make(chan int)
// 启动一个 goroutine 负责接收
go func() {
n := <-ch // 接收
fmt.Println("收到:", n)
}()
ch <- 42 // 发送(无缓冲:必须有人接,否则阻塞)
fmt.Println("发送完成")
}二、有缓冲通道
加第二个参数就成了有缓冲通道:make(chan int, 5) 能暂存 5 个元素。缓冲没满之前发送不阻塞、缓冲没空之前接收不阻塞,起到"削峰填谷"的作用。len(ch) 返回当前元素数,cap(ch) 返回容量。缓冲通道适合生产者和消费者速度不匹配的场景,但容量不是越大越好——它本质上是个队列,会延迟反压信号。
package main
import "fmt"
func main() {
// 有缓冲通道:容量为 3
ch := make(chan string, 3)
ch <- "a" // 不阻塞
ch <- "b" // 不阻塞
ch <- "c" // 缓冲满,再写才阻塞
fmt.Println(len(ch), cap(ch)) // 3 3
// 关闭通道:告诉接收方"不会再发数据了"
close(ch)
// for range 遍历,直到通道关闭才结束
for v := range ch {
fmt.Println(v) // a b c
}
}三、遍历通道
for v := range ch 会反复接收,直到通道被 close 才停止。所以发送方一定要记得关闭,否则接收方会永远阻塞、最终死锁报错 fatal error: all goroutines are asleep - deadlock!。约定俗成:由发送方关闭,因为接收方无法知道还有没有后续数据。从已关闭的通道接收,会拿到元素类型的零值。
四、单向通道
通道还能声明成单向的:chan<- int 只能发送、<-chan int 只能接收。它常用于函数参数——明确告诉调用方"我这个函数只往里写"或"只往外读",编译器替你把关,误操作直接编译失败。把双向通道赋给单向通道是自动转换的,反之不行。这是 Go 用类型系统表达意图的又一个例子。
五、select 多路监听
select 语句长得像 switch,但专门用来同时监听多个 channel 的收发。它会随机挑一个就绪的分支执行(就绪的都不阻塞);如果都不就绪,有 default 就走 default(实现非阻塞收发),没有就阻塞等待。多个分支同时就绪时随机选,这点很重要——可以避免某个 channel 一直被冷落。
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "来自 ch1"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "来自 ch2"
}()
// select 同时监听多个 channel,哪个先就绪就走哪个分支
for i := 0; i < 2; i++ {
select {
case msg := <-ch1:
fmt.Println(msg) // 1 秒后打印
case msg := <-ch2:
fmt.Println(msg) // 2 秒后打印
}
}
}六、超时模式
现实中我们绝不能让程序永远等一个 channel。经典做法是把接收放进 select,再配一个 time.After(duration) 分支——一旦超时,就走超时分支,避免 goroutine 被永久挂起(协程泄漏)。这是 Go 并发的看家本领,几乎所有"调外部服务"的代码都会用。注意 time.After 每次都会创建一个定时器,频繁调用要考虑用 time.NewTimer 复用。
package main
import (
"fmt"
"time"
)
func slowWork() chan string {
ch := make(chan string, 1) // 带缓冲,发送不阻塞,协程不会泄漏
go func() {
time.Sleep(3 * time.Second) // 故意很慢
ch <- "完成"
}()
return ch
}
func main() {
ch := slowWork()
// 经典超时模式:select 配 time.After
select {
case res := <-ch:
fmt.Println("收到:", res)
case <-time.After(1 * time.Second):
fmt.Println("超时了,不等了") // 1 秒后走这里
}
// 单向通道:常用于函数参数,约束只能发或只能收
sendOnly := func(ch chan<- int) { ch <- 1 }
recvOnly := func(ch <-chan int) int { return <-ch }
bidir := make(chan int, 1) // 双向通道
sendOnly(bidir) // 自动转成"只发送"
fmt.Println("收到:", recvOnly(bidir)) // 收到: 1
}七、关闭通道的注意事项
关闭通道有几条铁律,违反其中任何一条都会触发 panic:
- 只有发送方应该关闭通道——接收方关闭会让发送方下次发送触发 panic。
- 不能重复关闭同一个通道,否则 panic。
- 关闭后仍可接收已缓冲的剩余值;取完后接收返回零值和
false(用v, ok := <-ch判断)。 - 向已关闭的通道发送会 panic。
所以"到底要不要关闭"也要看场景:如果接收方靠 for range 等结束信号,就得关;如果是一个长期运行的事件流(比如全局的日志 channel),通常不关,程序退出时自然回收。
小结
- 无缓冲通道同步会合,有缓冲通道异步削峰。
for range遍历直到close;由发送方关闭。select多路监听 +time.After实现超时,是 Go 并发的看家本领。- 单向通道用于函数参数,让意图更清晰、编译器替你把关。
上一篇 Go 并发 Goroutine | 下一篇 Go 包管理与 go mod。