Appearance
Channel 基础:无缓冲与有缓冲通道
channel 是 Go 并发编程的灵魂。如果说 goroutine 是 Go 并发的执行单元,那么 channel 就是它们之间通信的管道。本篇我们将系统学习 channel 的创建、收发、关闭、遍历、方向限制,并深入分析无缓冲与有缓冲通道的区别,以及常见的死锁场景。掌握 channel 是写出地道 Go 并发代码的关键。
一、Channel 的本质:并发安全的队列
从实现角度看,channel 就是一个并发安全的 FIFO 队列。goroutine 可以向其中发送数据,也可以从中接收数据,这些操作都是原子且线程安全的,不需要额外的锁来保护。
可以这样理解 channel:
- 它是一个管道,一端发送,一端接收。
- 它是线程安全的,多个 goroutine 同时收发不会出错。
- 它是阻塞的(默认情况下),发送方在没有接收方时会阻塞,接收方在没有数据时也会阻塞——这种阻塞正是 goroutine 间同步的基础。
- 它是有类型的,一个
chan int只能传递 int,chan string只能传递 string。
channel 实现了「不要通过共享内存来通信,而要通过通信来共享内存」这一哲学——数据在 goroutine 之间通过 channel 传递,同一时刻只有一个 goroutine 持有数据,天然避免了数据竞争。
二、创建 channel
channel 是引用类型,必须用 make 创建。有两种创建方式:
1. 无缓冲通道
go
ch := make(chan int) // 无缓冲通道无缓冲通道没有容量,发送方必须等接收方就绪才能发送成功,反之亦然。它是一种同步通信机制。
2. 有缓冲通道
go
ch := make(chan int, 5) // 容量为 5 的有缓冲通道有缓冲通道有一个固定大小的缓冲区。缓冲区未满时发送不会阻塞,缓冲区未空时接收不会阻塞。它是一种异步通信机制。
3. channel 的零值
未初始化的 channel 是 nil:
go
package main
import "fmt"
func main() {
var ch chan int // 零值是 nil
fmt.Printf("ch = %v, 是否 nil: %v\n", ch, ch == nil)
// 对 nil channel 的操作会永久阻塞(后面会详细讲)
}输出:
ch = <nil>, 是否 nil: true4. channel 的类型
channel 可以传递任何类型的值,包括基本类型、结构体、指针、甚至另一个 channel:
go
package main
import "fmt"
func main() {
chInt := make(chan int)
chStr := make(chan string)
chChan := make(chan chan int) // 传递 channel 的 channel
fmt.Printf("chan int 的类型: %T\n", chInt)
fmt.Printf("chan string 的类型: %T\n", chStr)
fmt.Printf("chan chan int 的类型: %T\n", chChan)
}三、发送与接收
channel 的基本操作就两个:发送和接收。
1. 基本语法
go
ch <- value // 发送 value 到 channel
v := <-ch // 从 channel 接收,赋值给 v
<-ch // 从 channel 接收,丢弃结果
v, ok := <-ch // 接收,ok 表示是否收到有效值(用于检测关闭)箭头 <- 的方向就是数据流动的方向,形象地表示「数据流入/流出 channel」。
2. 简单示例
go
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go func() {
time.Sleep(500 * time.Millisecond)
ch <- "hello from goroutine" // 发送
}()
msg := <-ch // 接收,会阻塞直到有数据
fmt.Println("收到:", msg)
}主 goroutine 在 <-ch 处阻塞,直到子 goroutine 发送数据后才继续执行。
3. 收发的阻塞行为
理解什么时候会阻塞是关键:
| 操作 | 无缓冲 channel | 有缓冲 channel(未满/未空时) |
|---|---|---|
发送 ch<-v | 阻塞,直到有接收方 | 缓冲区未满时不阻塞,满时阻塞 |
接收 <-ch | 阻塞,直到有发送方 | 缓冲区未空时不阻塞,空时阻塞 |
四、无缓冲通道:同步通信
无缓冲通道(make(chan T))是同步的:发送和接收必须同时就绪,就像一次「握手」。
1. 握手模型
想象两个人交接一份文件:发送方拿着文件等接收方,接收方来了之后,两人「交接」这一刻同时完成——发送方知道文件被接收了,接收方拿到了文件。在交接完成前,双方都在等待。
go
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go func() {
fmt.Println("发送方:准备发送数据...")
ch <- "数据" // 阻塞,直到主 goroutine 接收
fmt.Println("发送方:数据已被接收")
}()
time.Sleep(time.Second) // 故意延迟,让发送方先阻塞
fmt.Println("接收方:准备接收数据...")
msg := <-ch // 接收,发送方此时被唤醒
fmt.Println("接收方:收到", msg)
time.Sleep(time.Second) // 确保发送方的打印执行
}输出:
发送方:准备发送数据...
接收方:准备接收数据...
发送方:数据已被接收
接收方:收到 数据可以看到,「数据已被接收」是在主 goroutine 接收后才打印的——发送方的 ch <- 一直阻塞到接收发生。
2. 无缓冲通道用于同步
无缓冲通道常用于 goroutine 间的同步点,确保某个事件发生后另一个才继续:
go
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
done := make(chan struct{}) // 用空结构体做信号
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("worker:工作中...")
// 工作完成后,通知主 goroutine
close(done) // 用 close 发送信号(接收方能感知到关闭)
}()
<-done // 阻塞,直到 done 被关闭
fmt.Println("主 goroutine:收到完成信号")
wg.Wait()
}chan struct{} 是一种常见的「信号」channel,struct{} 不占内存,纯粹用于同步。
五、有缓冲通道:异步通信
有缓冲通道(make(chan T, n))有一个容量为 n 的缓冲区。只要缓冲区没满,发送就不会阻塞;只要缓冲区没空,接收就不会阻塞。它解耦了发送方和接收方的时序。
1. 基本示例
go
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int, 3) // 容量为 3
// 发送 3 个数据,不会阻塞(缓冲区未满)
ch <- 1
ch <- 2
ch <- 3
fmt.Println("已发送 3 个数据到缓冲通道")
// 第 4 个会阻塞,因为缓冲区满了
// ch <- 4 // 这里会死锁(没有接收方)
// 接收
fmt.Println("接收:", <-ch)
fmt.Println("接收:", <-ch)
fmt.Println("接收:", <-ch)
time.Sleep(time.Second)
}2. 生产者-消费者模式
有缓冲通道非常适合解耦生产者和消费者:
go
package main
import (
"fmt"
"sync"
"time"
)
func producer(ch chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for i := 1; i <= 5; i++ {
ch <- i
fmt.Printf("生产者:发送 %d\n", i)
time.Sleep(100 * time.Millisecond)
}
close(ch) // 生产完毕,关闭通道
}
func consumer(ch <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for v := range ch { // 遍历直到通道关闭
fmt.Printf(" 消费者:接收 %d\n", v)
time.Sleep(200 * time.Millisecond)
}
}
func main() {
ch := make(chan int, 3) // 缓冲区容量 3
var wg sync.WaitGroup
wg.Add(1)
go producer(ch, &wg)
wg.Add(1)
go consumer(ch, &wg)
wg.Wait()
fmt.Println("全部完成")
}缓冲区起到了「削峰填谷」的作用:生产者快时,数据先存在缓冲区;消费者忙时,从缓冲区取。
3. len 和 cap
可以用 len(ch) 查看缓冲区中当前元素数量,cap(ch) 查看缓冲区容量:
go
package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
fmt.Printf("len=%d, cap=%d\n", len(ch), cap(ch)) // len=2, cap=3
<-ch
fmt.Printf("len=%d, cap=%d\n", len(ch), cap(ch)) // len=1, cap=3
}注意:len 反映的是「当前」快照,在并发环境下它的值可能已经变化,不要用它做同步判断。
六、缓冲区大小选择策略
缓冲区大小选多少,没有万能答案,但有一些原则:
1. 容量为 0(无缓冲)
- 适用场景:需要严格同步,发送方必须确认接收方收到。
- 特点:发送和接收强耦合。
2. 容量为 1
- 适用场景:简单的信号传递、结果返回。
- 特点:相当于「一个槽位的信箱」。
3. 容量等于任务数
- 适用场景:批量任务,想一次性投递完。
- 特点:需要预知任务数,且内存能容纳。
4. 经验法则
- 大多数情况用无缓冲或小缓冲(1~几个)。缓冲越大,越容易掩盖设计问题(比如生产者比消费者快太多,缓冲区只是延缓了阻塞)。
- 不要用大缓冲试图解决性能问题。如果生产消费速度长期不匹配,应该增加消费者或限流生产者,而不是堆缓冲。
- 缓冲区不能无限大。无界缓冲会导致内存暴涨。Go 的 channel 容量是固定的,这是好事。
一个常见的反模式:
go
package main
import (
"fmt"
"time"
)
func main() {
// ❌ 反模式:以为大缓冲能解决速度不匹配
ch := make(chan int, 10000)
go func() {
for i := 0; ; i++ {
ch <- i // 迟早会阻塞,只是推迟了问题
}
}()
go func() {
for v := range ch {
time.Sleep(time.Second) // 消费太慢
fmt.Println(v)
}
}()
time.Sleep(3 * time.Second)
}七、channel 的关闭
用 close(ch) 关闭一个 channel。关闭后:
- 不能再向其发送数据(会 panic)。
- 可以继续接收数据:缓冲区中剩余的数据可以正常接收,缓冲区空后再接收会立即返回零值。
close应该由发送方调用,不应该由接收方调用。
1. 为什么要关闭
关闭 channel 主要有两个目的:
- 通知接收方「没有更多数据了」,这样
for range遍历可以正常结束。 - 允许多个接收方都感知到结束信号(关闭的 channel 对所有接收方都可见)。
2. close 示例
go
package main
import "fmt"
func main() {
ch := make(chan int, 5)
// 发送方:发送完数据后关闭
go func() {
for i := 1; i <= 5; i++ {
ch <- i
}
close(ch) // 关闭,通知接收方没有更多数据
}()
// 接收方:用 for range 遍历,通道关闭后自动退出
for v := range ch {
fmt.Println("收到:", v)
}
fmt.Println("通道已关闭,遍历结束")
}3. 关闭的注意事项
- 不能关闭已关闭的 channel,会 panic。
- 不能向已关闭的 channel 发送数据,会 panic。
- 关闭 nil channel 会 panic。
- 可以从已关闭的 channel 接收,缓冲区有数据就返回数据,没有就返回零值。
go
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch)
// 缓冲区有数据,正常接收
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
// 缓冲区空了,返回零值(不会阻塞)
v := <-ch
fmt.Println("关闭后接收:", v) // 0(int 的零值)
// 重复关闭会 panic
// close(ch) // panic: close of closed channel
}八、从 channel 读取时检测关闭
用「逗号 ok」模式可以区分「接收到的零值是真实数据还是通道已关闭」:
go
value, ok := <-chok为true:收到了有效数据。ok为false:通道已关闭且缓冲区为空,value是零值。
go
package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch)
for {
v, ok := <-ch
if !ok {
fmt.Println("通道已关闭,退出")
break
}
fmt.Println("收到:", v)
}
}这种模式在需要精细控制接收逻辑时很有用,而 for range 已经内置了这个检测,更简洁。
九、遍历 channel:for range
for v := range ch 是遍历 channel 的惯用方式,它会持续接收直到 channel 关闭:
go
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go func() {
messages := []string{"你好", "世界", "Go", "并发"}
for _, m := range messages {
ch <- m
time.Sleep(200 * time.Millisecond)
}
close(ch) // 必须 close,否则 for range 会一直阻塞
}()
// for range 会在 ch 关闭后自动退出
for msg := range ch {
fmt.Println("收到:", msg)
}
fmt.Println("遍历结束")
}关键点:如果 channel 永远不关闭,for range 会永远阻塞。所以发送方在发送完毕后关闭 channel 是良好的实践。
十、channel 的方向限制
默认 channel 是双向的,但我们可以用类型系统限制为「只发送」或「只接收」,这在函数参数中很有用,能从类型层面防止误用。
chan<- T:只发送(send-only),只能往里写,不能读。<-chan T:只接收(receive-only),只能从里读,不能写。
1. 用方向限制表达意图
go
package main
import "fmt"
// producer 只能发送
func producer(out chan<- int) {
for i := 1; i <= 3; i++ {
out <- i
}
close(out)
}
// consumer 只能接收
func consumer(in <-chan int) {
for v := range in {
fmt.Println("消费:", v)
}
}
func main() {
ch := make(chan int) // 双向 channel
// 双向 channel 可以隐式转换为单向
go producer(ch) // chan int -> chan<- int
consumer(ch) // chan int -> <-chan int
}2. 方向限制的好处
- 编译期检查:在 consumer 里写
ch <- 1会编译报错,防止逻辑错误。 - 自文档化:函数签名直接说明了它是生产者还是消费者。
- 常见于接口设计:返回一个
<-chan T让调用方只能读不能关。
go
package main
import (
"fmt"
"time"
)
// 返回只读 channel,调用方不能关闭或发送
func counter() <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
for i := 1; i <= 5; i++ {
ch <- i
time.Sleep(100 * time.Millisecond)
}
}()
return ch
}
func main() {
c := counter()
for v := range c {
fmt.Println(v)
}
// close(c) // 编译错误:c 是只读的,不能 close
}3. 单向转双向不可
注意:双向可以隐式转单向,但单向不能转回双向:
go
package main
func main() {
ch := make(chan int)
var sendOnly chan<- int = ch // ✅ 双向转单向
var recvOnly <-chan int = ch // ✅ 双向转单向
_ = sendOnly
_ = recvOnly
// var bi chan int = sendOnly // ❌ 编译错误:单向不能转双向
}十一、常见死锁场景分析
死锁是 channel 编程中最容易遇到的问题。Go 运行时能检测到「所有 goroutine 都阻塞」的情况,会直接 panic 并打印堆栈。
1. 无缓冲通道无接收方
go
package main
func main() {
ch := make(chan int)
ch <- 1 // 死锁:没有接收方,主 goroutine 永久阻塞
}运行会 panic:fatal error: all goroutines are asleep - deadlock!
2. 无缓冲通道无发送方
go
package main
func main() {
ch := make(chan int)
_ = <-ch // 死锁:没有发送方
}3. 向已满的有缓冲通道发送
go
package main
func main() {
ch := make(chan int, 1)
ch <- 1
ch <- 2 // 死锁:缓冲区满,且无接收方
}4. 在同一个 goroutine 里收发(无缓冲)
go
package main
func main() {
ch := make(chan int)
ch <- 1 // 阻塞,等待接收方
v := <-ch // 永远执行不到
_ = v
}发送阻塞了,根本到不了接收那一行。
5. 正确的写法:用 goroutine 解耦
go
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
ch <- 1 // 在另一个 goroutine 发送
}()
v := <-ch // 主 goroutine 接收
fmt.Println("收到:", v)
}只要有一个 goroutine 能推进,就不会死锁。
6. 用 select 避免死锁
后面章节会讲 select,这里先看一眼它如何避免死锁:
go
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int)
// 用 select + default 做非阻塞发送
select {
case ch <- 1:
fmt.Println("发送成功")
default:
fmt.Println("无接收方,跳过") // 不会死锁
}
// 用 select + time.After 做超时
go func() {
time.Sleep(2 * time.Second)
ch <- 42
}()
select {
case v := <-ch:
fmt.Println("收到:", v)
case <-time.After(time.Second):
fmt.Println("超时") // 1 秒后超时,避免永久阻塞
}
}十二、小结
本篇我们系统学习了 channel 的基础,核心要点:
本质:channel 是并发安全的 FIFO 队列,实现了「通过通信来共享内存」的思想。
创建:
make(chan T)无缓冲,make(chan T, n)有缓冲。未初始化的 channel 是 nil。收发:
ch <- v发送,<-ch接收。无缓冲通道收发同步(握手),有缓冲通道缓冲区未满/未空时异步。无缓冲通道:强同步,发送方阻塞直到接收方就绪,反之亦然。常用于同步点、信号传递。
chan struct{}是常用的信号类型。有缓冲通道:解耦生产消费时序,缓冲区起削峰填谷作用。
len看当前元素数,cap看容量,但不要用于同步判断。缓冲区大小:默认用无缓冲或小缓冲,不要用大缓冲掩盖设计问题,缓冲不能无界。
关闭:
close(ch)由发送方调用,通知接收方「没有更多数据」。不能重复关闭、不能向已关闭 channel 发送、关闭 nil channel 会 panic。检测关闭:
v, ok := <-ch,ok为 false 表示通道已关闭且缓冲区空。for range内置了关闭检测。方向限制:
chan<- T只发送,<-chan T只接收。双向可隐式转单向,反向不行。用于函数参数表达意图、编译期防错。死锁:常见于无缓冲通道无对应收发方、向满缓冲发送、同 goroutine 收发。运行时检测到「全部 goroutine 阻塞」会 panic。用 goroutine 解耦或 select 避免死锁。
下一篇我们将学习 select 语句,它是处理多个 channel 的利器,能实现超时、非阻塞读写、多路复用等高级模式。