Go 并发代码中,经常需要让同一个 goroutine 在不同阶段监听不同事件:任务未启动时不接收数据,队列为空时不向下游发送,某个输入关闭并排空后不再关注它。

最直接的做法是为每种状态编写一套 select,但分支一多,代码很快变成重复的状态判断。Go 其实提供了一个简洁的控制手段:在进入下一轮 select 前把某个 channel 变量设为 nil,就能让对应的 case 暂时失效;恢复为有效 channel,分支又会在后续求值时重新启用。

这个技巧没有特殊语法,完全建立在 channel 的基本语义之上,却很适合实现状态机、多路合并与背压控制。

nil channel 到底是什么

channel 和 slice、map、指针一样,零值是 nil。只声明而没有使用 make 初始化,就会得到一个 nil channel

var ch chan int
fmt.Println(ch == nil) // true

它不是一个已经关闭的 channel,也不是由 make 创建的已初始化 channel。根据 Go 语言规范,向 nil channel 发送数据会永久阻塞,从中接收数据同样会永久阻塞。

var ch chan int
ch <- 1 // 永久阻塞

如果 goroutine 没有其他退出路径,单独执行这种操作会使它永久停在收发语句上。如果这导致程序中的所有 goroutine 都无法继续执行,程序也就无法取得进展;常见的独立示例会被 Go 运行时检测为死锁并终止。nil channel 真正有价值的地方,是与 select 配合使用。

select 只会从当前可以执行的通信分支中选择一个。由于 nil channel 上的通信永远无法执行,对应分支自然不会被选中,相当于被暂时禁用。这里没有关闭任何 channel。

var ch chan int
select {
case v := <-ch:
    fmt.Println(v) // 不会执行
default:
    fmt.Println("disabled")
}

这里的 case 仍然存在,编译器也会检查类型,但运行时不会选择它。只要把 ch 换成有效 channel,分支就能再次参与选择。如果有多个通信分支同时可以执行,规范要求从中进行均匀伪随机选择,不能依赖分支在源码中的先后顺序实现优先级。

用一个变量开关分支

假设服务只有在配置加载完成后才能接收任务。可以保留真实的任务 channel,再用一个局部变量控制它是否参与 select

jobs := make(chan Job)
var activeJobs <-chan Job
if ready {
    activeJobs = jobs
}

<-chan Job 表示只能接收 Job 的 channel 类型,其零值是 nil。双向的 jobs 类型为 chan Job,可以赋值给接收专用的 activeJobs;反向赋值则不成立。activeJobsnil 时,接收任务的分支处于禁用状态;赋值为 jobs 后,分支被启用。

select {
case job := <-activeJobs:
    handle(job)
case <-reload:
    activeJobs = jobs
}

需要注意,进入一次 select 时,各个通信操作的 channel 操作数,以及发送语句右侧的表达式,都会按源码顺序求值一次。分支内部对变量的修改会影响下一轮 select,不会改变本轮已经完成的选择。

这也意味着,如果某次 select 已经因为所有通信分支不可执行而阻塞,仅在其他位置把原 channel 变量改为非 nil,不会让这次 select 重新求值或自动醒来。常见做法是让同一个 goroutine 在其他可执行分支中更新变量,然后进入下一轮 select;跨 goroutine 修改还必须额外解决数据竞争问题。

这个模式的本质不是关闭或重新创建 channel,而是切换 select 看到的引用。真实的 jobs 可以继续由其他 goroutine 持有,其生命周期不会受到影响。

多路合并时移除已关闭输入

更常见的场景是把两个输入流合并到一起。当某个输入关闭并且缓冲中的数据全部读完后,需要永久停用对应分支。

双返回值接收中的 ok 可以判断本次值是否来自成功的发送操作。只有 channel 已关闭且缓冲区已经排空,接收到关闭产生的零值时,ok 才为 false

v, ok := <-ch
if !ok {
    ch = nil
}

把它放进循环,就能在不创建额外 goroutine 的情况下完成多路合并:

for left != nil || right != nil {
    select {
    case v, ok := <-left:
        if !ok { left = nil; continue }
        consume(v)
    case v, ok := <-right:
        if !ok { right = nil; continue }
        consume(v)
    }
}

循环条件同样重要。当两个输入都变成 nil 时,如果继续执行一个没有 defaultselect,所有通信分支都无法执行,goroutine 将永久阻塞。

为什么不能发现 ok == false 后仍保留这个 channel?因为此时 channel 已经关闭且排空,后续接收都能立即完成,并持续返回元素类型的零值。这样的分支会一直处于就绪状态,使循环空转并占用 CPU。将变量设为 nil,才是从后续选择中真正移除该分支。

队列为空时禁用发送

nil channel 不只可以控制接收分支,也能控制发送分支。一个典型用途是在 goroutine 内维护待发送队列:队列为空时不允许发送,队列有数据时才启用输出。

假设外层循环的条件是 input != nil || len(queue) > 0。每轮先根据队列状态准备输出 channel 和待发送值:

inCh := input
var outCh chan<- Event
var next Event
if len(queue) > 0 {
    outCh = output
    next = queue[0]
}

随后用同一个 select 同时接收新事件并发送队首事件:

select {
case event, ok := <-inCh:
    if !ok { input = nil; continue }
    queue = append(queue, event)
case outCh <- next:
    queue = queue[1:]
}

队列为空时,outCh 保持为 nil,发送分支被禁用,不会误发 next 的零值。队列非空时,它指向真实的 output,只有下游能够接收,发送分支才会执行。当接收返回 ok == false 时,必须把 input 设为 nil,否则接收分支会持续得到零值;队列排空后,外层循环才能安全结束。

这种结构把“是否允许发送”编码进 channel 值本身,不需要为队列为空和非空分别维护两套 select,状态分支更容易保持一致。

用 nil 实现背压

如果内部队列必须限制长度,还可以反过来控制输入:队列达到上限时,把输入分支设为 nil,暂停接收;队列腾出空间后,再恢复真实 channel。

inCh := input
if len(queue) >= maxQueue {
    inCh = nil
}

前一节的 select 正是从 inCh 接收。内部队列达到上限时,它仍可向下游发送,但不会继续从上游取数据。发送成功、队列长度下降后,下一轮重新把 inCh 指向 input,接收自动恢复。maxQueue 应当是大于零的有效上限,否则输入和输出可能同时被禁用。

这是一种协作式背压:本地队列已满后不再消费输入。若 input 带缓冲,上游还可以写入其剩余容量;缓冲填满后,阻塞才会继续向上游传播。如果上游必须无等待写入,就需要明确采用丢弃、落盘或扩容策略,不能期待 nil channel 自动解决容量问题。

还要警惕带 default 的循环。default 会让 select 在没有通信可执行时立即返回,如果外层没有休眠或其他阻塞点,就可能形成忙循环。仅为了“避免阻塞”而添加 default,通常会把正确的等待变成无意义的 CPU 消耗。

三个边界必须分清

第一,nil channel 与已关闭 channel 的行为完全不同。前者上的收发永远无法完成;后者上的接收可以立即执行,会先取出关闭前已经发送的值,排空后持续返回零值。向已关闭的 channel 发送,或者再次关闭它,都会引发 panic。

第二,不要关闭 nil channel。对于类型上允许发送、但值为 nil 的 channel,调用 close 会引发 panic;接收专用的 <-chan T 则根本不能作为 close 的参数,代码无法通过编译。动态禁用分支只需要赋值为 nil,不需要调用 close

第三,修改共享的 channel 变量仍然需要同步。如果多个 goroutine 同时读写同一个变量,可能产生数据竞争。更稳妥的结构是让一个 goroutine 独占状态和 select 循环,其他 goroutine 通过控制 channel 发送启用、暂停或退出命令。

nil channel 也不应被当成普通业务数据随意传播。它适合作为并发状态机内部的控制值;如果函数可能返回 nil channel,应在 API 契约中清楚说明,否则调用方很容易永久等待。

写在最后

nil channel 的作用可以概括为一句话:利用“永远无法通信”的语义,让某个 select case 暂时不参与竞争。

将有效 channel 赋给局部变量,是启用分支;将变量设为 nil,是禁用分支。这个简单切换可以移除已关闭并排空的输入、避免空队列发送、限制内部缓冲,并把复杂的并发状态收敛到一个清晰的 select 循环中。

真正需要关注的不是技巧本身,而是边界:所有分支都禁用时会永久阻塞,已经关闭并排空的输入应及时从循环中移除,带 default 的循环可能空转,共享状态不能逃避同步。把这些条件处理完整,nil channel 才会成为可靠的控制工具,而不是难以排查的死锁来源。