channel
需要预先了解:
1. channel
channel 是 goroutine 之间传递数据的管道,也是 Go 并发模型的核心。它同时承担两件事:传递数据和同步。这一篇讲清楚无缓冲与有缓冲的差别、close 的规则、select 的多路复用,以及几个会让程序直接崩溃的操作。
1.1 设计哲学
Go 的并发信条是:
不要通过共享内存来通信,而要通过通信来共享内存。
意思是:与其让多个 goroutine 抢着读写同一块内存(然后用锁保护),不如让数据在 goroutine 之间传过来,同一时刻只有一方持有它。channel 就是传递的载体。
1.2 无缓冲 channel:同步语义
make(chan T) 创建的是无缓冲 channel。它的发送和接收必须同时就位才能完成——发送方会一直阻塞,直到有接收方来取:
ch := make(chan int)
go func() {
fmt.Println("发送前")
ch <- 1
fmt.Println("发送完成")
}()
time.Sleep(100 * time.Millisecond)
fmt.Println("接收前")
v := <-ch
fmt.Println("收到", v)
time.Sleep(100 * time.Millisecond)
发送前
接收前
收到 1
发送完成
时间线是这样的:
发送方 接收方
| |
| ch <- 1 |
|=== 阻塞,等待接收者 ======|
| | v := <-ch
|<---- 值交付,双方继续 ----|
| |
| 打印"发送完成" | 打印"收到 1"
“发送完成"排在"收到 1"之后,说明发送方是在接收方取走值之后才继续执行的。这种语义叫 rendezvous(会合):无缓冲 channel 不是"把值放进某个地方”,而是"把值交到对方手上"。
1.3 有缓冲 channel
make(chan T, n) 创建容量为 n 的缓冲 channel:
- 缓冲区没满时,发送方不阻塞,直接把值放进缓冲区;
- 缓冲区满了时,发送方阻塞,直到有人取走一个;
- 缓冲区空时,接收方阻塞,直到有人放进来。
ch := make(chan int, 2)
fmt.Println("容量:", cap(ch), "当前长度:", len(ch)) // 2 0
ch <- 1
ch <- 2
fmt.Println("两个值都在缓冲区里,长度:", len(ch)) // 2
fmt.Println("接收:", <-ch) // 1
fmt.Println("接收:", <-ch) // 2
len(ch) 返回当前缓冲区里的元素个数,cap(ch) 返回容量。缓冲区的作用是解耦发送与接收的节奏:允许发送方提前跑一段,不必每一步都等接收方。
选择容量时考虑:
- 容量为 0(无缓冲):强调"交接"语义,适合信号、同步;
- 容量为 1:常见的"最多缓存一个任务"模式;
- 容量较大:批量场景、削峰,但会掩盖背压(back pressure),也可能让 goroutine 堆积。
1.4 close
发送方完成任务后关闭 channel,通知接收方"不会再有数据了"。
接收端的两种写法
带 ok 判断:
v, ok := <-ch
if !ok {
// channel 已关闭且缓冲区已空
}
for range 会在 channel 关闭且数据读完后自动退出:
ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch)
for v := range ch {
fmt.Println("range 读到:", v)
}
range 读到: 1
range 读到: 2
关闭后再读,会立即返回零值加 ok == false:
v, ok := <-ch
fmt.Println("关闭后再读:", v, ok) // 0 false
关闭的责任
由发送方关闭,接收方永远不要关闭。原因是:接收方可能有多个,任何一方关闭都会让其他接收方拿到"数据结束"的错误信号;而发送方最清楚自己什么时候不再发送。
需要"通知接收方停止"时,用另一个 channel 或者 context(见 context),而不是让接收方关闭数据 channel。
三个致命操作
try("向已关闭的 channel 发送", func() {
ch := make(chan int, 1)
close(ch)
ch <- 1
})
try("重复关闭 channel", func() {
ch := make(chan int)
close(ch)
close(ch)
})
try("关闭 nil channel", func() {
var ch chan int
close(ch)
})
向已关闭的 channel 发送 -> panic: send on closed channel
重复关闭 channel -> panic: close of closed channel
关闭 nil channel -> panic: close of nil channel
这三个 panic 都可以被 recover,但正确做法是从设计上避免:保证只有一个 goroutine 负责关闭,且关闭前不再发送。
1.5 channel 的元素是拷贝
发送到 channel 的值会被复制一份,接收方拿到的是副本:
type Message struct {
Text string
}
msg := Message{Text: "原始"}
ch <- msg
msg.Text = "修改后"
got := <-ch
fmt.Println("发送方看到的:", msg.Text) // 修改后
fmt.Println("接收方收到的:", got.Text) // 原始
如果元素是切片、map 或指针,拷贝的只是表头或地址,底层数据仍然共享:
s := []int{1, 2, 3}
sch <- s
s[0] = 100
fmt.Println("接收方收到的切片:", <-sch) // [100 2 3]
用大结构体作为 channel 元素时,每次收发都是一次完整拷贝。元素较大时考虑传指针,但要自己保证不再并发访问它。
1.6 单向 channel
channel 类型可以带方向,用来在 API 上限制使用方式:
func produce(out chan<- int) { // 只能发送
out <- 1
out <- 2
close(out)
}
func consume(in <-chan int) { // 只能接收
for v := range in {
fmt.Println("消费:", v)
}
}
双向 channel 可以隐式转换成单向 channel,反之不行。函数参数用单向类型,可以明确表达"这个函数只会往里面写"或"只会从里面读",调用方也无法误用。
1.7 nil channel 的行为
| 操作 | nil channel | 已关闭的 channel |
|---|---|---|
| 发送 | 永久阻塞 | panic |
| 接收 | 永久阻塞 | 立即返回零值、ok=false |
close |
panic | panic |
len/cap |
0 | 0 |
nil channel 的"永久阻塞"特性在 select 里很有用——把某个分支的 channel 设为 nil,就相当于临时禁用这个分支:
var nilCh chan int
select {
case <-nilCh:
fmt.Println("不会执行")
default:
fmt.Println("nil channel 在 select 中相当于禁用该分支")
}
如果没有任何 case 就绪,整个 goroutine 会永久阻塞,运行时会报告:
fatal error: all goroutines are asleep - deadlock!
这是运行时对"所有 goroutine 都无法继续"的检测,属于 fatal error,不能 recover。
1.8 select
select 同时等待多个 channel 操作,哪个就绪就执行哪个:
select {
case v := <-ch1:
fmt.Println("从 ch1 收到", v)
case v := <-ch2:
fmt.Println("从 ch2 收到", v)
case ch3 <- 42:
fmt.Println("往 ch3 发送成功")
}
多个分支同时就绪时,随机选择一个,不会按书写顺序优先:
for i := 0; i < 6; i++ {
select {
case v := <-ch1:
fmt.Println("从 ch1 收到", v)
ch1 <- v
case v := <-ch2:
fmt.Println("从 ch2 收到", v)
ch2 <- v
}
}
从 ch1 收到 1
从 ch2 收到 2
从 ch2 收到 2
从 ch2 收到 2
从 ch2 收到 2
从 ch2 收到 2
这个随机化是为了防止某个分支长期"饿死"。
default 分支
加上 default 后 select 不再阻塞:所有 channel 都没就绪时立刻走 default:
select {
case v := <-empty:
fmt.Println("收到", v)
default:
fmt.Println("没有数据,不阻塞")
}
这是"非阻塞读"的写法。在 for 循环里用 default 要小心,它会变成忙等(CPU 空转),通常要配 time.Sleep 或改用阻塞式 select。
空的 select
select {}
永远阻塞,常用于让 main 不退出(比如只靠后台 goroutine 工作的程序)。它会让运行时报告死锁,除非还有其他活跃 goroutine。
1.9 超时模式
channel 操作没有超时参数,超时靠 select + time.After 组合:
slow := make(chan string)
go func() {
time.Sleep(200 * time.Millisecond)
slow <- "结果"
}()
select {
case v := <-slow:
fmt.Println("收到:", v)
case <-time.After(50 * time.Millisecond):
fmt.Println("超时了")
}
超时了
time.After(d) 返回一个在 d 之后收到当前时间的 channel。注意它创建的定时器在超时前不会被回收,循环里频繁调用会有内存压力——需要复用时用 time.NewTimer 并显式 Stop(见 标准库(一):IO 与系统交互)。
更完整的取消与超时控制由 context 提供,见 context。
1.10 练习
- 用无缓冲 channel 实现"两个 goroutine 交替打印奇偶数":一个负责奇数,一个负责偶数,靠 channel 交接顺序。
- 写一个生产者—消费者程序,生产者发 100 个任务,3 个消费者并发处理,用
close和for range让消费者优雅退出。 - 实现一个
or(channels ...<-chan any) <-chan any函数,把多个 channel 合并成一个(提示:每个输入起一个 goroutine 转发)。



