背景与动机
Go 里的 channel 经常被翻译成“通道”。它的作用不是简单地“存数据”,而是在 goroutine 之间传递数据、表达同步关系、声明生产者什么时候结束。
学习 channel 时,最容易卡住的是这几个问题:
ch <- 1和<-ch到底谁会阻塞。- 无缓冲 channel 和有缓冲 channel 的区别是什么。
close(ch)以后为什么不能继续发送。for v := range ch为什么会自动结束。select为什么能同时等多个 channel。
这一篇只聚焦 channel 本身,不展开调度器、Mutex、WaitGroup 这些内容。相关规则可以对照 Go 语言规范中的 Channel types、close 内置函数 和 Go Memory Model 的 Channel communication。
核心原理拆解
1. channel 是带类型的通信通道
channel 的类型写法是:
chan intchan stringchan<- int<-chan int含义分别是:
chan int:可以发送,也可以接收int。chan<- int:只能发送int。<-chan int:只能接收int。
创建 channel 要用 make:
ch := make(chan int)如果只声明不创建,channel 的零值是 nil:
var ch chan intfmt.Println(ch == nil) // truenil 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)}这个程序不会死锁,因为缓冲区可以先存下 1 和 2。
但如果继续发送第三个值,就会阻塞:
ch := make(chan int, 2)
ch <- 1ch <- 2ch <- 3 // 阻塞:缓冲区已经满了有缓冲 channel 的规则是:
- 缓冲区没满,发送不会阻塞。
- 缓冲区满了,发送会阻塞。
- 缓冲区不空,接收不会阻塞。
- 缓冲区空了,接收会阻塞。

image
5. close 表示“不再发送新值”
close(ch) 的意思不是“销毁 channel”,而是告诉接收方:以后不会再有新值发送到这个 channel。
close(ch)关闭 channel 后,有三条非常重要的规则:
- 已经放进缓冲区的值仍然可以继续接收。
- 缓冲区读空后,再接收会拿到零值,
ok为false。 - 关闭后再发送会 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 true2 true0 false这里第三次接收拿到 0 false,说明 channel 已经关闭并且没有剩余数据了。
6. 为什么 close 后再发送会 panic
你问到的这段代码:
close(ch)ch <- 1 // panic: send on closed channel
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 intclose(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) }}输出:
taskchannel 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) }}输出:
123如果忘记关闭,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。consumer用range读完所有数据后退出循环。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。
面试高频问题
-
关闭 channel 后还能接收吗? 可以。缓冲区里已有的值会继续被接收;读空后再接收会得到零值和
ok == false。 -
关闭 channel 后还能发送吗? 不能。向已关闭 channel 发送会 panic。
-
谁应该关闭 channel? 通常由发送方关闭,因为关闭表示“不再发送新值”。接收方一般不关闭。
-
无缓冲 channel 和有缓冲 channel 有什么区别? 无缓冲要求发送和接收同时准备好;有缓冲允许发送方在缓冲区未满时先把值放进去。
-
range ch什么时候结束? channel 被关闭,并且缓冲区里的值被读完之后结束。 -
nilchannel 会怎样? 对nilchannel 发送或接收会永久阻塞;关闭nilchannel 会 panic。 -
select多个 case 同时就绪时怎么选? 伪随机选择一个,不保证按源码顺序优先。
一句话总结
channel 的核心不是“队列”,而是:用发送和接收建立 goroutine 之间的通信与同步,用 close 告诉接收方“数据已经结束”。