写并发的人几乎都用过 channel,但用得顺和讲得清是两件事。会写 `ch <- v`、会 `for range ch`、会 `select`,不代表能回答这几个问题:向一个已关闭的 channel 发送为什么是 panic 而接收不是?为什么有人说「无缓冲 channel 是同步的,有缓冲是异步的」这句话只对了一半?`select` 里放一个 `nil` channel 到底有什么用?

这篇把 channel 拆到 runtime 层面讲一遍:底层结构长什么样,发送和接收分别走哪几条路径,关闭和 `nil` 的语义边界在哪,`select` 怎么工作,以及几个生产里真会用到的模式和对应的坑。看完能带走的是一套「出问题时知道该怀疑哪一层」的判断,而不是又一份 API 速查。

前置只要两点:知道 goroutine 是什么,知道 `go func(){}()` 会起一个新协程。

## 一、channel 不是队列,是一段带锁的运行时结构

很多教程把 channel 画成一根管道,数据从一头流到另一头。这个图能解释缓冲区,解释不了「无缓冲 channel 收发双方要碰面」这件事。要讲清楚,得看 runtime 里 channel 到底是什么。

下面这段是 Go 运行时里 channel 的实际结构(`runtime/chan.go`,去掉了 race 检测相关字段),重点看 `buf`、`sendq`、`recvq` 三个字段。

```go
type hchan struct {
    qcount   uint           // 当前缓冲区里的元素个数
    dataqsiz uint           // 缓冲区容量,即 make(chan T, N) 里的 N
    buf      unsafe.Pointer // 指向环形缓冲区,无缓冲时为 nil
    elemsize uint16
    closed   uint32         // 是否已关闭
    elemtype *_type
    sendx    uint           // 环形缓冲区里下一次写入的下标
    recvx    uint           // 环形缓冲区里下一次读取的下标
    recvq    waitq          // 阻塞在接收上的 goroutine 队列
    sendq    waitq          // 阻塞在发送上的 goroutine 队列
    lock     mutex          // 保护 hchan 所有字段的互斥锁
}
```

三件事从这个结构就能看出来。channel 自带一把 `lock`,所有收发操作先抢锁再改状态,这是它线程安全的来源;`buf` 是一个环形缓冲区,`sendx`/`recvx` 是读写游标,无缓冲 channel 的 `buf` 是 `nil`、`dataqsiz` 是 0;`recvq` 和 `sendq` 存的是「因为收不到 / 发不出而被挂起的 goroutine」,队列里每个元素是一个 `sudog`,包住了 goroutine 和它想收发的那个值的地址。

记住这个结构,后面所有行为都是它字段状态的组合。

## 二、发送和接收各有三条路径

`ch <- v` 在源码里对应 `chansend`,`<-ch` 对应 `chanrecv`。它们各自不是单一动作,而是抢到锁之后按当前状态分三条路走。先看发送。

发送时按顺序判断:`recvq` 里有没有正在等的接收方?有就直接把值交给它。没有的话,缓冲区还有空位吗?有就写进 `buf`。都没有,就把自己挂进 `sendq` 等着。

```go
// 路径一:有接收方在等 —— 直接把值拷贝到它的栈上,不经过缓冲区
if sg := c.recvq.dequeue(); sg != nil {
    send(c, sg, ep, ...) // ep 是待发送值的地址,直接拷到接收方 sudog 记录的地址
    return true
}
// 路径二:缓冲区有空位 —— 拷进环形缓冲区,推进 sendx
if c.qcount < c.dataqsiz {
    typedmemmove(c.elemtype, chanbuf(c, c.sendx), ep)
    c.sendx++
    if c.sendx == c.dataqsiz { c.sendx = 0 }
    c.qcount++
    return true
}
// 路径三:发不出去 —— 打包成 sudog 挂进 sendq,gopark 让出 CPU
```

路径一是最容易被忽略的一条:当已经有接收方阻塞在那里时,发送方会把数据直接拷到接收方的地址,绕开缓冲区。这一步说明「有缓冲 = 异步」这个说法不完整——哪怕是有缓冲 channel,只要缓冲区空且有接收方在等,数据也是点对点直接交付的。真正决定走哪条路的是运行时那一刻的状态,不是 channel 的类型。

接收方对称地也有三条路径,其中有一条值得单独看。

```go
// chanrecv 里:有发送方在 sendq 排队时
if sg := c.sendq.dequeue(); sg != nil {
    recv(c, sg, ep, ...)
    return true, true
}
```

这里的 `recv` 分两种情况。无缓冲 channel(`dataqsiz == 0`)直接从发送方拷贝过来;有缓冲 channel 走到这条路,说明缓冲区是满的、且还有发送方在排队,于是接收方先从缓冲区头部取走一个元素给自己,再把排队发送方的值补到缓冲区尾部。这一步保证了 FIFO 顺序:先入缓冲的先被取走,排队的发送方按顺序补位。漏掉这个细节,就会以为满缓冲区来了新接收方是「跳过缓冲直接拿新值」,顺序就理解反了。

把两组路径合起来看,无缓冲和有缓冲的区别就清楚了:无缓冲 channel 永远走「直接交付」或「挂起等待」,没有中间态,所以收发两方必须同时就绪,这就是它的同步语义;有缓冲 channel 多了一个「缓冲区没满 / 没空就不阻塞」的窗口,在这个窗口里收发是解耦的。

下面这张图把发送和接收的三条路径放在一起,对照上面两段代码看。

```mermaid
flowchart TD
    subgraph SEND["ch &lt;- v (chansend)"]
        S0[抢 hchan.lock] --> S1{recvq 有等待的接收方?}
        S1 -->|有| S2[直接把 v 拷到接收方地址<br/>绕过缓冲区, 唤醒它]
        S1 -->|无| S3{qcount &lt; dataqsiz<br/>缓冲区有空位?}
        S3 -->|有| S4[写入 buf, sendx++]
        S3 -->|无| S5[打包 sudog 挂进 sendq<br/>gopark 挂起]
    end

    subgraph RECV["&lt;-ch (chanrecv)"]
        R0[抢 hchan.lock] --> R1{sendq 有等待的发送方?}
        R1 -->|有, 无缓冲| R2[直接从发送方拷贝]
        R1 -->|有, 缓冲已满| R3[取 buf 头部给自己<br/>发送方的值补到 buf 尾部<br/>保持 FIFO]
        R1 -->|无| R4{qcount &gt; 0<br/>缓冲区有值?}
        R4 -->|有| R5[从 buf 取一个, recvx++]
        R4 -->|无| R6[打包 sudog 挂进 recvq<br/>gopark 挂起]
    end
```

`S2`(发送直接交付)和 `R3`(缓冲满时接收方补位发送方的值)是两条最容易被忽略的路径,走哪条取决于运行时那一刻的状态,不是 channel 类型。

## 三、关闭:一次广播,两种反应

`close(ch)` 做的事在 `closechan` 里,核心是三步:置 `closed = 1`,然后把 `recvq` 和 `sendq` 里所有挂着的 goroutine 全部唤醒,最后释放锁。被唤醒的收发两方反应完全不同,这是 channel 里最容易踩的语义边界。

关闭后的行为规则:

- 向已关闭的 channel **发送**:直接 panic(`send on closed channel`)。这是设计上的硬约束——close 表示「不会再有值进来了」,还往里发说明程序逻辑错了,runtime 选择让它立刻崩,而不是静默吞掉。
- 从已关闭的 channel **接收**:不 panic。缓冲区里还有值就继续取,取空之后每次接收立刻返回元素类型的零值。
- 用 comma-ok 形式接收:`v, ok := <-ch`,`ok` 为 `false` 表示「channel 已关闭且缓冲区已空」,这是区分「收到一个零值」和「channel 关了」的唯一手段。

下面这段验证关闭后缓冲区仍会被排空,重点看输出顺序。

```go
ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch)

v, ok := <-ch // 1, true —— 缓冲区还有值,正常取
fmt.Println(v, ok)
v, ok = <-ch  // 2, true
fmt.Println(v, ok)
v, ok = <-ch  // 0, false —— 缓冲区空了,才返回零值 + false
fmt.Println(v, ok)
```

关闭不是「立刻断流」,而是「不再进新值,存量正常放完」。`for v := range ch` 之所以能优雅退出,靠的就是它内部用 comma-ok 判断 `ok == false` 后跳出循环——这也是为什么 range 一个永不关闭的 channel 会永远阻塞。

关于「谁来 close」有一条工程约定值得记死:**只有发送方 close,接收方永不 close,多个发送方时谁都不能直接 close**。原因就是上面那条 panic 规则——接收方无法知道其他发送方是否还要发,贸然关闭会让别的发送方撞上 panic。多发送方场景要退出,标准做法是用一个额外的 `done` channel 或 `context` 通知发送方停止,让它们自己收敛,而不是从下游关上游。

## 四、nil channel:一个能被临时「关掉」的开关

对 `nil` channel 的收发行为很反直觉,但理解之后它是 `select` 里最好用的技巧。

规则只有一条:向 `nil` channel 发送或接收,**永远阻塞**,不会 panic,也不会返回。单独看这条像是纯粹的坑——确实,忘了 `make` 直接用 channel 就会撞上永久阻塞。但放进 `select` 就变成了特性:`select` 会跳过那些永远不可能就绪的 `case`,于是把某个 channel 变量置为 `nil`,等于在运行时动态禁用掉对应的分支。

下面是一个典型用法:合并两个数据源,某一路关闭后就不再监听它,避免关闭的 channel 反复返回零值把 CPU 打满。重点看 `in1 = nil` 这一行。

```go
func merge(in1, in2 <-chan int, out chan<- int) {
    for in1 != nil || in2 != nil {
        select {
        case v, ok := <-in1:
            if !ok {
                in1 = nil // 关闭后置 nil,下一轮 select 直接跳过这个 case
                continue
            }
            out <- v
        case v, ok := <-in2:
            if !ok {
                in2 = nil
                continue
            }
            out <- v
        }
    }
    close(out)
}
```

不置 `nil` 会怎样:某一路关闭后,`<-in1` 会立刻返回 `(0, false)`,`select` 每一轮都命中这个 case,循环空转烧满一个核。置 `nil` 之后那个 case 被彻底摘除,`select` 只在剩下的活跃 channel 上阻塞等待。这就是 `nil` channel 从「坑」变「开关」的地方——判断它是不是坑,得看它在不在 `select` 里。

## 五、select:随机、公平、可退出

`select` 是 channel 的调度入口,行为上有三个点要说清楚。

第一,多个 case 同时就绪时,`select` **伪随机**挑一个执行,不是从上到下。这是刻意设计,防止写在前面的 case 长期饿死后面的。所以不能靠 case 的书写顺序来表达优先级——需要优先级得自己拆成嵌套 select。

第二,`default` 把阻塞的 `select` 变成非阻塞。没有任何 case 就绪且存在 `default` 时,立刻执行 `default` 走人。这是实现「试一下,不行就算了」的非阻塞收发的标准手段。

第三,配合 `time.After` 做超时,这是最常见的组合。下面这段给一次接收加 500ms 上限,重点看两个 case 的竞争关系。

```go
select {
case v := <-ch:
    handle(v)
case <-time.After(500 * time.Millisecond):
    return errTimeout
}
```

`time.After` 返回一个在指定时长后收到值的 channel,它和 `ch` 一起进入 `select` 竞争,谁先就绪走谁。要注意一个已知代价:`time.After` 每次调用都会创建一个 Timer,如果这段 `select` 在一个高频循环里,而 `ch` 大多数时候先就绪,那些没触发的 Timer 要等到超时才被回收,循环够快时会短暂堆积。热路径上更稳的写法是复用 `time.NewTimer` 并在每轮 `Reset`。这个坑不致命,但在每秒几万次的循环里能观察到内存和 GC 的波动。

## 六、四个能直接用的模式

前面是机制,这一节是落地。挑四个覆盖面最广的,每个都点明适用条件。

**worker pool(固定并发的 fan-out / fan-in)**。用一个 `jobs` channel 派活,一个 `results` channel 收结果,`worker` 数量就是并发上限。适用于任务量大、但要限制同时在跑的 goroutine 数(比如控制对下游的并发请求)。

```go
func run(jobs <-chan Job, results chan<- Result, workers int) {
    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for j := range jobs { // jobs 关闭后 range 自动退出
                results <- process(j)
            }
        }()
    }
    go func() { wg.Wait(); close(results) }() // 所有 worker 收工后关 results
}
```

关键是关闭顺序:上游发完活 close(jobs)→ worker 的 range 退出 → `wg.Wait()` 返回 → close(results)。整条退出靠 close 逐级传播,不需要额外信号。

**信号量(限流)**。用一个容量为 N 的缓冲 channel 当计数器,拿令牌就是发送、还令牌就是接收,缓冲满了自然阻塞。适用于给某段代码限定最大并发数,比 worker pool 更轻,不用起常驻协程。

```go
sem := make(chan struct{}, 3) // 最多 3 个并发
for _, task := range tasks {
    sem <- struct{}{}          // 获取令牌,满了就在这阻塞
    go func(t Task) {
        defer func() { <-sem }() // 释放令牌
        do(t)
    }(task)
}
```

用 `struct{}` 做元素类型是因为它零内存占用——这里只关心「占了几个位置」,不关心传什么值。

**优雅退出(done channel)**。用一个只关不发的 channel 广播退出信号,所有监听方在 `select` 里收到关闭事件后收敛。关闭一个 channel 会唤醒所有接收方,这正好是「一对多广播」的天然实现。

```go
func worker(done <-chan struct{}, tasks <-chan Task) {
    for {
        select {
        case <-done:      // done 被 close 后,所有 worker 同时命中这个 case
            return
        case t := <-tasks:
            do(t)
        }
    }
}
```

生产代码里这个模式基本被 `context.Context` 取代了——`ctx.Done()` 返回的就是这样一个 channel,还多带了超时、取消原因和层级传播。自己手写 `done` channel 适合简单场景,跨函数、跨服务的取消传播直接用 `context`。

**超时控制**已在第五节给过,不重复。

## 七、什么时候别用 channel

channel 好用,但它不是并发问题的默认答案。Go 官方那句「Don't communicate by sharing memory; share memory by communicating」经常被读成「一切都用 channel」,这是过度解读。

判断标准:channel 解决的是**所有权转移和协作时序**——一个值从 goroutine A 交给 B、之后 A 不再碰它,或者多个 goroutine 需要按某种顺序协调。这种场景用 channel 表达得干净。

但如果只是**保护一块共享状态的读写**(比如一个并发访问的 map、一个计数器),`sync.Mutex` 或 `sync/atomic` 更直接也更快。用 channel 去包一个计数器,等于给一次原子加法套上了「抢 channel 锁 + 挂起唤醒 goroutine」的开销,慢一个数量级,代码还更绕。

几条能直接对照的反模式:

- 用 channel 实现互斥锁(`make(chan struct{}, 1)` 当 mutex):能跑,但比 `sync.Mutex` 慢且难读,除非需要「带超时的加锁」这种 mutex 给不了的能力。
- 无缓冲 channel 上收发双方在同一个函数里、且没有第三个 goroutine:必然死锁,runtime 会直接报 `all goroutines are asleep - deadlock`。
- 给 channel 加大缓冲来「解决」阻塞:缓冲只是把背压推后,生产快于消费时缓冲迟早填满,问题原样回来,还多了一段被掩盖的延迟。缓冲区大小该按「允许的突发量」来定,不是拿来消除阻塞的。

一个可操作的默认:先问这段并发是「传递数据」还是「保护数据」。传递用 channel,保护用锁。拿不准时先写锁的版本,它更容易读也更容易在出问题时定位。

## 小结

channel 的所有行为都能回到第一节那个 `hchan` 结构:收发是 `buf`、`sendq`、`recvq` 三个字段在锁保护下的状态转移,关闭是一次置位加广播,`nil` 是让 `select` 跳过分支的空状态。记住这套状态模型,遇到死锁、panic、CPU 空转时就知道该查哪一层——是没人接收、还是关了还在发、还是 `select` 里漏了 `nil` 处理。

想再往下挖两处:一是 `runtime/chan.go` 的 `chansend` / `chanrecv` 全文,五百行左右,读完对「直接交付」那条路径会更有体感;二是 Go Memory Model 里关于 channel 的 happens-before 保证(一次发送 happens-before 对应接收完成,close happens-before 因关闭而返回的接收),这是 channel 能替你省掉显式加锁的理论依据。