goroutine 之间如何安全地交换数据、如何表达“没有更多结果了”、如何在多个就绪通道里做非确定性选择——这些都落在 channel 与 select 上。Go 语言规范把 channel 定义为并发函数之间按指定元素类型发送/接收的通信机制;官方博客的口号则是:
Do not communicate by sharing memory; instead, share memory by communicating.
本文依据 Go 语言规范(Channel types / Send / Receive / close / Select statements)、Effective Go 的 Channels 章节、官方博客 Share Memory By Communicating 与 Go Concurrency Patterns: Pipelines and cancellation,把无缓冲/有缓冲阻塞规则、关闭语义、select 调度、管道关闭与取消讲清楚,并给出可复现实验与排错清单。它和「context 取消传播」互补:context 解决“该停了”,channel/select 解决“值怎么传、何时结束、多路怎么选”。
一、问题背景:共享内存锁 vs 通道通信
传统多线程模型里,线程通过共享数据结构 + 锁协作:谁拿到锁谁改状态。Go 的 CSP 风格把协作点显式化——发送/接收本身就是同步点。
常见踩坑往往不是“不会写 ch <- v”,而是对阻塞条件判断错误:
- 无缓冲通道上“先发后收”在单 goroutine 里会永久阻塞;
- 向已关闭通道发送触发 runtime panic;
- 对
nil通道发送/接收会永远阻塞,select里一不小心就“整段卡死”; - 只
close不约定所有权,多发送者并发 close 直接 panic; - 管道中游生产者退出却不 close,下游
range永不结束。
把规范里的规则记成表,比背 API 更有用。
二、核心模型:无缓冲握手与有缓冲队列
1. 创建与零值
var ch chan int // nil channel,零值
ch = make(chan int) // 无缓冲(容量 0)
ch2 := make(chan int, 0) // 同样无缓冲
buf := make(chan int, 8) // 缓冲容量 8
规范要点:
| 概念 | 规范语义 | 工程含义 |
|---|---|---|
| 元素类型 | chan T / 方向 chan<- T、<-chan T | 编译期限制发送/接收方向 |
| make 容量 | 可选 capacity;0 或省略 → unbuffered | 无缓冲 = 同步通信 |
| 缓冲通道 | 缓冲未满可发送、非空可接收且不阻塞 | 解耦生产/消费速率 |
| 零值 nil | 未初始化的 channel 为 nil | 对 nil 收发会永久阻塞 |
| FIFO | 通道是先进先出队列 | 多 goroutine 共用同一通道无需额外同步结构(仍要注意关闭约定) |
无缓冲通道:只有发送方与接收方都就绪时通信才成功——值直接从发送方拷到接收方,同时完成同步。有缓冲通道:发送在缓冲有空位时即可完成;接收在缓冲有数据时即可完成。
Effective Go 的表述更偏工程直觉:
- 接收方总是阻塞到有数据可收;
- 无缓冲时,发送方阻塞到接收方取走值;
- 有缓冲时,发送方阻塞到值被拷入缓冲;缓冲满则等到有接收方腾出空位。
2. 发送 / 接收何时可推进
规范对 send 的可推进条件可以压缩成:
| 操作 | 可立即推进的条件 | 否则 |
|---|---|---|
| 无缓冲发送 | 已有接收方就绪 | 阻塞 |
| 有缓冲发送 | 缓冲未满 | 阻塞 |
| 向 closed 发送 | — | runtime panic |
| 向 nil 发送 | — | 永久阻塞 |
| 接收(开通道) | 有数据或有发送方(无缓冲握手) | 阻塞 |
| 从 closed 接收 | 总是可立即推进 | 先排空缓冲,再得到零值 |
| 从 nil 接收 | — | 永久阻塞 |
// 无缓冲:必须另一端参与
ch := make(chan string)
go func() { ch <- "ready" }()
msg := <-ch
// 有缓冲:容量内发送不阻塞
b := make(chan int, 2)
b <- 1
b <- 2
// b <- 3 // 若无接收方,此处阻塞
3. 关闭、ok 与 range
内建 close(ch) 表示“不会再有新值”。规范与管道博客共同强调的接收语义:
- 关闭后,先前已发送、仍在缓冲中的值会被正常接收完;
- 排空后,接收立即返回元素类型零值且不阻塞;
- 双返回值形式
v, ok := <-ch:ok == false表示通道已关闭且没有更多值; close(nil)、close已关闭通道 → runtime panic;- 向已关闭通道发送 → panic(这是最常见的线上事故之一)。
out := make(chan int, 3)
out <- 1
out <- 2
close(out)
for v := range out { // 自动在关闭且排空后结束
fmt.Println(v)
}
v, ok := <-out
fmt.Println(v, ok) // 0 false
关闭约定(工程硬规则):
- 发送侧关闭:通常由唯一“拥有发送权”的一方 close;多个发送者需要额外同步(例如用
sync.Once或先汇合再 close)。 - 接收侧不要 close(除非协议明确且全局只有你发送——极少见)。
- 需要“广播停工”时,更常见的是 close 一个只读 done 信号通道(见下文管道取消),而不是让每个工作通道被多方乱关。
三、select:多路就绪与非确定性选择
select 像 switch,但 case 全部是通信操作。规范给出的执行步骤值得逐条对照代码:
- 进入 select 时,按源码顺序对所有 case 的通道操作数(以及发送右侧表达式)各求值恰好一次——副作用一定会发生,与最终选中哪个 case 无关;
- 若一个或多个通信可推进,用均匀伪随机选中其中一个;
- 若都不能推进且存在
default,走 default; - 若都不能推进且无 default,阻塞直到至少一个可推进;
- 仅有 nil 通道 且无 default 的 select 永久阻塞。
select {
case v := <-a:
handleA(v)
case b <- x:
// 发送成功
case <-time.After(200 * time.Millisecond):
// 超时分支(timer channel)
default:
// 非阻塞探测:此刻无人可通信
}
工程模式
1)非阻塞试探(try-send / try-recv)
select {
case ch <- job:
default:
// 队列满或无接收方:降级、丢弃或写指标
}
Effective Go 用 free-list + select/default 演示:缓冲通道兼作资源池,满则新建。
2)有缓冲通道当信号量
sem := make(chan struct{}, MaxOutstanding)
func handle(r *Request) {
sem <- struct{}{} // 占坑;满则阻塞
defer func() { <-sem }() // 释放
process(r)
}
容量限制同时在飞的 process 数。注意:若外层对每个请求都 go handle 而不做接纳控制,仍可能制造海量 goroutine——需要在入口再限流。
3)与 context 组合:可取消等待
select {
case res := <-resultCh:
return res, nil
case <-ctx.Done():
return nil, ctx.Err()
}
channel 负责传结果,context 负责传“该停了”。二者不要互相替代。
四、可复现实验:阻塞、关闭与 select
下面用标准库即可在本机验证规范语义(Go 1.20+ 均可)。
package main
import (
"fmt"
"time"
)
func mustPanic(name string, fn func()) {
defer func() {
if r := recover(); r != nil {
fmt.Println(name, "panic OK:", r)
} else {
fmt.Println(name, "ERROR: expected panic")
}
}()
fn()
}
func main() {
// 1) 无缓冲:单 goroutine 发送会死锁(用超时模拟探测)
ub := make(chan int)
go func() {
select {
case ub <- 1:
fmt.Println("unexpected send success")
case <-time.After(50 * time.Millisecond):
fmt.Println("unbuffered send blocked as expected")
}
}()
time.Sleep(80 * time.Millisecond)
// 2) 有缓冲:容量内不阻塞
b := make(chan int, 1)
b <- 42
fmt.Println("buffered len/cap:", len(b), cap(b))
// 3) 关闭后接收
close(b)
v, ok := <-b
fmt.Println("drain closed:", v, ok) // 42 true
v, ok = <-b
fmt.Println("after empty:", v, ok) // 0 false
// 4) 向关闭通道发送 → panic
mustPanic("send-on-closed", func() { b <- 1 })
// 5) close(nil) → panic
var n chan int
mustPanic("close-nil", func() { close(n) })
// 6) select 伪随机:两个都就绪时多次运行会看到不同分支
a1, a2 := make(chan int, 1), make(chan int, 1)
a1 <- 1
a2 <- 2
counts := map[string]int{}
for i := 0; i < 200; i++ {
// 重新填满,保证两 case 均可推进
select {
case <-a1:
counts["a1"]++
a1 <- 1
case <-a2:
counts["a2"]++
a2 <- 2
}
}
fmt.Println("select distribution:", counts)
// 7) 仅 nil + 无 default:用超时包裹,避免真死锁进程
var nilCh chan int
select {
case <-nilCh:
fmt.Println("unreachable")
case <-time.After(50 * time.Millisecond):
fmt.Println("nil-only select would block forever without timeout")
}
}
预期观察:
- 无缓冲在无人接收时发送走超时分支;
- 关闭通道先拿到缓冲残留,再
ok=false; - 向关闭通道发送、
close(nil)触发 panic; - 双就绪 select 的计数大致接近(伪随机,不保证 50/50 精确)。
五、管道:关闭传播与 done 取消
官方 Pipelines 博客的模型:
A pipeline is a series of stages connected by channels, where each stage is a group of goroutines running the same function.
每阶段从上游 channel 读、处理后写入下游 channel。结束信号有两种常见方式:
- 关闭数据通道:表示“没有更多输入”。因对已关闭通道的接收总可立即完成,下游
range能自然退出;发送方在发完后close(out)。 - 显式取消(done channel):当下游提前退出、不再消费时,上游若继续发送会阻塞。博客做法是额外传入
done <-chan struct{},由下游(或main)close(done) 做广播,上游在select里同时监听done与数据发送,从而放弃未完成发送。
func gen(done <-chan struct{}, nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
select {
case out <- n:
case <-done:
return
}
}
}()
return out
}
// main 中:
done := make(chan struct{})
defer close(done) // 退出时广播取消
for n := range sq(done, gen(done, 2, 3)) {
fmt.Println(n)
break // 提前结束也安全:done 关闭后上游不再堵在发送上
}
这与 context 的取消树是同一思想的两种载体:done 通道是轻量广播;复杂调用链更推荐 context.Context。
六、常见坑与排查清单
| 症状 | 可能根因 | 排查 / 修复 |
|---|---|---|
fatal error: all goroutines are asleep - deadlock! | 无缓冲自收自发;所有 goroutine 互相等待 | 用 go 起对端;检查是否缺接收/发送 |
panic: send on closed channel | 接收方误 close,或多发送者重复 close | 单一发送所有者 close;多生产者用 WaitGroup 汇合后再 close |
panic: close of closed channel / close of nil channel | 重复 close 或 close 零值 | sync.Once;初始化后再 close |
| goroutine 数量持续上涨 | 发送阻塞无人收;go handle 无限接纳 | 有缓冲限流 + context 取消;pprof goroutine |
range 永不结束 | 上游未 close | 契约:最后一个发送者负责 close |
| select “总是”走同一分支 | 另一 case 实际不可推进(nil/满/空) | 打印 len/cap、是否 nil、是否已 close |
| 定时器泄漏 | time.After 在热路径大量创建 | 复用 time.NewTimer 并 Stop |
| 数据竞争仍在 | channel 传的是指针/切片头,共享底层数组 | 发送所有权转移;或拷贝;或明确只读契约 |
补充 runtime 视角(runtime/chan.go 注释):缓冲通道上,缓冲非空时接收队列为空、缓冲未满时发送队列为空——实现上把“数据在缓冲”与“对端在等待队列”分成清晰不变量。排障时你不需要背这些字段,但要理解:阻塞 = 进入 sendq/recvq 等待被对端或 close 唤醒。
七、总结
- 无缓冲 channel = 通信 + 同步;有缓冲 = 带容量的 FIFO,用于削峰与信号量。
- 关闭是“不再发送”的信号,不是“销毁”;排空后接收零值;禁止向已关闭通道发送。
- nil 通道收发永久阻塞;
select求值有副作用,多就绪 case 伪随机,无就绪且无 default 则阻塞。 - 管道靠 close 数据通道 表达结束,靠 done/context 表达提前取消,避免上游堵死。
- 与上一篇 Go context 取消传播 搭配:context 管生命周期,channel/select 管数据面与多路选择。
把“谁发送、谁关闭、谁取消、缓冲多大”四件事写进代码评审清单,比事后查 deadlock 日志便宜得多。
参考资料
- The Go Programming Language Specification — Channel types / Send statements / Receive operator / Close / Select statements
- Effective Go — Channels
- Share Memory By Communicating (The Go Blog)
- Go Concurrency Patterns: Pipelines and cancellation (The Go Blog)
- Go runtime channel implementation (
src/runtime/chan.go) - 本站相关:Go context 取消传播与超时控制:原理、API 与工程实践