Go Channel 通道

笔记/Go/Go核心编程/Go Channel 通道

背景与动机#

Go 里的 channel 经常被翻译成“通道”。它的作用不是简单地“存数据”,而是在 goroutine 之间传递数据、表达同步关系、声明生产者什么时候结束。

学习 channel 时,最容易卡住的是这几个问题:

  1. ch <- 1<-ch 到底谁会阻塞。
  2. 无缓冲 channel 和有缓冲 channel 的区别是什么。
  3. close(ch) 以后为什么不能继续发送。
  4. for v := range ch 为什么会自动结束。
  5. select 为什么能同时等多个 channel。

这一篇只聚焦 channel 本身,不展开调度器、MutexWaitGroup 这些内容。相关规则可以对照 Go 语言规范中的 Channel typesclose 内置函数Go Memory Model 的 Channel communication

核心原理拆解#

1. channel 是带类型的通信通道#

channel 的类型写法是:

chan int
chan string
chan<- int
<-chan int

含义分别是:

  • chan int:可以发送,也可以接收 int
  • chan<- int:只能发送 int
  • <-chan int:只能接收 int

创建 channel 要用 make

ch := make(chan int)

如果只声明不创建,channel 的零值是 nil

var ch chan int
fmt.Println(ch == nil) // true

nil channel 很特殊:对它发送或接收都会一直阻塞。

var ch chan int
ch <- 1 // 永久阻塞
v := <-ch // 永久阻塞

所以初学时要先记住:channel 必须 make 之后才能正常通信。

2. 发送和接收的基本写法#

发送数据:

ch <- 1

接收数据:

v := <-ch

发送和接收通常发生在不同 goroutine 里:

package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
ch <- 1
}()
v := <-ch
fmt.Println(v)
}

这个程序会输出:

1

如果没有 goroutine,下面这样会死锁:

func main() {
ch := make(chan int)
ch <- 1
fmt.Println(<-ch)
}

原因是无缓冲 channel 的发送必须等到接收方出现。执行到 ch <- 1 时,当前 goroutine 已经卡住了,后面的 <-ch 根本没机会执行。

3. 无缓冲 channel:发送和接收必须同时准备好#

无缓冲 channel 这样创建:

ch := make(chan int)

它没有内部缓冲区,所以发送方和接收方要同时配合:

  • 发送方先到,会等接收方。
  • 接收方先到,会等发送方。
  • 只有双方都准备好,通信才完成。

示例:

package main
import "fmt"
func main() {
ch := make(chan string)
go func() {
fmt.Println("before send")
ch <- "hello"
fmt.Println("after send")
}()
msg := <-ch
fmt.Println("receive:", msg)
}

可以把无缓冲 channel 理解成“当面交接”:发送方把值交给接收方之后,发送操作才算完成。

因此它不只是传值,还顺便完成一次同步。

4. 有缓冲 channel:缓冲区没满就能先发送#

有缓冲 channel 这样创建:

ch := make(chan int, 2)

第二个参数 2 表示缓冲区最多放 2 个元素。

package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 1
ch <- 2
fmt.Println(<-ch)
fmt.Println(<-ch)
}

这个程序不会死锁,因为缓冲区可以先存下 12

但如果继续发送第三个值,就会阻塞:

ch := make(chan int, 2)
ch <- 1
ch <- 2
ch <- 3 // 阻塞:缓冲区已经满了

有缓冲 channel 的规则是:

  • 缓冲区没满,发送不会阻塞。
  • 缓冲区满了,发送会阻塞。
  • 缓冲区不空,接收不会阻塞。
  • 缓冲区空了,接收会阻塞。
    image
    image

5. close 表示“不再发送新值”#

close(ch) 的意思不是“销毁 channel”,而是告诉接收方:以后不会再有新值发送到这个 channel。

close(ch)

关闭 channel 后,有三条非常重要的规则:

  • 已经放进缓冲区的值仍然可以继续接收。
  • 缓冲区读空后,再接收会拿到零值,okfalse
  • 关闭后再发送会 panic。

例如:

package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch)
v1, ok1 := <-ch
v2, ok2 := <-ch
v3, ok3 := <-ch
fmt.Println(v1, ok1)
fmt.Println(v2, ok2)
fmt.Println(v3, ok3)
}

输出:

1 true
2 true
0 false

这里第三次接收拿到 0 false,说明 channel 已经关闭并且没有剩余数据了。

6. 为什么 close 后再发送会 panic#

你问到的这段代码:

close(ch)
ch <- 1 // panic: send on closed channel

image-1
image-1
可以这样理解:

close(ch) 已经声明“不会再有新数据了”。如果后面又 ch <- 1,就等于破坏了这个声明。Go 选择直接 panic,而不是悄悄丢数据或让接收方困惑。

完整示例:

package main
func main() {
ch := make(chan int)
close(ch)
ch <- 1
}

运行时会报:

panic: send on closed channel

重复关闭也会 panic:

close(ch)
close(ch) // panic: close of closed channel

关闭 nil channel 也会 panic:

var ch chan int
close(ch) // panic: close of nil channel

因此有一个很常用的原则:

谁负责发送,谁负责关闭;接收方通常不要关闭 channel。

7. 用 v, ok := <-ch 判断 channel 是否关闭#

接收 channel 时,可以用两个返回值:

v, ok := <-ch

含义是:

  • ok == true:这个值是正常发送过来的。
  • ok == false:channel 已关闭,并且没有剩余数据了。

示例:

package main
import "fmt"
func main() {
ch := make(chan string, 1)
ch <- "task"
close(ch)
for {
v, ok := <-ch
if !ok {
fmt.Println("channel closed")
break
}
fmt.Println(v)
}
}

输出:

task
channel closed

如果 channel 的元素类型本身零值也有业务意义,就更应该用 ok 判断,而不是只看接收到的值。

比如 chan int 关闭后会收到 0,但正常发送也可能发送 0。这时必须靠 ok 区分。

8. 用 range 持续接收,直到 channel 关闭#

for range 可以不断从 channel 接收值:

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

这个循环什么时候结束?

答案是:channel 被关闭,并且里面的数据被读完之后结束。

示例:

package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)
for v := range ch {
fmt.Println(v)
}
}

输出:

1
2
3

如果忘记关闭,range 会一直等下一条数据,最后可能死锁:

ch := make(chan int, 1)
ch <- 1
for v := range ch {
fmt.Println(v)
}

这段代码接收完 1 后还会继续等,因为它不知道生产者是否已经结束。

9. 单向 channel 用来限制函数能力#

函数参数里经常写单向 channel:

func producer(out chan<- int) {
out <- 1
close(out)
}
func consumer(in <-chan int) {
for v := range in {
fmt.Println(v)
}
}

chan<- int 表示这个函数只能发送,不能接收。

<-chan int 表示这个函数只能接收,不能发送。

完整示例:

package main
import "fmt"
func producer(out chan<- int) {
for i := 1; i <= 3; i++ {
out <- i
}
close(out)
}
func consumer(in <-chan int) {
for v := range in {
fmt.Println(v)
}
}
func main() {
ch := make(chan int)
go producer(ch)
consumer(ch)
}

这样写的好处是:函数签名直接告诉读代码的人“这个函数是生产者还是消费者”。

10. select 同时等待多个 channel#

select 用来同时等待多个 channel 操作:

select {
case v := <-ch1:
fmt.Println("ch1:", v)
case v := <-ch2:
fmt.Println("ch2:", v)
}

规则是:

  • 哪个 case 的通信先准备好,就执行哪个。
  • 如果多个 case 同时准备好,会伪随机选择一个。
  • 如果都没准备好,并且没有 default,当前 goroutine 会阻塞。
  • 如果有 default,都没准备好时会立刻执行 default

带超时的例子:

package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go func() {
time.Sleep(200 * time.Millisecond)
ch <- "done"
}()
select {
case msg := <-ch:
fmt.Println(msg)
case <-time.After(100 * time.Millisecond):
fmt.Println("timeout")
}
}

这个程序会输出:

timeout

因为主 goroutine 只等 100 毫秒,但发送方 200 毫秒后才发送。

最小可运行代码示例#

下面这个例子把生产者、消费者、缓冲 channel、关闭、range 串起来:

package main
import "fmt"
func producer(out chan<- int) {
defer close(out)
for i := 1; i <= 5; i++ {
out <- i
}
}
func consumer(in <-chan int, done chan<- struct{}) {
defer close(done)
for v := range in {
fmt.Println("receive:", v)
}
}
func main() {
jobs := make(chan int, 2)
done := make(chan struct{})
go producer(jobs)
go consumer(jobs, done)
<-done
fmt.Println("all done")
}

这个例子里:

  • producer 只发送,所以参数是 chan<- int
  • consumer 只接收,所以参数是 <-chan int
  • producer 发送完所有数据后关闭 jobs
  • consumerrange 读完所有数据后退出循环。
  • done 用来通知主 goroutine:消费者已经处理完。

常见陷阱与错误示例#

1. 关闭后继续发送#

close(ch)
ch <- 1 // panic: send on closed channel

关闭表示“不会再有新值”。如果还要继续发送,就不要关闭。

2. 重复关闭#

close(ch)
close(ch) // panic: close of closed channel

如果有多个发送方,不要让多个 goroutine 都去 close(ch)。通常应该让一个统一的协调者关闭。

3. 接收方主动关闭#

func consumer(ch chan int) {
close(ch) // 通常不应该这样做
}

接收方不知道发送方是否还会继续发送。接收方贸然关闭,很容易导致发送方 panic。

4. 忘记关闭导致 range 永远等#

ch := make(chan int)
go func() {
ch <- 1
}()
for v := range ch {
fmt.Println(v)
}

这段代码收到 1 之后不会自动结束,因为 channel 没有关闭。

5. 把缓冲大小当成并发安全的万能药#

ch := make(chan int, 1000)

缓冲区变大只能让发送方晚一点阻塞,不会自动解决生产速度远大于消费速度的问题。缓冲区打满后,问题还是会暴露。

6. 在热循环里滥用 default#

for {
select {
case v := <-ch:
fmt.Println(v)
default:
}
}

这会变成忙等循环,可能把 CPU 打满。没有明确需求时,不要随手加 default

设计取舍#

channel 适合表达“数据流动”和“生命周期结束”:

  • 一个 goroutine 生产任务,另一个 goroutine 消费任务。
  • 多个 worker 从同一个任务队列取任务。
  • 一个关闭信号通知多个接收方退出。
  • 用缓冲 channel 控制并发数量。

但 channel 不是所有并发问题的答案。

如果只是保护一个共享 map、计数器或缓存状态,sync.Mutex 往往更直接。如果要等一批 goroutine 结束,sync.WaitGroup 更清晰。如果要取消一条调用链,context.Context 更合适。

可以简单记成:

  • 有数据流转、生产消费关系:优先想 channel。
  • 只是保护共享状态:优先想 Mutex
  • 只是等待任务结束:优先想 WaitGroup
  • 需要超时、取消、请求生命周期:优先想 context.Context

面试高频问题#

  1. 关闭 channel 后还能接收吗? 可以。缓冲区里已有的值会继续被接收;读空后再接收会得到零值和 ok == false

  2. 关闭 channel 后还能发送吗? 不能。向已关闭 channel 发送会 panic。

  3. 谁应该关闭 channel? 通常由发送方关闭,因为关闭表示“不再发送新值”。接收方一般不关闭。

  4. 无缓冲 channel 和有缓冲 channel 有什么区别? 无缓冲要求发送和接收同时准备好;有缓冲允许发送方在缓冲区未满时先把值放进去。

  5. range ch 什么时候结束? channel 被关闭,并且缓冲区里的值被读完之后结束。

  6. nil channel 会怎样? 对 nil channel 发送或接收会永久阻塞;关闭 nil channel 会 panic。

  7. select 多个 case 同时就绪时怎么选? 伪随机选择一个,不保证按源码顺序优先。

一句话总结#

channel 的核心不是“队列”,而是:用发送和接收建立 goroutine 之间的通信与同步,用 close 告诉接收方“数据已经结束”。

文章目录

文章目录