Interview Lab / Go / Channel

Go Channel:有缓冲 vs 无缓冲,以及关闭后的读写行为

目标不是死记“会不会阻塞”,而是理解:数据到底放在哪里、谁在等谁、为什么关闭以后还能继续读。 看懂这三个问题,绝大多数 Channel 基础追问都能顺着底层机制回答。

面试 30 秒标准回答:
无缓冲 Channel 容量为 0,发送和接收必须同时准备好,数据通常由发送方直接交给接收方,因此天然带有同步语义; 有缓冲 Channel 内部有固定容量的环形队列,只要缓冲区没满,发送方可以先把数据放进去继续执行,因此更适合生产者和消费者速度不一致的场景。 Channel 关闭后再发送会直接 panic;接收不会 panic,缓冲区有数据时会先继续读完,彻底读空后再接收会立即得到元素类型的零值,并且 ok=false
一、先学会最基本的 Channel 语法

把 Channel 想成“goroutine 之间传东西的管道”。chan int 表示“这根管道里只能传 int”。

无缓冲

make(chan int)

没有存货位置,容量是 0。

ch := make(chan int)
fmt.Println(cap(ch)) // 0
有缓冲

make(chan int, 3)

内部最多暂存 3 个 int。

ch := make(chan int, 3)
fmt.Println(cap(ch)) // 3

三个必须认识的符号

语法含义读法
ch <- 10向 Channel 发送 10把 10 放进 ch
x := <-ch从 Channel 接收一个值从 ch 拿一个值给 x
x, ok := <-ch接收值,同时判断是否还收到真正发送过的数据ok 为 false 表示 channel 已关闭且已经没有数据
<- 的箭头方向就是数据流方向。 ch <- v:数据流进 ch;v := <-ch:数据从 ch 流出来。

为什么通常要配 goroutine?

ch := make(chan int)

ch <- 10       // 这里会一直等接收者
x := <-ch       // 永远走不到这里

无缓冲 Channel 的发送需要有人同时接收。这里只有一个 goroutine,它自己卡在发送处,后面的接收语句永远执行不到,因此运行时最终会报死锁。

ch := make(chan int)

go func() {
    ch <- 10
}()

x := <-ch
fmt.Println(x)

go func() { ... }() 表示启动一个新的 goroutine。现在“发送”和“接收”可以由两个 goroutine 配对。

二、核心概念:两种 Channel 到底差在哪里
核心不是“有没有缓冲”这五个字,而是:发送成功的条件不同

无缓冲 Channel

cap(ch) == 0

  • 没有数据暂存区。
  • 没有接收者准备好时,发送者阻塞。
  • 没有发送者准备好时,接收者阻塞。
  • 发送和接收完成时,相当于双方做了一次“握手”。

关键词:同步、交接、确认对方已接手。

有缓冲 Channel

cap(ch) > 0

  • 内部有固定容量的缓冲区。
  • 缓冲区没满时,发送者通常不需要等消费者。
  • 缓冲区有数据时,接收者可以直接取。
  • 满了再发才阻塞;空了再收才阻塞。

关键词:解耦、削峰、短暂速度差。

为什么 Go 要同时提供两种?

因为并发程序里有两类需求:一种是“你必须接到,我才继续”,强调同步; 另一种是“我先放这里,你稍后处理”,强调生产者与消费者解耦。

典型场景:无缓冲

任务交接 / 同步信号。

例如主 goroutine 把任务交给 worker,并希望确保 worker 已经接手之后自己才继续。

典型场景:有缓冲

Worker Pool 任务队列。

请求短时间涌入时先进入固定容量队列,多个 worker 慢慢消费。缓冲还能形成有限的背压边界。

三、图解:无缓冲与有缓冲到底怎么“走数据”

1. 无缓冲:像当面递作业

发送 goroutine ch <- 100 无缓冲 Channel 容量 = 0 没有“仓库格子” 只负责撮合双方 接收 goroutine x := <-ch 双方同时准备好,值完成交接,双方继续运行

可以把它理解成两个人面对面递一本书。桌子上没有临时存放的位置。 A 如果到了、B 还没到,A 就得拿着书等;B 如果先到了,也只能等 A。

2. 有缓冲:像有 3 个格子的快递柜

生产者 连续发送 buffered channel,cap = 3 A B qcount = 2,仍可继续发送 1 个 消费者 稍后取走 缓冲区没满:发送可先完成;缓冲区满:下一次发送阻塞
一个容易被忽略的底层细节: 即使是有缓冲 Channel,如果此时已经有一个接收 goroutine 在 recvq 里等待, 新来的发送者可以直接把数据交给这个接收者,绕过缓冲区,而不是非要先放进 buffer。

阻塞条件一张表记住

操作无缓冲 Channel有缓冲 Channel
发送没有接收者准备好就阻塞缓冲区满,并且没有等待中的接收者时阻塞
接收没有发送者准备好就阻塞缓冲区空,并且没有等待中的发送者时阻塞
成功后数据在哪里通常发送方直接交给接收方可能直接交接,也可能进入环形缓冲区
四、关闭 Channel:面试最容易答错的部分
close 不是“销毁 Channel”,而是宣布:以后不会再有新数据发送进来。

问题 1:向已关闭的 Channel 发送会怎样?

直接 panic: send on closed channel
ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel

问题 2:从已关闭的 Channel 接收会怎样?

分两种情况,千万别只回答“返回零值”。

关闭了,但缓冲区还有数据

继续正常把以前发送进去的数据读完。

此时 ok == true

关闭了,而且缓冲区已经空了

接收操作立即返回元素类型的零值。

此时 ok == false

close(ch) 之后,旧数据不会被清空 Channel 已关闭 closed = 1 缓冲区还有 10、20 先返回 10,再返回 20 缓冲区读空 之后再读零值 v, ok := <-ch 10, true v, ok := <-ch 20, true v, ok := <-ch 0, false int 的零值是 0;string 的零值是 "";指针、map、slice、func 等的零值通常是 nil

为什么要有 ok

因为单看值无法判断“这是业务真的发送了一个 0”,还是“Channel 已关闭且读空以后返回的 int 零值 0”。

v, ok := <-ch
if !ok {
    fmt.Println("channel 已关闭且没有剩余数据")
}

for range 为什么经常和 close 一起出现?

for v := range ch {
    fmt.Println(v)
}

range ch 会不断接收数据。当 Channel 已关闭并且剩余数据全部读完时,循环自动结束。 如果发送方永远不关闭,而接收方又一直 range,接收方可能永远等下去。

五、底层实现:runtime.hchan 里到底有什么

在 Go runtime 中,Channel 的核心运行时对象叫 hchan。源码会随版本演进, 面试不需要逐字段背诵,但下面这些字段应该知道用途。

简化后的 hchan 关键结构
type hchan struct {
    qcount   uint           // 当前缓冲区已有多少个元素
    dataqsiz uint           // 缓冲区总容量;0 表示无缓冲
    buf      unsafe.Pointer // 指向环形缓冲区
    elemsize uint16         // 每个元素大小
    closed   uint32         // 是否已经关闭
    elemtype *_type         // Channel 元素类型

    sendx    uint           // 下次写入 buffer 的位置
    recvx    uint           // 下次读取 buffer 的位置

    recvq    waitq          // 等待接收的 goroutine 队列
    sendq    waitq          // 等待发送的 goroutine 队列

    lock     mutex          // 保护 hchan 中的共享状态
}
runtime.hchan qcount 现在有几个元素 dataqsiz 容量 cap buf:环形队列 sendx 指向写位置;recvx 指向读位置 sendq 等待发送的 G recvq 等待接收的 G closed lock 无缓冲时 dataqsiz = 0 有缓冲时 buf 有实际空间

sendq / recvq 里装的是什么?

并不是“把整个 goroutine 塞进链表”。Runtime 使用一个叫 sudog 的等待节点来描述 “哪个 goroutine 正在某个同步对象上等待”,节点里会关联 goroutine、待发送/接收的数据位置等信息。

面试表达: “阻塞不是线程在 CPU 上空转。Runtime 会把当前 goroutine 通过 sudog 挂到 Channel 的等待队列中, 然后用 gopark 挂起;匹配到发送/接收方后再通过调度逻辑唤醒。”

为什么 buffer 是环形队列?

固定容量队列不断出队、入队。如果每次接收都把剩余元素整体向前搬,会浪费 CPU。 环形队列只移动两个索引:sendxrecvx,到末尾后绕回 0。

// 假设容量 = 3
buffer: [ A ][ B ][ 空 ]
              ↑        ↑
            recvx    sendx

// 读 A/B、写 C/D 时,只移动索引,不整体搬数组
六、底层流程:发送、接收和 close 到底发生了什么

A. 发送 ch <- value 的简化流程

ch <- value Channel 已关闭? 是:panic recvq 有等待接收者? 有:直接把值交给它并唤醒 buffer 还有空间? 有:写入 sendx,qcount++ 否则:当前 goroutine 阻塞 生成 sudog → 进入 sendq → gopark 等待接收者把它唤醒

注意这说明了一个重要事实:Channel 的发送不是简单等价于“向数组 append”。 Runtime 首先看是否有等待中的接收者;有的话可以直接匹配。

B. 接收 value := <-ch 的简化流程

  1. 如果 Channel 已关闭且 buffer 已空:立即返回零值,双返回形式中 ok=false
  2. 如果有等待中的发送者:
    • 无缓冲:直接从发送者复制数据。
    • 有缓冲且缓冲已满:先从 buffer 头部取一个,再把等待发送者的数据补到 buffer 尾部。
  3. 如果 buffer 里有数据:从 recvx 读出,qcount--
  4. 否则没有数据可拿:把当前 goroutine 通过 sudog 放进 recvq,然后挂起。

C. close(ch) 的简化流程

1. 加锁并检查

nil Channel 或已经关闭的 Channel 再 close 都会 panic。

2. 标记关闭

closed 标志设为 1。

3. 唤醒等待者

等待接收者被唤醒;等待发送者也会被唤醒,但发送者随后会 panic。

重点:close 不会把缓冲区中已经存在的数据清掉。 因此“关闭”与“清空”是完全不同的概念。
七、常见面试追问:从基础到源码级
1. 有缓冲和无缓冲 Channel 的核心区别是什么?
无缓冲容量为 0,发送与接收需要配对完成,天然具有同步交接语义; 有缓冲内部有固定容量队列,发送者可以在缓冲区未满时先完成发送,因此生产者和消费者不必每次同步碰面。
追问关键词:同步交接、buffer、阻塞条件。
2. 有缓冲 Channel 容量 3,连续发送第几个值会阻塞?
如果没有任何接收者,前 3 次可以放进缓冲区,第 4 次阻塞。 但不要机械回答“第 4 次一定阻塞”:如果此时有接收者消费,或者已有等待接收者,发送可能继续成功。
3. 已关闭 Channel 还能读吗?
能。若缓冲区还有历史数据,先正常读完,ok=true; 读空后继续读取会立即得到零值,ok=false,不会阻塞,也不会 panic。
4. 为什么向已关闭 Channel 发送会 panic,而读取不会?
close 表示“生产结束,不会再有新数据”。继续发送违反了这个状态约定,因此属于程序错误; 接收方则需要有一种方式知道“生产结束”,所以关闭会让接收方可以安全地把剩余数据消费完,并通过 ok=false 得知结束。
5. 谁应该关闭 Channel?发送方还是接收方?
通常由发送方,准确地说是“能够确定以后绝对不会再发送数据的一方”负责关闭。 接收方通常无法判断其他发送者是否还会继续发,贸然 close 容易导致别的发送者 panic。
6. 多个发送者时谁 close?
不应该让多个发送者抢着 close。常见设计是增加一个协调者,例如: 多个 worker 发送结果,一个独立 goroutine 等 WaitGroup 归零后统一 close(results)
7. Channel 是线程安全的吗?内部为什么还要 lock?
从语言使用层面,一个 Channel 可以被多个 goroutine 并发发送/接收。 Runtime 需要保护 qcount、缓冲区索引、等待队列、关闭状态等共享数据的一致性,所以 hchan 内有锁。 “Channel 对使用者并发安全”不等于“底层完全无锁”。
8. 无缓冲 Channel 没有 buffer,数据到底怎么过去?
当发送者和接收者匹配时,Runtime 可以直接把发送者的数据复制到接收者目标位置。 源码中有专门的直接发送/接收路径,因此并不需要先经过一个容量为 0 的“虚拟数组”。
9. goroutine 因 Channel 阻塞后发生什么?是不是占着一个系统线程?
Runtime 会创建/复用 sudog 节点,把 goroutine 挂入 sendqrecvq, 然后使用 gopark 让这个 goroutine 进入等待状态。它不是一直占 CPU 自旋等待。 后续有匹配操作时再被唤醒进入可运行状态。
10. len(ch) 能不能用来判断“现在发送一定不会阻塞”?
不可靠。len(ch) 只是某一瞬间的缓冲元素数量,其他 goroutine 可能马上改变 Channel 状态。 并发程序中不能把 len(ch) 当作同步条件。非阻塞尝试应使用 select + default
11. nil Channel 与 closed Channel 有什么区别?
nil Channel 没有实际的 hchan 对象:普通发送和接收会永久阻塞; closed Channel 是存在且已关闭的 Channel:发送 panic,读空后接收立即返回零值和 ok=false
12. Channel 能重新打开吗?
不能。关闭是不可逆状态。需要新的通信通道时只能重新 make 一个新的 Channel。
八、常见误区与坑点

误区 1:关闭后不能读

错。关闭后仍能读,buffer 里有数据先读数据;读空后返回零值并 ok=false

误区 2:close 会清空缓冲区

错。close 只修改关闭状态,不等于清空已经发送的数据。

误区 3:接收方用完就 close

危险。只要还有任意发送者继续发送,就会触发 panic。

误区 4:close 两次没关系

错。第二次 close(ch) 会 panic:close of closed channel

误区 5:nil Channel 等于 closed Channel

完全不同。nil Channel 的普通收发会永久阻塞,closed Channel 的接收不会阻塞。

误区 6:缓冲越大越好

错。过大缓冲会吃内存、掩盖消费者处理不过来的问题,并增加排队延迟。

误区 7:有缓冲就不会阻塞

错。buffer 满时发送照样阻塞;buffer 空时接收照样阻塞。

误区 8:用 recover 解决重复 close

不推荐。正确做法是明确 Channel 所有权,必要时使用协调者、sync.Once 等控制关闭时机。

这 4 组行为必须背熟

nil + 发永久阻塞
nil + 收永久阻塞
closed + 发panic
closed + 收读完后零值 + false
再补两条:close(nil channel) 会 panic;重复 close 会 panic。
九、一个可运行示例:一次看懂缓冲、close、ok 与 panic

保存为 main.go 后运行 go run main.go

完整 Go 示例
package main

import (
    "fmt"
)

func main() {
    bufferedDemo()
    closedSendDemo()
}

func bufferedDemo() {
    fmt.Println("=== buffered channel ===")

    // 创建容量为 2 的有缓冲 Channel。
    ch := make(chan int, 2)

    // 没有接收者也能先发送两个值,因为 buffer 有两个位置。
    ch <- 10
    ch <- 20

    fmt.Println("len =", len(ch), "cap =", cap(ch))

    // close 只是告诉接收方:以后不会再有新数据。
    // 10、20 不会因此消失。
    close(ch)

    v1, ok1 := <-ch
    fmt.Println(v1, ok1) // 10 true

    v2, ok2 := <-ch
    fmt.Println(v2, ok2) // 20 true

    // Channel 已关闭,并且 buffer 已经读空。
    // int 的零值是 0,ok 为 false。
    v3, ok3 := <-ch
    fmt.Println(v3, ok3) // 0 false

    // 再读多少次都立即返回 0, false,不会阻塞。
    v4, ok4 := <-ch
    fmt.Println(v4, ok4) // 0 false
}

func closedSendDemo() {
    fmt.Println("=== send to closed channel ===")

    ch := make(chan string, 1)
    close(ch)

    // 这里只为了演示 panic 内容。
    // 正常业务代码不要依赖 recover 来“安全发送”。
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("捕获到 panic:", r)
        }
    }()

    ch <- "hello" // panic: send on closed channel
}

预期输出

=== buffered channel ===
len = 2 cap = 2
10 true
20 true
0 false
0 false
=== send to closed channel ===
捕获到 panic: send on closed channel

再看一个无缓冲 Channel 的最小例子

package main

import "fmt"

func main() {
    ch := make(chan int)

    go func() {
        fmt.Println("发送者:准备发送")
        ch <- 100
        fmt.Println("发送者:接收方已经接走数据,我才能走到这里")
    }()

    v := <-ch
    fmt.Println("接收者收到:", v)
}
十、最后一分钟速记:面试前只看这里
无缓冲看“人”有没有到;有缓冲看“格子”有没有空;关闭看“以后还有没有新货”。
  1. 无缓冲:容量 0,收发双方配对,强调同步交接。
  2. 有缓冲:有环形队列;未满可发,非空可收;满发阻塞,空收阻塞。
  3. 关闭后发送:panic。
  4. 关闭后接收:先读完历史数据,再返回零值 + ok=false
  5. 底层:hchan + buf + sendx/recvx + sendq/recvq + lock + closed
  6. 阻塞 goroutine:通过 sudog 挂到等待队列,gopark,以后再被唤醒。
  7. 谁 close:能确定“不再有任何发送”的一方。
十一、资料依据与版本说明

本页底层描述按 Go 官方语言规范与当前官方 runtime Channel 实现整理。源码字段可能随 Go 版本演进,但核心语义由语言规范保证。

官方参考:

  • Go Language Specification:Channel types、Send statements、Close、Receive operator。
  • Go runtime 源码:src/runtime/chan.go,包括 hchanchansendchanrecvclosechan