服务跑了一周,goroutine 数稳定在 20 万,CPU 却只有 5%,pprof 显示几乎所有 goroutine 都停在 chan receive。这类"并发很高、吞吐很低"的现场,八成是把 channel 当队列用,而消费端根本没起来,或者 select 少了超时分支。
channel 的语义很小,但每条都有尖锐的边界。按 Go 1.22 起的运行时行为拆。
1. 两种 channel 与容量语义
| 类型 | 发送行为 | 接收行为 | 适用 |
|---|---|---|---|
无缓冲 make(chan T) |
阻塞直到有接收方就绪 | 阻塞直到有发送方就绪 | 交接(handoff)、同步信号 |
有缓冲 make(chan T, n) |
缓冲未满时立即返回,满了阻塞 | 缓冲非空时立即返回,空了阻塞 | 解耦生产/消费速率、限流 |
ch := make(chan int) // 无缓冲:发送与接收必须"碰面"
buf := make(chan int, 8) // 有缓冲:可先存 8 个
无缓冲 channel 是同步点,不只是数据通道:发送完成即意味着对方已接收。这让它天然适合"等待某个事件发生",也让它在没有接收方时变成永久阻塞。
2. 三种操作 × 两种状态
| 操作 | channel 为 nil | channel 已关闭 |
|---|---|---|
发送 ch <- v |
永久阻塞 | panic: send on closed channel |
接收 v := <-ch |
永久阻塞 | 立即返回零值,ok == false |
关闭 close(ch) |
panic: close of nil channel | panic: close of closed channel |
len/cap |
0 / 0 | 剩余缓冲数 / 容量 |
v, ok := <-ch // ok=false 表示已关闭且缓冲已空
for v := range ch // 关闭后自动退出循环 —— 这是唯一的"正常退出"写法
记住三条:向已关闭的 channel 发送会 panic;关闭已关闭的会 panic;nil channel 上的收发永久阻塞。 第三条常被当作技巧用(见第 6 节),前两条是事故来源。
3. "通过通信共享内存"到底在说什么
// 共享内存 + 锁
mu.Lock(); counter++; mu.Unlock()
// 通信传递所有权
ch <- &task{...} // 发送后本地不再持有该指针
这句话的原意是**"数据的写入权在同一时刻只属于一个 goroutine"**,不是"禁止使用锁"。判断标准是所有权:
| 需求 | 用 channel | 用 mutex |
|---|---|---|
| 传递数据所有权 | 是,天然表达 | 需要额外约定"谁还能用" |
| 保护一个共享结构的内部状态 | 别扭,需要额外请求-响应协议 | 是,最直接 |
| 等待事件/信号 | 是 | 需要 condition variable |
| 计数器、缓存、配置 | 过度设计 | 是 |
把计数器改成 channel 请求-响应,是把简单问题复杂化;用 mutex 保护一个需要跨阶段传递的对象,则容易忘记同步。选型看的是"这块数据归谁",不是"哪种更 Go"。
4. select 的语义与三个陷阱
select {
case v := <-a:
handle(v)
case b <- 1:
case <-time.After(100 * time.Millisecond):
return errTimeout
default:
// 非阻塞分支
}
| 陷阱 | 表现 | 修法 |
|---|---|---|
| 多个分支同时就绪 | 随机选一个,不保证优先级 | 需要优先级时先做非阻塞探测,再进阻塞 select |
只有 default |
立刻返回,形成忙轮询,CPU 打满 | 去掉 default,或用 time.Sleep/ticker |
无 default 也无超时 |
所有 case 都不就绪时永久阻塞 | 加 <-ctx.Done() 或 time.After |
select 的随机性是故意的:它避免固定优先级造成饿死。如果你的逻辑依赖"channel A 优先于 B",必须显式写两次 select(先非阻塞试 A,再阻塞等 A 和 B)。
time.After 在循环里会不断创建 Timer,到期前不回收——循环内用 time.NewTicker 并 defer Stop()。
5. context 与 channel 的配合
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() // 必须调用,否则 Timer 与子 context 泄漏
select {
case v := <-ch:
return v, nil
case <-ctx.Done():
return nil, ctx.Err() // context.DeadlineExceeded / Canceled
}
| 概念 | 作用 | 常见误用 |
|---|---|---|
ctx.Done() |
返回一个关闭即代表取消的 channel | 每次调用都新建,导致无法统一取消 |
WithCancel/WithTimeout |
产生可取消的子 context | 忘记 cancel(),父链上的 Timer 不释放 |
| 取消传播 | 父取消 → 所有子取消 | 以为取消会杀掉 goroutine(它只是通知) |
取消是协作式的:goroutine 必须在 select 里监听 Done 才会退出。cancel 不是"停止",是"通知该停了"。
6. 五种常用模式
| 模式 | 结构 | 适用 | 代价 |
|---|---|---|---|
| 管道(pipeline) | 每级一个 channel,逐级处理 | 流式转换 | 级数越多,调度与缓冲开销越大 |
| 扇出/扇入 | 多个 worker 消费同一 channel,再合并结果 | 提高吞吐 | 结果顺序不保证 |
| worker pool | 固定 worker 数 + 任务 channel | 限并发、复用资源 | 队列积压需监控 |
errgroup |
一组 goroutine + 首个错误取消 | 有错误语义的并发 | 需 Go 官方扩展包 x/sync |
semaphore |
带缓冲 channel 或 x/sync/semaphore |
限流、限制外部调用并发 | 获取失败要正确释放 |
// worker pool
jobs := make(chan Job, 100)
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs { // range 在 close 后自动退出
process(j)
}
}()
}
for _, j := range all { jobs <- j }
close(jobs) // 由发送方关闭
wg.Wait()
// 信号量限流:缓冲 channel 版
sem := make(chan struct{}, 4)
for _, u := range urls {
sem <- struct{}{}
go func(u string) {
defer func() { <-sem }()
fetch(u)
}(u)
}
// 扇出扇入 + 取消
func fanIn(ctx context.Context, chs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, c := range chs {
wg.Add(1)
go func(c <-chan int) {
defer wg.Done()
for {
select {
case v, ok := <-c:
if !ok { return }
select { case out <- v: case <-ctx.Done(): return }
case <-ctx.Done():
return
}
}
}(c)
}
go func() { wg.Wait(); close(out) }() // 关闭责任集中在一处
return out
}
7. nil channel 与关闭约定
var ch chan int // nil
<-ch // 永久阻塞——可用于"关闭某个 select 分支"
// 典型用法:动态禁用分支
var out chan int
if enabled { out = realCh }
select {
case out <- v: // enabled=false 时该分支永不就绪
case <-ctx.Done():
}
把分支置 nil 可以让 select "忽略"它,这是标准技巧;代价是如果所有分支都 nil,select 永久阻塞,且这种阻塞在 pprof 里表现为 chan receive (nil chan),很容易被当成正常等待。
关闭的三条约定:
| 约定 | 理由 |
|---|---|
| 由发送方关闭 | 接收方无法知道还有没有其他发送者 |
| 不要重复关闭 | 第二次 close 直接 panic |
| 不要关闭只读 channel | 编译期就报错(<-chan T 不可 close) |
多个发送者时,标准做法是:用 sync.Once 或让一个协调者 goroutine 负责关闭,其余发送者只发不关。
8. 缓冲大小不是背压
| 缓冲设置 | 效果 | 风险 |
|---|---|---|
| 0(无缓冲) | 强背压,生产与消费严格同步 | 消费稍慢就阻塞生产 |
| 小缓冲(几个) | 削峰,抵抗调度抖动 | 缓冲一满立刻退化为无缓冲 |
| 大缓冲(上千) | 掩盖消费不足 | 队列积压变成内存占用,故障延迟暴露 |
| 按峰值估算 | 更长的容忍时间 | 峰值持续时仍然阻塞,只是来得更晚 |
缓冲的本质是"把阻塞推迟",不是"消除阻塞"。把缓冲当背压调节手段,等于把问题从"调用变慢"改成"内存上涨 + 延迟变大"。 真正的背压需要监控队列长度并拒绝新请求(如 select + default 返回 429)。
9. 排错表:常见坑
| 坑 | 表现 | 修法 |
|---|---|---|
| 向已关闭 channel 发送 | panic: send on closed channel |
关闭权集中;发送前用 select + ok 保护 |
| 多个发送者都 close | 偶发 panic | sync.Once 包装关闭 |
select 无 default 无超时 |
永久阻塞,goroutine 泄漏 | 加 ctx.Done() |
| 缓冲开太大 | 内存涨、延迟高、故障晚发现 | 小缓冲 + 队列长度指标 + 拒绝策略 |
range 前忘记 close |
消费 goroutine 永不退出 | 发送方 defer close(ch) |
| 用 channel 保护计数器 | 吞吐下降、代码复杂 | 用 sync.Mutex 或 atomic |
10. 边界表
| 场景 | 选择 | 理由 |
|---|---|---|
| 传递任务/所有权 | channel | 语义即所有权转移 |
| 保护共享结构 | sync.Mutex |
避免请求-响应开销 |
| 等待一组任务结束 | WaitGroup / errgroup |
channel 需要额外的完成计数协议 |
| 限流 | 信号量(带缓冲 channel) | 语义清晰,容量即并发上限 |
| 事件通知 | close(ch) + <-ch |
关闭是广播,比发 N 条消息便宜 |
| 需要优先级 | 多 channel + 显式两级 select | select 本身随机 |
最后一个值得展开:close 是广播。要通知 100 个 goroutine,关闭一个 channel 就够了,不需要发 100 条消息;接收方从已关闭 channel 立刻得到零值,这正是"取消信号"的实现原理。
11. 自测
- 无缓冲 channel 的"发送成功"意味着什么?这使它适合表达哪类语义?
- 向 nil channel 发送、向已关闭 channel 发送,分别是什么行为?
select多个分支同时就绪时按什么规则选?如何实现优先级?select里只写default会带来什么问题?- 为什么关闭 channel 的责任要放在发送方?多个发送者时怎么办?
- 缓冲大小为什么不能当作背压手段?真正的背压需要什么?