跳至内容
Channel 深水区:关闭原则、缓冲语义与 select 陷阱

Channel 深水区:关闭原则、缓冲语义与 select 陷阱

2026年6月28日·
yanlong

Channel 同时承担两种角色:传递数据,以及在 goroutine 之间建立同步关系。只把它理解成“线程安全队列”,会错过无缓冲 channel 的会合语义,也很容易在关闭责任上制造 panic。

一个可靠的 channel 设计,应该先写清楚谁发送、谁接收、谁关闭,再决定容量。

无缓冲 Channel 是一次会合

ch := make(chan Job)
ch <- job

发送会一直阻塞,直到某个接收者接收该值。成功发送不仅传递 job,还建立同步关系:发送之前发生的写入,对应接收完成之后可见。

var message string
done := make(chan struct{})

go func() {
    message = "ready"
    close(done)
}()

<-done
fmt.Println(message) // 保证看到 ready

这里真正提供可见性保证的是 channel close 与 receive 之间的 happens-before,而不是“goroutine 大概已经执行完了”。

缓冲 Channel 解耦的是一段时间,不是责任

jobs := make(chan Job, 100)

缓冲允许发送方在队列未满时继续执行。它可以吸收短暂突发,却不能解决长期生产速度高于消费速度的问题。缓冲满后,发送照样阻塞。

容量应该来自可解释的系统约束,例如允许排队的请求数、单个请求占用的最大内存和可接受等待时间,而不是“先写 1000 看看”。

内存上界 ≈ channel 容量 × 单个元素实际持有的数据量

如果元素包含指向大对象的指针,unsafe.Sizeof(element) 并不能反映真实保留内存。

关闭是广播“不会再有值”

接收关闭的 channel 会立即返回元素零值,并令 ok == false

value, ok := <-ch
if !ok {
    // channel 已关闭且缓冲已排空
}

for range 会自动读取到关闭且排空:

for value := range ch {
    consume(value)
}

关闭不是销毁 channel,也不是通知发送方“请停止”。向已关闭 channel 发送会 panic,再次关闭也会 panic。

谁负责关闭

最稳妥的规则是:由唯一发送方关闭,因为只有发送方知道未来不会再有值。

多个发送方存在时,不应让接收方猜它们何时结束。可以用 WaitGroup 在协调者中关闭:

func merge(inputs ...<-chan int) <-chan int {
    output := make(chan int)
    var wg sync.WaitGroup
    wg.Add(len(inputs))

    for _, input := range inputs {
        input := input
        go func() {
            defer wg.Done()
            for value := range input {
                output <- value
            }
        }()
    }

    go func() {
        wg.Wait()
        close(output)
    }()

    return output
}

后面的流水线文章会给这个例子补上取消;没有取消时,如果下游提前停止消费,转发 goroutine 仍可能阻塞。

只发送和只接收让所有权可见

func produce() <-chan Event
func consume(events <-chan Event)
func publish(out chan<- Event, event Event)

方向类型不仅防止误操作,还让 API 读者知道谁掌握什么能力。返回 <-chan Event 的生产者保留发送和关闭责任,调用方只能接收。

nil Channel 的价值

对 nil channel 的发送和接收都会永远阻塞。直接操作它通常是 bug,但在 select 中可以用 nil 动态禁用 case:

func sendAll(ctx context.Context, out chan<- int, values []int) error {
    var next chan<- int
    var value int

    for len(values) > 0 {
        next = out
        value = values[0]

        select {
        case <-ctx.Done():
            return ctx.Err()
        case next <- value:
            values = values[1:]
        }
    }
    return nil
}

更常见的是状态机:当某一路暂时不可用时,把对应 channel 变量设为 nil,它的 case 就不会被选中;恢复时再赋回真实 channel。

select 不提供业务优先级

当多个 case 同时就绪时,select 会伪随机选择一个可执行分支。case 写在上面不代表优先级更高:

select {
case <-ctx.Done():
    return ctx.Err()
case job := <-jobs:
    process(job)
}

如果取消和任务同时就绪,仍可能取到任务。需要严格优先取消时,要在执行昂贵工作前再次检查,或调整状态机,而不是依赖 case 顺序。

default 会把阻塞等待变成忙轮询:

for {
    select {
    case value := <-ch:
        consume(value)
    default:
        // 循环高速空转,占满 CPU
    }
}

非阻塞发送适合明确允许丢弃的遥测数据:

select {
case metrics <- sample:
default:
    dropped.Add(1)
}

但“队列满了就静默丢业务任务”通常不是降级,而是数据丢失。丢弃策略必须成为可观测、可测试的业务决定。

用 Channel 发送错误和结果

不要为值和错误分别建两个 channel,否则接收顺序与关闭语义会变复杂。把一次操作的结果合在一个值里:

type Result[T any] struct {
    Value T
    Err   error
}

如果只产生一个结果,容量为 1 往往能避免调用方取消时发送方卡在交付结果的瞬间;但底层工作本身仍应支持 Context。

Channel 不是所有同步问题的答案

Channel 适合传递所有权、编排阶段和发送事件。保护一个进程内小 map 的复合不变量,sync.Mutex 往往更直白;只维护计数器,atomic 可能更合适。

“不要通过共享内存来通信”是一条设计引导,不是禁止锁。选最能直接表达不变量的原语。

Channel 设计检查

  • 发送者和接收者分别是谁?
  • 谁关闭,为什么它能确定不再发送?
  • 下游提前退出时,上游发送能否取消?
  • 容量表达了什么流量和内存上界?
  • 是否真的允许 default 分支丢弃数据?
  • 多个 select case 同时就绪时,业务是否仍然正确?

延伸阅读