channel
channel 是 Go 中用于传递指定类型值的通道,发送和接收还可以在 goroutine 之间建立同步关系。它让任务通过数据交付配合执行,但不会自动决定谁负责关闭、谁等待任务完成,以及接收方提前退出后怎么办。
判断一段 channel 代码,可以先看通道是否为 nil、是否已关闭、有没有缓冲空间,再看另一端是否仍会执行。本文以 Go 1.22 为基线,区分语言保证与该版本运行时的实现。
1. 面试核心
先记住这几条,再读正文- 无缓冲 channel 需要发送与接收配对,有缓冲 channel 允许一定数量的值等待接收。容量限制排队数量,不等于任务并发数,也不保证消费速度。卡 · 缓冲、阻塞与顺序
- nil、打开但为空、关闭且排空是三种不同状态:nil 通道收发一直阻塞;打开的空通道等待发送方;关闭且排空的通道立即返回元素零值和
false。卡 · 把状态与操作放在一起判断 - 关闭后仍可接收缓冲中的值,排空后才得到
ok == false。向已关闭通道发送、重复关闭,以及关闭 nil 通道都会 panic。卡 · 关闭后先排空,再返回结束状态 - 关闭表示不再发送,不表示释放内存或消费者已经处理完数据。关闭者必须能够确认不会再有发送;多个生产者通常由协调者等待它们完成后统一关闭。卡 · 谁能确认不会再发送,谁安排关闭
- Go 1.22 的 channel 由运行时维护缓冲区、发送与接收等待队列及内部锁。收发操作在满足条件时交付值,并把相应等待任务变成可运行状态,不需要额外任务轮询所有 channel。卡 · Go 1.22 的运行时实现
- 同步保证建立在具体收发或关闭事件之间。发送前的写入可通过对应接收发布,但发送后的任意修改不自动受保护;发送切片和指针也不是深拷贝。卡 · 同步保证与传值范围
- channel 的内部同步只保护通道操作,不会替业务共享状态加锁。channel、Mutex 和 WaitGroup 分别表达数据交付、共享状态保护和工作完成。卡 · channel 与 Mutex、WaitGroup 的分工
- 多个接收者从同一个 channel 取值时,一次发送只交给其中一次接收,不会自动广播给所有订阅者。关闭通知和多次事件广播需要分别设计。卡 · 竞争消费与广播通知
len(ch)是瞬时观察,不是下一次操作的许可。检查之后状态可能变化,也没有一个可供通用安全关闭使用的「先检查是否关闭」操作。卡 · len 与状态检查不能替代操作
2. 通道类型与创建
channel 类型声明元素类型,也可以限制允许的操作:
| 类型 | 允许的操作 | 常见接口用途 |
|---|---|---|
chan int | 发送、接收和关闭 | 创建和协调通道 |
chan<- int | 发送和关闭 | 只向调用方暴露写入能力 |
<-chan int | 接收 | 只向调用方暴露结果读取能力 |
方向限制由编译器检查,但「允许关闭」不等于「这个调用者在业务上负责关闭」。关闭责任仍由接口约定决定。
package main
import "fmt"
func put(dst chan<- int, value int) {
dst <- value
}
func take(src <-chan int) int {
return <-src
}
func main() {
jobs := make(chan int, 2)
alias := jobs
fmt.Println(jobs == alias, len(jobs), cap(jobs)) // true 0 2
put(alias, 7)
before := len(jobs)
value := take(jobs)
after := len(jobs)
fmt.Println(before, value, after) // 1 7 0
var input <-chan int = jobs
_ = input
// input <- 8 // 编译错误:不能向只接收通道发送
// close(input) // 编译错误:不能关闭只接收通道
}
make(chan int, 2) 创建容量为 2 的缓冲通道。赋值给 alias 复制的是 channel 值,两者指向同一条通道,所以通过 alias 发送的值可以从 jobs 接收。传入函数也不会复制出新的缓冲队列。
var ch chan int 的零值是 nil。make(chan int) 和 make(chan int, 0) 则创建可用的无缓冲通道,两者都不是 nil。len 返回当前缓冲中尚未接收的元素数,cap 返回容量;无缓冲和 nil 通道的 len、cap 都是零。
3. 缓冲、阻塞与顺序
3.1 缓冲让交付可以暂时错开
无缓冲通道没有存放待收元素的容量,发送要等对应接收参与。有缓冲通道在有空位时可以先保存值,让发送方继续;缓冲满了以后,发送仍要等待接收方释放空间。
package main
import "fmt"
func main() {
jobs := make(chan int, 2)
jobs <- 10
jobs <- 20
fmt.Println(len(jobs), cap(jobs)) // 2 2
// jobs <- 30 // 此处发送会阻塞;没有其他 goroutine 来接收
fmt.Println(<-jobs) // 10
jobs <- 30
fmt.Println(<-jobs, <-jobs) // 20 30
}
前两次发送不需要同时存在接收者。取出 10 后腾出一个位置,30 才能进入缓冲;接下来仍先取出 20,再取出 30。channel 按已建立的发送顺序交付值,但多个并发发送者谁先完成发送,不能只根据启动顺序判断;多个消费者处理完任务的顺序也未必与接收顺序相同。
发送完成不等于接收方已经完成业务处理。即使使用无缓冲通道,它也只建立这次值交付的配合;接收后的数据库写入、计算或文件输出是否完成,需要另外的结果或完成通知。
3.2 把状态与操作放在一起判断
下表讨论普通的阻塞式收发,尚未引入带 default 的非阻塞选择:
| channel 状态 | 发送 | 接收 | 关闭 |
|---|---|---|---|
| nil | 一直阻塞 | 一直阻塞 | panic |
| 打开的无缓冲通道 | 等待对应接收 | 等待对应发送 | 成功,通知等待者 |
| 打开的有缓冲通道 | 有空位可完成,满时等待 | 有元素可完成,空时等待 | 成功,已有元素保留 |
| 已关闭,仍有缓冲元素 | panic | 取出元素,ok == true | panic |
| 已关闭,缓冲已空 | panic | 立即返回零值,ok == false | panic |
这里的等待描述任务尚不能继续,不承诺某个 goroutine 获得 CPU 的时刻。nil 通道也不会因为之后给另一份 channel 变量赋值,就把已经阻塞在旧 nil 值上的操作接到新通道上。
「空 channel」需要先澄清含义。一个打开但暂时没有元素的通道,可能随后收到数据;nil 通道没有可完成通信的对象;关闭且排空则可以立即读到结束状态。这三种情况下 len(ch) 都可能为零。
3.3 缓冲容量与背压
背压是下游暂时处理不过来时,通过等待或限制继续接收的工作,把压力传回上游。有限容量的 channel 可以在缓冲满时阻塞发送者,形成一部分背压,但前提是发送者的数量和来源也受到控制。
如果每个请求都新建 goroutine,然后让它阻塞发送,通道虽然只有 10 个缓冲位置,通道外仍可能堆积大量等待任务及其数据。容量限制了队列中的元素数,没有限制生产者数量,也不会限制消费者内部再次启动多少任务。
缓冲可以吸收短暂突发。持续生产速度高于消费速度时,再大的有限缓冲也会填满,之后要选择等待、拒绝、限流或其他明确策略。容量增加还可能增加排队时延和对象存活时间,不应把「调大缓冲」当成通用性能修复。
4. 关闭与任务结束
4.1 关闭后先排空,再返回结束状态
close(ch) 宣告不会再向这条通道发送值,不会丢弃已经进入缓冲的数据。两值接收 value, ok := <-ch 中,ok 表示这次是否取得了发送的值,不表示通道此刻是否仍然打开。
package main
import "fmt"
func main() {
values := make(chan int, 2)
values <- 0
values <- 7
close(values)
for {
value, ok := <-values
fmt.Println(value, ok)
if !ok {
break
}
}
}
输出依次为 0 true、7 true、0 false。第一次的零来自实际发送,最后的零来自通道已关闭且排空,只有 ok 能区分它们。
for value := range ch 会持续接收,直到通道关闭且排空后结束。打开的空通道会让它等待,nil 通道也会一直等待;不能把 range 理解成「把当前已有元素读完就结束」。
关闭时仍在等待的接收者会被通知结束;阻塞的发送者则会在恢复执行后因通道已关闭而 panic。给通道加一个 select 并不能让向已关闭通道发送变得合法。
4.2 谁能确认不会再发送,谁安排关闭
只有一个生产者时,通常由生产者在发送结束后关闭。多个生产者共用通道时,不能让每个生产者各自 defer close(ch):第一个结束的生产者就可能关闭其他生产者仍在使用的通道。
下面让协调者等待全部生产者结束,再关闭 out。消费者持续读取到结束,因此生产者不会因为无人接收而滞留。
package main
import (
"fmt"
"sort"
"sync"
)
func merge(batches [][]int) <-chan int {
out := make(chan int, 2)
var producers sync.WaitGroup
for _, batch := range batches {
producers.Add(1)
go func(values []int) {
defer producers.Done()
for _, value := range values {
out <- value
}
}(batch)
}
go func() {
producers.Wait()
close(out)
}()
return out
}
func main() {
var values []int
for value := range merge([][]int{{1, 3}, {2, 4}}) {
values = append(values, value)
}
sort.Ints(values)
fmt.Println(values) // [1 2 3 4]
}
两个批次可以交错交付,排序只是让演示输出稳定,没有要求通道把所有生产者的输入合成某个全局排序。producers.Wait() 保证不会再有生产者发送,消费者的 range 则负责读完关闭前的所有值。
这个接口要求消费者把数据读完,并且调用方在生产期间不修改输入批次。如果要允许消费者提前退出,还需要把取消信号传回生产者;否则生产者可能卡在发送上,协调者也就等不到它们结束。
4.3 关闭、回收与任务完成
channel 不要求像文件一样必须关闭才能被垃圾回收。通道及其数据不再可达时,可以由垃圾回收处理;如果阻塞的 goroutine 仍持有通道或待发送数据,就不能据此认为资源已经不再被使用。
是否需要关闭,取决于通信约定。如果接收者已知只收一次结果,收完即结束,可能不需要关闭。如果接收者用 range 等待未知数量的值,就需要约定结束通知,否则它不知道是否还会有下一条。
关闭也不等于所有消费者处理完毕。消费者可能已经取出最后一个值,但仍在计算;需要知道处理是否完成时,应等待消费者自己的结果或完成通知。等待一组任务的方法见 goroutine 的等待任务完成。
sync.Once 可以约束关闭动作最多执行一次,但不能保证关闭时没有其他发送者。没有发送与关闭之间的生命周期协调,仍可能发生发送已关闭通道的 panic。
5. 同步保证与传值范围
channel 不只传递值,还为两端建立先后关系:一次发送发生在对应接收完成之前;关闭发生在因关闭而取得零值的接收之前。因而,发送或关闭之前的写入,可以沿这些关系交给另一端观察。
package main
import "fmt"
func main() {
var result string
done := make(chan struct{})
go func() {
result = "ready"
close(done)
}()
<-done
fmt.Println(result) // ready
}
result 先被写入,再关闭 done;主 goroutine 从关闭的通道完成接收后才读取 result,因此这里不需要再为这次单次交付加锁。若改成先关闭再写入,就失去了保护该次读写的顺序。
对于有缓冲通道,收到一个缓冲值不代表已经观察到了后续关闭。也不能从一次发送推出「发送方之后的所有修改都先于接收方读取」。
发送传递的是元素值的副本,不会递归复制其关联对象:
package main
import "fmt"
func main() {
numbers := []int{1, 2}
queue := make(chan []int, 1)
queue <- numbers
numbers[0] = 9
received := <-queue
fmt.Println(received) // [9 2]
fmt.Println(&received[0] == &numbers[0]) // true
}
通道复制了切片值,两个切片仍引用同一个底层数组。这里都在一个 goroutine 中执行,没有数据竞争;如果发送方继续修改数组,接收方在另一个 goroutine 同时读取,就需要新的同步或明确的所有权交接。切片复制的范围见 Go 切片。
无缓冲通道还保证对应接收发生在发送完成之前,因此有时能用交付本身建立双向配合。有缓冲通道不能直接套用这条无缓冲保证。完整的同步推导应依照 Go 内存模型,而不是根据某次输出推断执行顺序。
6. Go 1.22 的运行时实现
6.1 缓冲区与等待队列
Go 1.22 的运行时用 hchan 保存一条通道的状态。下面列出用于理解收发路径的主要信息,不把内部字段当作应用可以依赖的 API:
| 状态 | 对应信息 | 作用 |
|---|---|---|
| 缓冲队列 | buf、dataqsiz、qcount | 保存元素,记录容量与当前数量 |
| 读写位置 | sendx、recvx | 在环形缓冲区中定位下一次写入和读取 |
| 元素描述 | elemtype、elemsize | 按元素类型复制、清理数据 |
| 结束状态 | closed | 记录通道是否关闭 |
| 等待者 | sendq、recvq | 保存等待发送或接收的任务记录 |
| 内部锁 | lock | 协调内部状态的并发修改 |
缓冲区和这些管理结构位于用户进程中,由 Go 运行时管理,不是操作系统内核中的消息队列。缓冲容量为零时没有用于排队的元素槽,但仍需要记录等待者和同步状态。
等待记录把一个等待的 goroutine、这次通信的值位置等信息联系起来,运行时内部使用 sudog 表示。它不是把整个 goroutine 复制进 channel,也不是另外启动了一个 goroutine 来监视这条通道。
6.2 发送、接收和唤醒怎样衔接
发送端在处理 nil、已关闭等条件后,沿以下主要路径选择:
- 已有等待的接收者时,可以直接把值交给接收者,即使通道有缓冲,也不一定先写入缓冲区。
- 没有等待接收者,但缓冲有空位时,把值复制进环形缓冲区,更新位置和数量。
- 暂时无法完成时,阻塞式发送把等待记录加入发送队列,挂起当前 goroutine。
接收端同样要看关闭状态、缓冲与等待发送者。缓冲非空时先交付更早进入的值;缓冲已满且有发送者等待时,可以在取出队头值的同时,把等待发送者的值补入腾出的槽位,保持队列顺序。无缓冲通道则在收发双方之间直接交付。
当收发或关闭让某个等待操作能够继续时,运行时从等待记录找到对应 goroutine,把它放回可运行状态。它何时真正得到执行机会,还由调度器决定;唤醒不等于立即运行,也不保证业务处理的公平性或完成顺序。
内部锁保护通道状态,但不能据此把每一条实现路径都概括成同一串加锁步骤。Go 1.22 对某些无法完成的非阻塞操作也有快速判断路径;面试解释应先讲清状态、数据交付与等待的因果关系。
7. 使用边界与选择
7.1 channel 与 Mutex、WaitGroup 的分工
channel 适合表达数据交付和任务之间的配合,Mutex 适合保护共享状态及其不变量,WaitGroup 适合等待登记的工作完成。它们可以组合使用,不能仅因为项目用了 channel,就认定其余同步机制多余。
例如,工作队列用 channel 分发任务,共享统计数据可能仍需要锁,退出时还可能需要等待所有工作者结束。也可以把统计完全交给一个任务维护,消除那部分共享修改;选择取决于整体数据流和复杂度。
channel 的内部锁保证多个任务可以按规则并发收发,不会自动保护发送前后的业务对象,也不会让「检查状态,再发送,再修改其他字段」成为一个原子操作。并发执行 close 与尚未结束的发送,也不属于安全的生命周期设计。
7.2 竞争消费与广播通知
多个消费者从同一条 channel 接收时,每个发送值只会交给一次接收。它适合把工作分给某个消费者,不适合直接表达「所有观察者都要收到这次事件」。
| 目标 | 通道安排 | 还要决定什么 |
|---|---|---|
| 一项任务由一个消费者处理 | 多个消费者共享一条任务通道 | 完成确认、失败处理与退出 |
| 一次性通知所有等待者结束 | 关闭一条约定的 chan struct{} | 谁负责关闭,收到通知后如何清理 |
| 每次事件都交给每个订阅者 | 每个订阅者有自己的通道,由发布者分别交付 | 慢订阅者是否阻塞发布、是否允许丢弃、退订与关闭如何协调 |
关闭只能发生一次,不能表达可反复发布的事件流。给每个订阅者分配通道也不自动获得可靠投递:发布者退出、订阅者不读或缓冲满时,仍需要业务规则处理。
7.3 len 与状态检查不能替代操作
len(ch) < cap(ch) 之后再发送,并不能保证发送不会阻塞:另一个发送者可能先占满空间。len(ch) > 0 之后接收也类似,值可能已经被别的消费者取走。
需要「当前不能完成就走另一条路径」时,应使用 select 和 default,让选择与通信作为一次操作表达。无缓冲通道容量始终为零,却仍可能与已就绪的另一端通信,单看 len、cap 更无法判断这种就绪状态。
同理,先检查某个标记,再决定是否关闭或发送,并不能自动排除两步之间的变化。安全关闭依靠发送者与关闭者的协调,而不是只读一次状态。通过尝试接收判断是否关闭,还可能顺便消费掉一个真实值。
7.4 接收方提前退出时,发送方怎样结束
接收方不再读取时,生产者可能停在发送上。需要允许放弃这次发送,可以把发送和取消放进同一个 select;select 等待其中一个分支可执行。
package main
import (
"context"
"fmt"
)
func publish(ctx context.Context, out chan<- int, value int) bool {
select {
case out <- value:
return true
case <-ctx.Done():
return false
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
out := make(chan int)
finished := make(chan bool)
go func() {
finished <- publish(ctx, out, 7)
}()
cancel()
fmt.Println(<-finished) // false
}
out 没有接收者,所以发送不能完成;取消后 publish 返回 false,调用方仍接收 finished 来确认这次工作已经结束。
这个例子要求发送期间 out 不被关闭。取消分支不能保护向已关闭通道发送;发送与取消同时可执行时,也不保证优先选择取消。取消与退出的区别见 goroutine。
8 · 面试题卡片
本页题目与参考回答
8.1 有缓冲与无缓冲 channel 有什么区别,分别适合什么场景?
考察重点:交付配合、排队容量、背压与业务完成边界。
相关内容:缓冲、阻塞与顺序。
参考回答
**无缓冲通道要求发送与接收配对,有缓冲通道允许最多指定容量的值等待接收。**前者适合需要交付配合的场景,后者适合允许有限排队、吸收短暂突发的场景;满时发送仍会等待。
发送完成不等于业务处理完成。容量也不等于 goroutine 数量或系统吞吐,持续过载需要限流、拒绝或其他策略,不能只增加缓冲。
8.2 channel 什么情况下阻塞?nil 和没有元素的 channel 是否相同?
考察重点:通道状态、缓冲条件与对应收发方。
相关内容:把状态与操作放在一起判断。
参考回答
**nil 通道的普通收发一直阻塞;打开的无缓冲通道等待对应操作;有缓冲通道满时发送等待、空时接收等待。**打开但没有元素的通道仍可能在之后收到数据,与 nil 不同。
关闭且排空的通道接收不会阻塞,会立即得到零值和 false。因此听到「空 channel」时,需要先明确是 nil、暂时为空,还是关闭后排空。
8.3 关闭 channel 后还能读取吗?哪些操作会 panic?
考察重点:缓冲排空、两值接收、发送与关闭的非法状态。
相关内容:关闭后先排空,再返回结束状态。
参考回答
**关闭后仍可读完缓冲值,这些接收的 ok 为 true;排空后持续返回元素零值和 false。**不能只凭值是不是零判断是否结束。
向已关闭通道发送、再次关闭同一通道、关闭 nil 通道都会 panic。nil 通道的发送和接收本身则是阻塞,不是因为 nil 直接 panic。
8.4 channel 应由谁关闭?每条 channel 都必须关闭吗?
考察重点:结束协议、多发送者协调与资源回收的区别。
相关内容:谁能确认不会再发送,谁安排关闭、关闭、回收与任务完成。
参考回答
**应由能够确认不再有发送的一方安排关闭,通常是唯一生产者或等待全部生产者结束的协调者。**接收方不能因为暂时不想读,就直接关闭别人仍可能发送的通道;多个生产者也不能各自关闭。
不是所有通道都必须关闭,是否关闭取决于接收者如何识别结束。close 不负责释放内存;sync.Once 只防重复关闭,不能防止另一个任务在关闭后发送。
8.5 channel 的底层结构是什么,阻塞的 goroutine 如何被唤醒?
考察重点:缓冲与等待记录、直接交付、内部同步及运行时调度。
相关内容:Go 1.22 的运行时实现。
参考回答
**Go 1.22 的运行时用 hchan 维护缓冲区、读写位置、元素描述、关闭状态、收发等待队列和内部锁。**这些属于用户进程内的运行时结构,不是内核消息队列。
发送可以直接交给等待接收者,也可以写入有空位的缓冲;无法完成时,记录等待操作并挂起当前 goroutine。接收或关闭改变条件后,运行时找到对应等待者,把它变成可运行状态,无需独立任务遍历所有通道。被唤醒后何时执行,仍由调度器决定。
8.6 channel 可以并发收发,为什么项目里还可能需要 Mutex 和 WaitGroup?
考察重点:通道内部安全、业务共享状态和工作完成的不同保证。
相关内容:channel 与 Mutex、WaitGroup 的分工。
参考回答
**channel 的同步保护通道操作,不会自动保护所有业务状态,也不会自动等待任务完成。**Mutex 可以保护共享统计或多个字段组成的不变量,WaitGroup 可以等待一组工作完成,channel 则负责数据交付或通知。
是否需要组合,要看数据流和状态归属。也可以把某个状态交给单一任务维护,通过消息更新;不能仅凭出现了 channel 就判断其他同步机制多余。
8.7 怎样用 channel 把多个生产者的数据交给消费者,并确定何时结束?
题型:综合题
考察重点:生产者登记、有限缓冲、关闭责任和消费者退出。
相关内容:谁能确认不会再发送,谁安排关闭、接收方提前退出时,发送方怎样结束。
参考回答
**多个生产者发送到同一通道,由协调者等待全部生产者结束后关闭,消费者持续接收到通道关闭且排空。**正文的 merge 给出有限批次的参考实现;计数在启动前登记,生产者各自完成后通知,只有协调者关闭。
该实现要求消费者把数据读完。若允许提前结束,需要让生产者的发送也观察取消,否则生产者可能阻塞,协调者也无法结束。缓冲容量只限制通道内排队量,不限制生产者数量。
8.8 如何用 channel 实现观察者通知?共用一条 channel 能否广播事件?
考察重点:竞争消费、一次性关闭通知与多订阅者事件交付。
相关内容:竞争消费与广播通知。
参考回答
**共用一条 channel 接收会分摊值,不能让每个观察者都收到同一个发送值。**一次性结束通知可以关闭共享的 chan struct{};需要反复广播事件时,可为每个订阅者维护通道,由发布者逐个交付。
还必须定义慢订阅者、缓冲满、取消订阅和关闭的处理方式。简单增加订阅通道数量,并不会自动提供可靠投递。
8.9 通过 channel 传值后,发送方能否继续修改原对象?
考察重点:值复制范围、同步事件与对象所有权。
相关内容:同步保证与传值范围。
参考回答
**要看复制的值是否仍关联共享对象,以及后续访问是否有同步。**发送整数会复制整数值,发送指针或切片仍可能共享所指对象或底层数组;发送方之后的写入并不会因之前发送过一次而自动安全。
可以约定发送后转移访问责任,也可以继续用锁或其他同步协调。一次发送与对应接收、关闭与观察到关闭的接收分别建立同步关系,不能把这些保证扩大成对全部后续操作的保护。
8.10 能否先检查 len 或关闭状态,再安全地收发或关闭?
考察重点:瞬时状态、检查与执行之间的变化及生命周期协调。
相关内容:len 与状态检查不能替代操作。
参考回答
一般不能。检查与执行是两个步骤,中间可能被其他任务改变状态。len(ch) > 0 后元素可能被取走,有空位后也可能被填满;无缓冲通道的就绪性更无法由长度判断。
需要非阻塞通信时使用带 default 的 select;需要安全关闭时协调所有发送者结束。select 不会让向已关闭通道发送变得安全,先读取一个关闭标记也不能代替这种协调。
9. 参考资料
- Go 1.22 语言规范:通道类型、发送、接收、range 与 close
- Go 内存模型:channel 通信的同步关系
- Go 1.22.12:channel 运行时实现
- Go 1.22.12:goroutine 等待记录
- Go Concurrency Patterns: Pipelines and cancellation
来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明