Go Channel
Go channel 面试要点整理。覆盖:基本用法、无缓冲与有缓冲的区别、读写 panic 案例、超时与非阻塞收发、select 语义、底层结构(hchan)、内存模型、常见模式、泄漏与死锁。
基本用法
创建、收发、关闭、遍历:
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 只能读),编译期保证方向,常用于函数签名约束:
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 / 接收零值。
超时与非阻塞收发
超时接收:
select {
case v := <-ch:
// 收到
case <-time.After(3 * time.Second):
// 超时
}循环内反复用超时,用 NewTimer 而不是 time.After:time.After 每次调用都新建一个 Timer,触发前不会被回收,循环里会堆积;NewTimer 配合 defer timer.Stop() 可复用:
timer := time.NewTimer(3 * time.Second)
defer timer.Stop()
select {
case v := <-ch:
case <-timer.C:
}非阻塞收发(try-send / try-receive),select + default:
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):
- 发送 happens-before 对应接收完成。发送方在发送之前写入的一切,接收方拿到数据时一定可见。这条对所有 channel 成立(缓冲无缓冲一样),官方文档的保证例子用的就是容量 10 的缓冲 channel。
- 无缓冲 channel:接收 happens-before 对应发送完成。反向也成立,所以无缓冲是双向同步。这条只对无缓冲成立。
- 关闭 channel happens-before 任何因关闭而返回零值的接收。
- 容量 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,只对无缓冲成立。
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:
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 时多发一次:
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 堵住:
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 条就是它的理论依据):
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。顺序被握手锁死。注意最后一步不能再发令牌:接收方已经退出,发送会永久阻塞,运行时直接死锁报错:
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 检查退出信号:
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 泄漏的典型:发送没人接收,且永远不会有接收者出现:
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。
ch := make(chan int, 1)
ch <- 7
close(ch)
v1, ok1 := <-ch // 7, true
v2, ok2 := <-ch // 0, falsenil 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 模型。