# Go Channel


Go channel 面试要点整理。覆盖：基本用法、无缓冲与有缓冲的区别、读写 panic 案例、超时与非阻塞收发、select 语义、底层结构（hchan）、内存模型、常见模式、泄漏与死锁。

## 基本用法

创建、收发、关闭、遍历：

```go
ch := make(chan int)        // 无缓冲
ch2 := make(chan string, 8) // 有缓冲，容量 8

ch <- 1        // 发送
v := <-ch      // 接收
v, ok := <-ch  // ok=false 表示 channel 已关闭且缓冲区已空
close(ch)      // 关闭，只能由发送方做

for v := range ch { // 读到关闭为止，自动退出
}
```

单向 channel（chan<- T 只能写，<-chan T 只能读），编译期保证方向，常用于函数签名约束：

```go
func produce(ch chan<- int) { ch <- 1 } // 只写
func consume(ch <-chan int) { <-ch }    // 只读
```

## 无缓冲与有缓冲

- 无缓冲（容量 0）：发送必须等接收者就绪，接收必须等发送者就绪，两边同时到齐才算完成，所以也叫同步 channel。两个 goroutine 靠它握手。
- 有缓冲：发送方在缓冲区未满时不阻塞，满了才阻塞等接收方取走；接收方在缓冲区为空时阻塞。缓冲解耦了收发双方的节奏。

运行时判断依据（src/runtime/chan.go 的 full()）：无缓冲时看接收等待队列 recvq 是否为空；有缓冲时看 qcount 是否等于 dataqsiz。

几个衍生点：

- 缓冲大小不影响正确性，只影响阻塞时机和吞吐。大小是提示（hint），不是上限语义。
- 数据走缓冲区是"拷贝进、拷贝出"；有等待者时直接交接（handoff），把元素拷给等待者，不经过缓冲区。
- 发送顺序与接收顺序是 FIFO：缓冲区是环形队列，读写各有一个下标 sendx/recvx；阻塞的收发者按排队顺序被唤醒。channel 的收发内部有锁保护，并发读写安全，多个 goroutine 同时收发不需要额外加锁。
- 无缓冲 channel 传值自带同步效果：发送 happens-before 接收完成（见内存模型一节），不需要额外加锁。

## 读写 panic 案例

| 操作                    | 结果                                       |
| ----------------------- | ------------------------------------------ |
| 向已关闭的 channel 发送 | panic: send on closed channel              |
| 关闭已关闭的 channel    | panic: close of closed channel             |
| 关闭 nil channel        | panic: close of nil channel                |
| 从 nil channel 接收     | 永久阻塞（运行时 gopark 挂起，不是 panic） |
| 向 nil channel 发送     | 永久阻塞（同上）                           |
| 从已关闭的 channel 接收 | 立即返回零值和 ok=false，不 panic          |

两个容易记混的点：

- 只有"写"和"关"会 panic，"读"永远不 panic。close 的语义是广播：所有等待的接收者（以及之后来读的）都拿到零值，所以读已关闭的 channel 是合法操作，这也是 done channel 广播退出的基础。
- 关闭后缓冲区里还剩的数据能继续读完，读完才返回零值，所以 range 能正常收尾。

nil channel 的收发永久阻塞，和"已关闭"行为完全不同，别混淆：nil 是阻塞，closed 是发送 panic / 接收零值。

## 超时与非阻塞收发

超时接收：

```go
select {
case v := <-ch:
    // 收到
case <-time.After(3 * time.Second):
    // 超时
}
```

循环内反复用超时，用 NewTimer 而不是 time.After：time.After 每次调用都新建一个 Timer，触发前不会被回收，循环里会堆积；NewTimer 配合 defer timer.Stop() 可复用：

```go
timer := time.NewTimer(3 * time.Second)
defer timer.Stop()
select {
case v := <-ch:
case <-timer.C:
}
```

非阻塞收发（try-send / try-receive），select + default：

```go
select {
case ch <- v:
    // 发送成功（缓冲区没满，或无缓冲且对方在等）
default:
    // channel 满或没有接收者，不阻塞，走这里
}

select {
case v := <-ch:
default:
    // 缓冲区空，不阻塞
}
```

延伸：context.WithTimeout 底层也是 timer + channel，等价于"截止时间 + 超时"的组合，多个 goroutine 共享超时信号时用它。

## select 的语义

select 监听多个 channel 操作，谁就绪执行谁。几条硬规则：

- 多个 case 同时就绪时，按均匀伪随机（uniform pseudo-random）选一个，不是按书写顺序。实现上对 case 做随机洗牌，保证公平。
- case 里的 channel 是 nil 时，该 case 被禁用，永远不参与选择。常用技巧：把不需要的 case 的 channel 置 nil，动态禁用。
- 所有 case 都阻塞且没有 default，当前 goroutine 挂起等待；如果有 default，直接走 default，这就是非阻塞收发。
- 全部阻塞且无 default，会触发死锁：运行时检测到所有 goroutine 都睡死，fatal error: all goroutines are asleep - deadlock!。
- 编译器和运行时还有一层优化：select 只有 0 或 1 个 case 加 default 时，编译器直接改写成简单的 if 判断，不走上层的 select 逻辑。

## 内存模型

### 什么是 happens-before

单 goroutine 程序里没有并发问题：代码顺序就是执行顺序，`a = 1` 写在前面，后面的语句读 a 一定是 1。多 goroutine 就不一样了——两个 goroutine 并行跑，编译器会重排指令，CPU 有缓存和乱序执行。一个 goroutine 里"先执行"的语句，在另一个 goroutine 眼里可能"还没发生"：写 a 的语句物理上跑完了，但新值还躺在 CPU 缓存里没写回内存，另一个 goroutine 读到旧值。

所以并发程序需要一套规则回答一个问题：什么情况下，操作 A 的效果一定对操作 B 可见？这个"一定可见"的关系就是 happens-before（先行发生）：

- 如果 A happens-before B，那么 A 以及 A 之前的所有写入，B 一定看得到。
- 它是逻辑顺序，不是物理时间。物理上 B 可能先跑完（乱序执行），但规范保证"效果上 A 先发生"。
- 可以传递：A happens-before B、B happens-before C，则 A happens-before C。

happens-before 从哪来：

- 同一个 goroutine 内部：代码顺序就是 happens-before（程序顺序）。`a = "hello"` 写在 `c <- 0` 前面，a 的写入就 happens-before 这次发送。
- 跨 goroutine：默认没有任何 happens-before 关系。两个操作之间没有关系，就是数据竞争，结果未定义。只有同步原语（channel 收发、mutex 加解锁、atomic 操作）能建立关系。

可以这么想：每个 goroutine 有一条自己的时间线（程序顺序），同步原语是缝合这些时间线的同步点——mutex 的加锁能看到解锁之前的所有写入，channel 的接收能看到发送之前的所有写入。时间线被缝合的地方，可见性有保证；没被缝合的地方，就是数据竞争。

channel 的规则（go.dev/ref/mem）：

1. 发送 happens-before 对应接收完成。发送方在发送之前写入的一切，接收方拿到数据时一定可见。这条对所有 channel 成立（缓冲无缓冲一样），官方文档的保证例子用的就是容量 10 的缓冲 channel。
2. 无缓冲 channel：接收 happens-before 对应发送完成。反向也成立，所以无缓冲是双向同步。这条只对无缓冲成立。
3. 关闭 channel happens-before 任何因关闭而返回零值的接收。
4. 容量 C 的 channel：第 k 次接收 happens-before 第 k+C 次发送完成。这是规则 2 对缓冲的推广：接收方的效果要隔 C 次发送（第 k+C 次发送完成）才对发送方可见。C=1 时，第 1 次接收只和第 2 次发送（如果存在）建立关系。

规则 2 反直觉，先解释它。"发送完成"指发送方确认数据被拿走、自己可以返回的那一刻，不是数据递出去的那一刻。无缓冲发送不是"发出去就完事"：发送方必须等接收方真正拿到才返回。用交接包裹打比方，三个事件按这个顺序发生：

递出包裹（发送）→ 对方接住（接收完成）→ 投递员松手离开（发送完成）

所以发送 happens-before 接收完成（规则 1），接收完成 happens-before 发送完成（规则 2）。两条合起来，无缓冲收发是背靠背的同步点：发送方不能假装完成，必须等接收方拿走；接收方也不能假装完成，必须等发送方给数据。规则 2 保证的是反向可见性：接收方在接收之前写入的数据，发送方在发送完成后一定看得到（go.dev/ref/mem 有交换方向的例子：f 里先写变量再 `<-c`，main 里 `c <- 0` 再读变量，同样有保证）。

有缓冲 channel 的同步能力：正向（发送方 → 接收方）由规则 1 保证，有缓冲也成立——数据进缓冲区也是发送，接收方从缓冲区读到的就是这次发送的数据，收发匹配。缺的只是规则 2 的反向同步：发送完成 = 数据进缓冲区，不等接收者，"接收先于发送完成"不成立。反向可见性要靠规则 4 补：接收方的效果隔 C 次发送才对发送方可见。

### 经典题：为什么接收方写的数据，换成有缓冲就可能看不到 "hello"

注意方向：发送方写、接收方读（规则 1 的方向）缓冲也保证，官方文档的保证例子用的就是 make(chan int, 10)。"换成有缓冲不保证"的是反向：接收方写数据、发送方读，它依赖规则 2，只对无缓冲成立。

```go
var a string
c := make(chan int) // 无缓冲：保证；换成 make(chan int, 1)：不保证

go func() {
	a = "hello"
	<-c      // 接收
}()
c <- 0      // 发送
print(a)    // 无缓冲时一定 "hello"；有缓冲时可能是 ""
```

无缓冲为什么保证：发送必须等接收者到场才能完成。a 的写入在接收之前（程序顺序），接收 happens-before 发送完成（规则 2），发送完成 happens-before print（程序顺序），链完整，一定可见。

换成有缓冲为什么不保证：发送完成 = 数据进缓冲区，不等接收者。main 放下数据可能立刻 print，此时子 goroutine 可能还没运行、还没写 a；就算已经写了，写入和 print 之间也没有同步边——规则 2 不适用（有缓冲没有这条），规则 4 需要"第 2 次发送"才生效，这里只有 1 次发送。所以 print(a) 是数据竞争，"" 或 "hello" 都可能，规范不保证。

### 用了缓冲也一定可见的变体

正向方向用缓冲完全没问题，规则 1 对所有 channel 成立，官方文档的保证例子就是容量 10 的缓冲 channel：

```go
var a string
c := make(chan int, 10) // 缓冲也没问题

go func() {
	a = "hello"
	c <- 0
}()
<-c      // 读到的就是这次发送的数据，收发匹配
print(a) // 一定 "hello"
```

反向方向想用缓冲，需要让规则 4 生效：容量 C 时，发送方在接收之后至少再发 C 次，第 k+C 次发送完成才与第 k 次接收建立同步。容量 1 时多发一次：

```go
var a string
c := make(chan int, 1)

go func() {
	a = "hello"
	<-c      // 接收 1
}()
c <- 0      // 发送 1
c <- 1      // 发送 2：第 1+1 次发送完成，与接收 1 建立同步
print(a)    // 一定 "hello"
```

链：a 写入（程序顺序，在接收 1 前）→ 接收 1 → 发送 2 完成（规则 4）→ print（程序顺序）。只发一次就没有这条链——这就是"换成有缓冲不保证"的确切机制：规则 2 缺失，规则 4 又没有第 2 次发送可挂钩。规则 4 也是"缓冲 channel 当信号量"的理论依据：缓冲区最多同时容纳 C 个在途元素，第 k+C 次发送者必然能看到第 k 次接收的效果。

## 常见模式

Worker pool（生产者消费者）：任务进 jobs，固定数量 worker 消费，close(jobs) 让 worker 的 range 自然退出。多个 worker 并发写 results 是安全的，但 results 得有人读，否则缓冲区满会把 worker 堵住：

```go
jobs := make(chan int, 100)
results := make(chan int, 100)

for i := 0; i < 4; i++ {
	go func() {
		for j := range jobs { // jobs 关闭且清空后自动退出
			results <- j * 2
		}
	}()
}

for i := 0; i < 100; i++ {
	jobs <- i
}
close(jobs) // 只由发送方关闭

for i := 0; i < 100; i++ {
	<-results
}
```

扇出扇入（fan-out / fan-in）：一个任务 channel 分发到多个 worker（扇出），worker 结果汇入一个 channel（扇入）。扇入端多个 goroutine 写同一个 channel 不需要额外锁，这是 channel 相对共享内存加锁的典型优势。

信号量：缓冲容量 N 的 channel 当信号量用，限制并发数（内存模型第 4 条就是它的理论依据）：

```go
limit := make(chan struct{}, 3)
for _, w := range work {
	go func(w func()) {
		limit <- struct{}{} // 占名额，满了阻塞
		w()
		<-limit             // 释放
	}(w)
}
```

两个 goroutine 交替打印 1~100。无缓冲 channel 当令牌用，谁拿到令牌谁打印。先打印的一定是 1，不是随机：main 的第一步就是发令牌，无缓冲发送必须等接收者就绪——子 goroutine 还没执行到 `<-ch` 时，main 会阻塞在发送上；子 goroutine 拿到令牌后第一件事就是打印 1，之后才发令牌放 main 打印 2。顺序被握手锁死。注意最后一步不能再发令牌：接收方已经退出，发送会永久阻塞，运行时直接死锁报错：

```go
func main() {
	ch := make(chan struct{})
	go func() {
		for i := 1; i <= 100; i += 2 {
			<-ch
			fmt.Println(i)
			ch <- struct{}{}
		}
	}()
	ch <- struct{}{} // 先给令牌
	for i := 2; i <= 100; i += 2 {
		<-ch
		fmt.Println(i)
		if i < 100 { // 最后一步不再发，否则没人收，死锁
			ch <- struct{}{}
		}
	}
}
```

优雅退出（done channel 广播）：close(done) 让所有等在 done 上的 goroutine 同时拿到零值退出，一对多通知。配合 select 检查退出信号：

```go
done := make(chan struct{})
jobs := make(chan int)

go func() {
	for {
		select {
		case <-done:
			return // 收到退出信号
		case j := <-jobs:
			// 干活
		}
	}
}()

// 某个时刻统一退出
close(done)
```

关闭原则：close 只由发送方执行，且一个 channel 只关一次。多个发送者时不要各自 close：要么引入一个专门的协调 goroutine 负责关闭，要么用 sync.Once 包住 close。

channel 与 mutex 怎么选：传递所有权、协作调度（任务分发、退出通知、流水线）用 channel；保护共享数据结构（计数器、map 的临界区）用 mutex。官方谚语：不要通过共享内存来通信，要通过通信来共享内存（Do not communicate by sharing memory; instead, share memory by communicating）。

## 泄漏与死锁

goroutine 泄漏的典型：发送没人接收，且永远不会有接收者出现：

```go
ch := make(chan int)
go func() { ch <- 1 }() // 没有接收者，永久阻塞，这个 goroutine 泄漏
```

排查手段：go vet 的 copylocks 查不到这类问题；pprof 的 goroutine profile 能看到卡在 chansend 的栈；实际项目里给收发都加超时/context 兜底，避免泄漏挂死整个流程。

死锁：所有 goroutine 都阻塞在 channel 上，运行时检测后 fatal error: all goroutines are asleep - deadlock!，程序直接崩溃退出。无缓冲 channel 收发没有配对、循环等待（A 等 B 发、B 等 A 发）都会触发。

## 面试题

### 无缓冲 channel 和有缓冲 channel 有什么区别？

答：无缓冲 channel 的收发必须配对。发送方会等接收方就绪，接收方也会等发送方就绪，双方完成一次同步交接。

有缓冲 channel 允许发送方先把值放进缓冲区。缓冲区未满时发送不阻塞，空时接收阻塞；它解耦收发节奏，但不会取消同步规则。

### 向已关闭的 channel 发送会怎样？从已关闭的 channel 接收呢？

答：发送会触发 `panic: send on closed channel`。关闭后仍可接收：先读完缓冲区中的值，之后立即得到元素类型的零值和 `ok=false`。

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

v1, ok1 := <-ch // 7, true
v2, ok2 := <-ch // 0, false
```

### nil channel 的收发和关闭分别会怎样？

答：向 nil channel 发送或从中接收都会永久阻塞。它出现在 `select` 的 case 中时，该 case 永远不会就绪，可以用来动态禁用分支。

关闭 nil channel 会触发 `panic: close of nil channel`。nil channel 和已关闭 channel 的行为不同。

### select 有多个 case 同时就绪时会选哪一个？

答：Go 会从可执行的 case 中做 uniform pseudo-random（均匀伪随机）选择，不按源码顺序，也不保证轮流执行。

有 `default` 时，只有其他 case 都不能立即执行才会走 `default`。没有 `default` 时，当前 goroutine 会挂起，直到某个 case 就绪。

### 谁应该关闭 channel？多个发送者怎么处理？

答：由能确定“不会再发送”的一方关闭，通常是唯一发送者或协调者。接收方不应关闭 channel，因为它无法确认是否还有发送者。

多个发送者不能各自尝试 `close`。应由协调 goroutine 在所有发送者退出后统一关闭；只需要防止重复关闭时，也可以用 `sync.Once`。

### 无缓冲 channel 建立了哪些 happens-before 关系？

答：happens-before（先行发生）表示前一个操作的写入对后一个操作保证可见。无缓冲 channel 同时有两条规则：

- 发送 happens-before 对应接收完成。
- 接收 happens-before 对应发送完成。

第一条对有缓冲 channel 也成立。第二条只属于无缓冲 channel，因此无缓冲收发能建立双向同步。

### channel 为什么会导致 goroutine 泄漏？怎么避免？

答：goroutine 在收发上永久阻塞，而且以后也不会出现配对操作时，就会泄漏。常见情况是结果 channel 无人接收，或流水线下游提前退出，上游仍在发送。

给阻塞操作提供退出路径。可以在 `select` 中监听 `context.Done()`，并明确 channel 的关闭者；排查时查看 pprof 的 goroutine profile，确认是否长期卡在 `chansend` 或 `chanrecv`。

### channel 和 mutex 应该怎么选？

答：任务分发、流水线和退出通知适合 channel，因为重点是 goroutine 之间传递数据或所有权。

计数器、map 和其他共享状态适合 mutex（互斥锁），因为重点是保护临界区。不要为了避开锁而把简单的共享状态硬改成 channel 模型。


---

> 作者: Nite  
> URL: https://www.nite07.com/zh-cn/posts/go-channel/  

