一句话结论

Channel 的阻塞取决于是否有缓冲和对方是否就绪。nil Channel 永久阻塞,已关闭 Channel 写入 panic、读取返回零值。谁写谁关——接收方关闭 Channel 极易 panic。

核心原理

阻塞条件全表

操作

无缓冲 Channel

有缓冲 Channel(未满)

有缓冲 Channel(已满)

nil Channel

发送

阻塞直到有接收者

不阻塞

阻塞直到有空间

永久阻塞

接收

阻塞直到有发送者

不阻塞(buf 不空)

不阻塞

永久阻塞

已关闭 Channel 的行为

操作

结果

发送 → 已关闭 Channel

panic: send on closed channel

接收 → 已关闭 + buf 有数据

返回数据, ok=true

接收 → 已关闭 + buf 空

返回零值, ok=false

关闭 → 已关闭 Channel

panic: close of closed channel

关闭 → nil Channel

panic: close of nil channel

谁负责关闭 Channel

// ✅ 正确:发送者关闭
func producer(ch chan<- int) {
    for i := 0; i < 10; i++ {
        ch <- i
    }
    close(ch)  // 发送方关
}

// ❌ 危险:接收者关闭(如果还有其他发送者就会 panic)
func consumer(ch <-chan int) {
    for v := range ch {
        if someCondition {
            // close(ch) ← 编译错误!<-chan 不可关闭
        }
    }
}

原则: Channel 由唯一的发送方关闭。如果多个发送方共用一个 Channel,需要额外的协调机制(如 sync.Once 或专门的 done Channel)。

select + 已关闭 Channel

ch := make(chan int)
close(ch)

select {
case v, ok := <-ch:
    // ok=false,v=0。会**立即**匹配这个 case!
    // 已关闭 Channel 永远不会阻塞 select
default:
    // 永远不会到这里
}

项目中的应用

在 项目二-物联网AI-Agent中枢控制平台 中,通过 select + ctx.Done() 防止 Channel 发送阻塞导致 Goroutine 泄漏:

func (h *DeviceHandler) publishStatus(ctx context.Context, status DeviceStatus) error {
    select {
    case h.statusCh <- status:
        return nil
    case <-ctx.Done():
        return ctx.Err()  // 接收方已退出,优雅放弃
    }
}

详见 Golang-并发深度 中的真实泄漏排查案例。

Channel 泄漏

场景 1:发送方泄漏

ch := make(chan int)
go func() {
    ch <- 1  // 没人接收 → 永久阻塞 → Goroutine 泄漏
}()
// 忘记从 ch 接收

场景 2:接收方泄漏

go func() {
    for v := range ch {  // ch 永远不会被 close → 永久阻塞
        fmt.Println(v)
    }
}()

场景 3:select + nil Channel

var ch chan int  // nil
select {
case v := <-ch:  // nil Channel 永久阻塞,这个 case 永远不会被选中
case <-time.After(time.Second):
    // 超时后走这里
}
// ✅ 利用 nil Channel 的特性:把不想选的 case 置为 nil

技巧: select 中把暂时不想处理的 case 对应的 Channel 设成 nil,该 case 就被禁用(永远不会被选中)。

高频面试问题

Q: 向已关闭 Channel 写入会怎样?从已关闭 Channel 读取呢?

30 秒回答: 写入已关闭 Channel → panic。从已关闭 Channel 读取 → 如果 buf 还有数据返回数据+true;buf 空时返回零值+false(不会 panic,也会立即返回)。

Q: select 中多个 case 同时就绪,选哪个?

30 秒回答: 伪随机选择。Go 运行时对就绪的 case 做均匀洗牌,不是按源码顺序。目的是防止饿死——如果总是按顺序,第一个 case 可能永远霸占。

常见错误

  1. 接收者关闭 Channel → panic: close of closed channel

  2. 忘记关闭 Channel → range channel 的 Goroutine 永久阻塞

  3. 多个发送者共用一个 Channel → 不知道谁来 close

最小实验

// 验证各种 Channel 行为
func main() {
    // 1. 无缓冲:发送阻塞直到接收
    ch := make(chan int)
    go func() { time.Sleep(time.Second); fmt.Println(<-ch) }()
    ch <- 42  // 阻塞 1 秒

    // 2. 已关闭 Channel 读
    close(ch)
    v, ok := <-ch
    fmt.Println(v, ok)  // 0, false
}

速记

无缓冲=同步阻塞;有缓冲=满写阻塞/空读阻塞。nil Chan 永久阻塞。closed Chan:读零值+false,写 panic,再关 panic。谁写谁关。select 已关闭 Chan 不会阻塞。select 多就绪=伪随机。