跳到主要内容

Channel

问题

Channel 的底层数据结构是什么?有缓冲和无缓冲 Channel 有什么区别?关闭 Channel 有什么注意事项?

答案

Channel 基础

Channel 是 Go 的核心并发原语,用于 goroutine 之间的通信和同步:

// 无缓冲 channel(同步)
ch := make(chan int)

// 有缓冲 channel(异步,缓冲区满才阻塞)
ch := make(chan int, 10)

// 发送
ch <- 42

// 接收
val := <-ch
val, ok := <-ch // ok=false 表示 channel 已关闭且为空

// 关闭
close(ch)

底层数据结构

Channel 的底层是 runtime.hchan 结构:

type hchan struct {
qcount uint // 缓冲区中的元素数量
dataqsiz uint // 缓冲区大小(make 的第二个参数)
buf unsafe.Pointer // 环形缓冲区指针
elemsize uint16 // 元素大小
closed uint32 // 是否已关闭
elemtype *_type // 元素类型
sendx uint // 发送索引(环形)
recvx uint // 接收索引(环形)
recvq waitq // 等待接收的 goroutine 队列
sendq waitq // 等待发送的 goroutine 队列
lock mutex // 互斥锁
}

无缓冲 vs 有缓冲

维度无缓冲 make(chan T)有缓冲 make(chan T, n)
缓冲区环形缓冲区,大小 n
发送阻塞直到有接收者缓冲区满时阻塞
接收阻塞直到有发送者缓冲区空时阻塞
同步语义同步(握手)异步(解耦)
用途信号通知、数据交换生产者-消费者、任务队列
// 无缓冲:发送和接收必须同时就绪(同步)
ch := make(chan int)
go func() { ch <- 1 }() // 阻塞直到有人 <-ch
val := <-ch // 阻塞直到有人 ch<-

// 有缓冲:缓冲区没满就不阻塞
ch := make(chan int, 3)
ch <- 1 // 不阻塞
ch <- 2 // 不阻塞
ch <- 3 // 不阻塞
// ch <- 4 // 阻塞!缓冲区满了

发送和接收的详细流程

发送 ch <- val

接收 val := <-ch

Channel 关闭规则

这是面试必考的关键知识

操作nil channel已关闭 channel正常 channel
发送 ch<-永久阻塞panic正常发送/阻塞
接收 <-ch永久阻塞返回零值, ok=false正常接收/阻塞
关闭 close(ch)panicpanic正常关闭
关闭 Channel 的三条铁律
  1. 不要关闭已关闭的 channel(panic)
  2. 不要向已关闭的 channel 发送数据(panic)
  3. 只有发送方应该关闭 channel(接收方不知道是否还有数据要发)

安全关闭模式

// 模式 1:单发送者,发送完毕后关闭
func producer(ch chan<- int) {
for i := 0; i < 10; i++ {
ch <- i
}
close(ch) // 发送方关闭
}

// 模式 2:多发送者,用 sync.Once 确保只关闭一次
type SafeChannel struct {
ch chan int
once sync.Once
}
func (sc *SafeChannel) Close() {
sc.once.Do(func() {
close(sc.ch)
})
}

// 模式 3:多发送者,用额外信号 channel 协调
func fanIn(done <-chan struct{}, channels ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, ch := range channels {
wg.Add(1)
go func(c <-chan int) {
defer wg.Done()
for {
select {
case v, ok := <-c:
if !ok { return }
select {
case out <- v:
case <-done:
return
}
case <-done:
return
}
}
}(ch)
}
go func() {
wg.Wait()
close(out) // 所有发送者完成后关闭
}()
return out
}

单向 Channel

用于函数签名中约束 channel 的使用方向:

// 只能发送
func producer(out chan<- int) {
out <- 42
// <-out // 编译错误
}

// 只能接收
func consumer(in <-chan int) {
val := <-in
// in <- 1 // 编译错误
}

// 双向 channel 可以隐式转换为单向
ch := make(chan int)
go producer(ch) // chan int → chan<- int
go consumer(ch) // chan int → <-chan int

常用模式

// 1. 用 channel 做信号通知
done := make(chan struct{})
go func() {
doWork()
close(done) // 通知完成,close 不发送数据
}()
<-done // 等待完成

// 2. 超时控制
select {
case result := <-ch:
return result
case <-time.After(3 * time.Second):
return errors.New("timeout")
}

// 3. 限流(Semaphore)
sem := make(chan struct{}, 10) // 最多 10 个并发
for _, task := range tasks {
sem <- struct{}{} // 获取信号量
go func(t Task) {
defer func() { <-sem }() // 释放信号量
t.Process()
}(task)
}

常见面试问题

Q1: 为什么向已关闭的 channel 发送会 panic?

答案

关闭 channel 是一种"广播"——告诉所有接收者"不会再有新数据了"。如果允许在关闭后继续发送,接收者无法区分这是关闭前还是关闭后的数据,语义被破坏。Go 选择用 panic 而非静默忽略,强迫开发者正确管理 channel 的生命周期。

Q2: 从已关闭的 channel 读取会怎样?

答案

  • 缓冲区还有数据:正常读取,ok = true
  • 缓冲区为空:立即返回零值ok = false
  • 永远不会阻塞
ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch)
fmt.Println(<-ch) // 1 (ok=true)
fmt.Println(<-ch) // 2 (ok=true)
fmt.Println(<-ch) // 0 (ok=false,零值)
fmt.Println(<-ch) // 0 (ok=false,可以无限读)

Q3: nil channel 有什么用?

答案

向 nil channel 发送或接收都会永久阻塞。在 select 中,nil channel 的 case 永远不会被选中,可以用来动态禁用某个 case:

func merge(ch1, ch2 <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for ch1 != nil || ch2 != nil {
select {
case v, ok := <-ch1:
if !ok { ch1 = nil; continue } // 关闭后设为 nil,禁用此 case
out <- v
case v, ok := <-ch2:
if !ok { ch2 = nil; continue }
out <- v
}
}
}()
return out
}

Q4: Channel 的容量设为多少合适?

答案

  • 0(无缓冲):需要同步行为时——数据必须被另一端立即处理
  • 1:最常见的信号通知场景
  • 已知的生产者/消费者速率差:缓冲区大小 = 生产突发量
  • 经验法则:不确定时从小开始(1 或生产者数量),通过 benchmark 调整

过大的缓冲区只是延迟了问题(消费者跟不上最终还是会阻塞),且增加内存使用。

Q5: select 中多个 case 同时就绪会怎样?

答案

随机选择一个执行。Go 运行时会通过随机打乱 case 顺序来保证公平性,避免某个 case 总是优先。

ch1 := make(chan int, 1)
ch2 := make(chan int, 1)
ch1 <- 1
ch2 <- 2

select {
case v := <-ch1: fmt.Println("ch1:", v)
case v := <-ch2: fmt.Println("ch2:", v)
}
// 随机输出 "ch1: 1" 或 "ch2: 2"

Q6: Channel 和 Mutex 性能对比?

答案

Channel 的实现内部也有互斥锁(hchan.lock),所以 Channel 的开销 ≥ Mutex。在纯保护共享状态的场景下,Mutex 通常快 2-5 倍。

Channel 的价值不在于性能,而在于编程模型——它提供了数据所有权转移的语义,减少了并发推理的复杂度。

相关链接