Go 语言通道(Channel)笔记
1. 概述
通道(channel)是 Go 语言中用于 goroutine 之间通信 的核心机制,是 CSP(Communicating Sequential Processes)模型在 Go 中的具体实现。Go 并发哲学强调:
"不要通过共享内存来通信,而应通过通信来共享内存。"
通道允许一个 goroutine 将值发送到另一个 goroutine,实现了内存的 无锁同步访问。通道本身是并发安全的,内置了同步语义,使得并发编程更加简洁和健壮。
2. 基本概念
2.1 类型表示
通道是一种类型化的引用类型,声明方式为 chan T,其中 T 是通道中元素的类型。
var ch chan int // ch 是一个 nil 的 int 通道
2.2 零值(nil)
-
通道的零值是
nil。 -
对
nil通道的发送和接收操作会 永久阻塞。 -
对
nil通道调用close会引发 panic。
2.3 引用类型
通道是引用类型,传递通道时传递的是底层数据结构的引用,副本指向同一个通道对象。
3. 创建和初始化
通道必须使用内置函数 make 创建,可以指定是否带缓冲区。
// 无缓冲通道(同步通道)
ch := make(chan int)
// 带缓冲通道,容量为 10
ch := make(chan int, 10)
-
无缓冲通道:发送和接收操作必须同时准备好,否则会阻塞。确保严格的同步。
-
带缓冲通道:发送只有在缓冲区满时阻塞,接收只有在缓冲区空时阻塞。实现异步通信。
4. 发送和接收操作
4.1 语法
-
发送:
ch <- v(将值 v 发送到通道 ch) -
接收:
v := <-ch(从通道 ch 接收一个值,赋给 v) -
丢弃接收:
<-ch(仅接收但忽略值)
4.2 阻塞特性
-
发送操作:
-
对无缓冲通道:阻塞直到有接收者准备好。
-
对缓冲通道:如果缓冲区未满,立即成功;否则阻塞直到有空间。
-
-
接收操作:
-
对无缓冲通道:阻塞直到有发送者准备好。
-
对缓冲通道:如果缓冲区非空,立即成功;否则阻塞直到有数据。
-
4.3 完成条件
发送/接收操作 成功返回 前,goroutine 会被挂起,直到条件满足。这种阻塞机制实现了 goroutine 间的自动同步。
5. 关闭通道
5.1 使用 close 函数
close(ch)
-
关闭通道表示 不再发送数据。
-
关闭后,接收操作可以继续接收已发送的值,直到通道为空,之后接收操作会立即返回元素类型的零值,且第二个返回值(可选)为
false表示通道已关闭且无数据。 -
向已关闭的通道发送数据会引发 panic。
-
关闭已关闭的通道也会引发 panic。
5.2 接收检测关闭
v, ok := <-ch
// ok == true 表示成功接收到值(通道未关闭)
// ok == false 表示通道已关闭且无更多数据
5.3 使用 for range 遍历通道
for v := range ch {
// 循环直到通道关闭且无数据
}
range会自动检测通道关闭,并在通道为空且关闭时退出循环。
6. 通道的类型:单向通道
在某些函数签名中,可以指定通道的方向,以约束发送或接收操作,提高类型安全性。
-
只发送通道 :
chan<- T(只能发送,不能接收) -
只接收通道 :
<-chan T(只能接收,不能发送)func send(ch chan<- int, data int) {
ch <- data // 合法
// <-ch // 编译错误:不能从只发送通道接收
}func receive(ch <-chan int) int {
return <-ch // 合法
// ch <- 1 // 编译错误:不能向只接收通道发送
}
双向通道可以隐式转换为单向通道,反之则不行。
ch := make(chan int)
send(ch, 10) // 双向转单向是允许的
7. 缓冲通道 vs 非缓冲通道
| 特性 | 无缓冲通道 | 缓冲通道 |
|---|---|---|
| 缓冲区大小 | 0 | >0 |
| 发送行为 | 必须有接收者同时就绪,否则阻塞 | 缓冲区有空闲则立即成功,否则阻塞 |
| 接收行为 | 必须有发送者同时就绪,否则阻塞 | 缓冲区非空则立即成功,否则阻塞 |
| 同步性 | 强同步,发送和接收发生在同一时刻 | 异步,发送和接收解耦(在容量范围内) |
| 典型用途 | 信号传递、严格同步、等待事件 | 工作队列、限流、解耦生产者和消费者 |
| 性能 | 每次操作都需要 goroutine 切换 | 可减少阻塞,但需注意容量设计 |
8. 底层数据结构(基于 Go 1.19)
通道的运行时表示在 runtime/chan.go 中,核心结构为 hchan。
type hchan struct {
qcount uint // 环形缓冲区中的元素个数
dataqsiz uint // 环形缓冲区的大小(即 make 时指定的容量)
buf unsafe.Pointer // 指向环形缓冲区的指针
elemsize uint16 // 每个元素的大小
closed uint32 // 关闭标志(0 表示未关闭)
elemtype *_type // 元素类型
sendx uint // 发送索引(环形缓冲区写指针)
recvx uint // 接收索引(环形缓冲区读指针)
recvq waitq // 等待接收的 goroutine 队列(链表)
sendq waitq // 等待发送的 goroutine 队列(链表)
lock mutex // 保护所有字段的互斥锁
}
-
环形缓冲区 :
buf指向一个循环队列,用于存储缓冲数据。当dataqsiz > 0时存在。 -
等待队列 :
sendq和recvq分别存放因缓冲区满/空而阻塞的 goroutine。每个等待项包含 goroutine 指针和待发送/接收的元素内存地址。 -
锁 :所有操作(发送、接收、关闭)都需要获取
lock,确保并发安全。 -
无缓冲通道 :
dataqsiz = 0,没有buf,发送和接收直接通过对方 goroutine 的栈传递数据,避免了内存拷贝(实际上还是拷贝了值,但直接从发送者栈拷贝到接收者栈)。
发送/接收流程简析:
-
发送:
-
加锁。
-
检查是否有等待的接收者(
recvq非空):- 如果有,直接将值传递给接收者,并唤醒接收者 goroutine(绕过缓冲区)。
-
否则,检查缓冲区是否有空位:
-
如果有,将值放入缓冲区,更新索引,解锁。
-
如果没有,将当前 goroutine 包装成 sudog 加入
sendq,挂起并解锁。
-
-
-
接收:
-
加锁。
-
检查是否有等待的发送者(
sendq非空)且缓冲区有数据(或无缓冲):- 如果有,从发送者直接接收(或从缓冲区取一个值,并唤醒发送者)。
-
否则,检查缓冲区是否有数据:
-
如果有,从缓冲区取数据,解锁。
-
如果没有,将当前 goroutine 加入
recvq,挂起并解锁。
-
-
-
关闭:
-
加锁,设置
closed = 1。 -
将
recvq和sendq中的所有 goroutine 唤醒(发送队列的 goroutine 会收到 panic)。
-
9. 常见使用模式
9.1 使用 for range 遍历通道
ch := make(chan int)
go func() {
for i := 0; i < 10; i++ {
ch <- i
}
close(ch)
}()
for v := range ch {
fmt.Println(v) // 打印 0~9,通道关闭后自动退出循环
}
9.2 使用 select 多路复用
select 语句让一个 goroutine 可以同时等待多个通道操作。
select {
case v := <-ch1:
fmt.Println("收到 ch1:", v)
case v := <-ch2:
fmt.Println("收到 ch2:", v)
case ch3 <- 42:
fmt.Println("向 ch3 发送 42")
default:
fmt.Println("无任何通道就绪")
}
-
select随机选择一个可用的 case 执行;如果多个同时就绪,随机选择。 -
如果所有 case 都阻塞,且没有
default,则select阻塞直到某个 case 就绪。 -
空
select{}会永久阻塞(通常用于防止 main 退出)。 -
default子句使select变为非阻塞。
9.3 超时控制
结合 time.After 实现超时:
select {
case v := <-ch:
fmt.Println(v)
case <-time.After(5 * time.Second):
fmt.Println("超时")
}
9.4 工作池(Worker Pool)
使用带缓冲通道作为任务队列:
func worker(id int, jobs <-chan int, results chan<- int) {
for job := range jobs {
results <- job * 2 // 模拟处理
}
}
func main() {
const numJobs = 100
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)
// 启动 3 个 worker
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// 发送任务
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs)
// 收集结果
for a := 1; a <= numJobs; a++ {
<-results
}
}
9.5 扇出/扇入(Fan-out/Fan-in)
-
扇出:从一个通道分发到多个 goroutine 处理。
-
扇入:将多个输入通道合并到一个输出通道。
// 扇入示例
func fanIn(ch1, ch2 <-chan string) <-chan string {
out := make(chan string)
go func() {
for {
select {
case v := <-ch1:
out <- v
case v := <-ch2:
out <- v
}
}
}()
return out
}
9.6 信号传递(通知)
使用无缓冲通道实现 goroutine 的协调。
done := make(chan struct{})
go func() {
// 执行任务
close(done) // 任务完成,关闭通道(广播通知)
}()
<-done // 等待任务完成
利用通道的阻塞特性实现简单的等待。
9.7 传递通道的通道
通道的元素类型可以是另一个通道,用于动态分发任务或传递控制信号。
type request struct {
data int
resp chan int
}
func handler(reqs <-chan request) {
for req := range reqs {
req.resp <- req.data * 2 // 处理并返回结果
}
}
func main() {
requests := make(chan request)
go handler(requests)
respCh := make(chan int)
requests <- request{10, respCh}
result := <-respCh
fmt.Println(result) // 20
}
10. 注意事项和常见陷阱
10.1 死锁
-
无接收者的发送:向无缓冲通道发送数据,且没有其他 goroutine 接收,当前 goroutine 永久阻塞,造成死锁(如果是在 main goroutine,则程序崩溃)。
-
无发送者的接收:从无缓冲通道接收,且没有发送者,同样死锁。
-
互相等待:goroutine A 等待通道 a,goroutine B 等待通道 b,且互相持有对方需要的资源,造成循环等待。
10.2 goroutine 泄漏
如果 goroutine 在通道上阻塞,且没有其他 goroutine 会解除阻塞(如发送者永远不发送,或接收者已退出),该 goroutine 会永久挂起,造成内存泄漏。
10.3 nil 通道操作
-
向
nil通道发送:永久阻塞。 -
从
nil通道接收:永久阻塞。 -
关闭
nil通道:panic。
这在 select 中可用于临时禁用某个 case(将通道置为 nil)。
10.4 关闭通道的注意事项
-
关闭已关闭的通道:panic。
-
向已关闭的通道发送:panic。
-
从已关闭的通道接收:总是立即返回零值,第二个返回值表示是否成功接收(若通道已关闭且无数据,则 ok = false)。
-
应由 发送者 关闭通道,而不是接收者(防止向已关闭通道发送数据)。
10.5 内存泄漏(未关闭的通道)
如果 goroutine 阻塞在从通道接收,而该通道永远不会被关闭且永远不会再有数据发送,该 goroutine 会一直阻塞。但注意,如果没有任何 goroutine 引用该通道,通道会被 GC 回收,但阻塞的 goroutine 不会被回收,导致泄漏。
10.6 并发安全
通道本身是并发安全的,但 不要 在多个 goroutine 中同时对一个通道进行发送/接收/关闭操作,除非确保互斥(如通过另一个通道或锁)。实际上,通道的设计允许多个发送者/接收者同时操作,底层通过锁保护。所以同时发送/接收是安全的,但关闭操作需要与发送操作同步,否则可能在发送过程中关闭通道导致 panic。
10.7 传递指针时的共享问题
通过通道传递指针时,多个 goroutine 可能同时访问同一内存,需要额外的同步措施(如互斥锁)来避免数据竞争。
10.8 对已关闭通道的 range
使用 for range 遍历通道时,必须确保通道会被关闭,否则 range 永远不结束,造成死锁。
10.9 select 和 default 的误用
default 分支会使 select 变为非阻塞,但如果逻辑中需要阻塞等待,就不应加 default。
11. select 语句深入
11.1 执行规则
-
所有 case 表达式(通道操作)被求值,顺序从上到下,从左到右。
-
如果多个 case 可以执行(即通道操作不阻塞),则随机选择一个执行。
-
如果没有 case 可以执行:
-
如果有
default,执行 default。 -
否则,阻塞直到某个 case 可以执行。
-
11.2 与 nil 通道结合
将通道设置为 nil 可以永久阻塞该 case,常用于动态控制 select 的行为。
var ch chan int
select {
case v := <-ch: // 由于 ch 为 nil,此 case 永远阻塞
default:
fmt.Println("不会走到这里,因为 default 先执行?") // 注意:如果所有 case 阻塞,且有 default,则执行 default
}
实际中,可通过置 nil 来"禁用"某个 case。
11.3 空 select
select {} 会永久阻塞,通常用于让 main goroutine 等待其他 goroutine,但此时其他 goroutine 必须能持续运行,否则整个程序死锁。
11.4 超时与 ticker
结合 time.After 实现超时,注意 time.After 会生成一个通道,但每次调用都会分配内存,大量使用时推荐使用 time.NewTimer 并手动停止。
12. 性能与优化
12.1 通道开销
-
无缓冲通道:每次发送/接收都需要 goroutine 切换(如果对方未就绪),开销较大。
-
缓冲通道:当缓冲区未满/非空时,操作仅涉及内存拷贝和索引更新,开销相对小。
12.2 缓冲区大小设计
-
太小:容易导致发送/接收阻塞,增加上下文切换。
-
太大:浪费内存,且可能掩盖设计问题(如生产者和消费者速率不匹配)。
-
通常可根据生产者和消费者的平均速率、容忍的延迟来估算。
12.3 零拷贝优化
当无缓冲通道的发送和接收双方都准备好时,数据直接从发送者栈拷贝到接收者栈,无需中间缓冲区。但对于大结构体,拷贝开销仍需考虑。
12.4 避免频繁创建通道
通道的创建开销较小,但大量创建会增加 GC 压力。尽量复用通道,或使用对象池(sync.Pool)管理。
12.5 使用带缓冲通道实现限流
通过带缓冲通道的容量限制并发数,达到限流目的。
var tokens = make(chan struct{}, 10) // 最多允许 10 个并发
func work() {
tokens <- struct{}{} // 获取令牌
defer func() { <-tokens }()
// do work
}
13. 与其他并发原语的对比
| 原语 | 适用场景 | 特点 |
|---|---|---|
| channel | goroutine 间通信,传递数据,信号同步 | 类型安全,内置同步,适合数据流和事件通知 |
| sync.Mutex | 保护共享内存,临界区访问 | 轻量级,低开销,适合简单的互斥 |
| sync.RWMutex | 读多写少的共享资源保护 | 允许多个读,单个写 |
| sync.WaitGroup | 等待一组 goroutine 完成 | 简单计数器,常用于等待任务结束 |
| sync.Once | 确保某个函数只执行一次 | 初始化单例等 |
| sync.Cond | 复杂的条件等待(多个条件变量) | 与 Mutex 配合使用,用于 goroutine 等待特定条件 |
| atomic | 简单的整数/指针原子操作,无锁编程 | 高性能,但仅支持基本类型,无法保护复杂结构 |
何时使用 channel?
-
需要传递数据所有权。
-
需要生产者-消费者模型。
-
需要超时、取消、广播通知。
-
需要将并发逻辑组合成流水线。
何时使用 Mutex?
-
保护共享结构体内部状态。
-
简单的计数器或标志位(虽然 atomic 更合适)。
-
性能敏感且无法用 channel 无阻塞实现。
14. 高级话题
14.1 使用通道实现取消(context 原理)
context 包底层使用通道来传递取消信号。一个简单的取消实现:
func worker(stop <-chan struct{}) {
for {
select {
case <-stop:
return
default:
// 工作
}
}
}
stop := make(chan struct{})
go worker(stop)
// 当需要停止时
close(stop)
14.2 使用通道实现流水线(pipeline)
将问题分解为多个阶段,每个阶段通过通道连接,实现并发流水线。
// 生成数字
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
// 平方
func sq(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
func main() {
for v := range sq(sq(gen(2, 3))) { // 2->4, 3->9 再平方?实际上 sq(sq(gen)) 是对每个数两次平方
fmt.Println(v)
}
}
14.3 使用通道实现并发循环
通过固定数量的 worker 处理大量任务,如工作池。
14.4 使用通道实现速率限制
利用 time.Ticker 或带缓冲通道控制请求速率。
limiter := time.Tick(200 * time.Millisecond) // 每秒 5 个
for req := range requests {
<-limiter
go process(req)
}
14.5 通道和 GC
当通道不再被任何 goroutine 引用时,它会被垃圾回收。但阻塞在通道上的 goroutine 会保持对通道的引用,导致通道无法被回收,直到这些 goroutine 被唤醒或退出。
15. 总结
通道是 Go 语言并发编程的基石,理解其工作原理和使用模式对于编写正确、高效的并发程序至关重要。要点回顾:
-
通道是类型化、引用类型、并发安全的。
-
使用
make创建,可指定缓冲区容量。 -
发送和接收操作是阻塞的,实现了 goroutine 间同步。
-
使用
close关闭通道,向关闭通道发送会 panic,接收可检测关闭状态。 -
select多路复用是处理多个通道的核心。 -
常见模式包括工作池、扇出/扇入、超时控制、通知退出等。
-
注意死锁、goroutine 泄漏、nil 通道、关闭不当等问题。
-
根据场景选择合适的并发原语(channel 或 sync 包)。
掌握通道的设计哲学和使用技巧,能让你在 Go 中构建出简洁、健壮的并发系统。