2. Go 并发通信:channel
2. Go 并发通信:channel
goroutine 启动之后,调用方怎么拿到它的结果?如果结果还没准备好,应该在哪里等?上一章解决了任务如何并发执行,这一章用 channel 把任务之间的通信补上。
channel 通常译为通道或管道。可以把它理解成一个有类型约束的交接点:发送方交出一个值,接收方取走这个值;条件不满足时,当前 goroutine 会等待。理解 channel,关键是分清什么时候能继续执行,以及谁负责让等待结束。
声明与创建
chan T 表示传递 T 类型值的 channel。声明变量和创建可用的 channel 是两件事:
var ch chan int // 零值为 nil
ch = make(chan int) // 无缓冲 channel
jobs := make(chan int, 3) // 最多暂存 3 个 int
make 的第二个参数是容量,省略或传入 0 都表示无缓冲。容量在创建时确定,不能直接扩容。
channel 变量可以赋值,也可以作为函数参数传递。复制变量并不会复制一条独立通道:
ch := make(chan int, 1)
other := ch
other <- 42
fmt.Println(<-ch) // 42
这里两个变量指向同一个 channel。将其中一个变量设为 nil,也不会改变另一个变量持有的 channel。
发送与接收
箭头表示值流动的方向:
ch <- 42 // 将 42 发送到 ch
value := <-ch // 从 ch 接收一个值
<-ch // 接收,但丢弃值
发送是一条语句,接收是可以参与赋值的表达式。它们都可能阻塞,所以不能只看代码有没有写对,还要看另一端是否有机会执行。
同一个 channel 可以被多个 goroutine 并发发送和接收,不需要另外给这些操作加锁。不过,一次发送的值只会交给一个接收者;多个接收者是在分担数据,不是每个人都收到一份。
channel 按 FIFO 传递数据。同一个 goroutine 依次发送 1、2、3,接收方就按这个顺序取出;多个 goroutine 同时发送时,不能靠启动顺序推断值的先后。多个接收者取出值以后,实际处理完成的顺序也可能不同。
无缓冲:交接时双方都要到场
下面的程序会卡在发送处,根本走不到下一行:
// 错误示例:没有其他 goroutine 接收。
func main() {
ch := make(chan int)
ch <- 42
fmt.Println(<-ch)
}
无缓冲 channel 没有存放值的位置。发送要等到接收方参与交接才能完成,但这个例子的接收方就是被阻塞的自己。如果整个程序没有其他可推进的任务,运行时会报告死锁。
让发送与接收在不同 goroutine 中执行,就能完成交接:
package main
import "fmt"
func main() {
result := make(chan int)
go func() {
result <- 6 * 7
}()
value := <-result
fmt.Println(value) // 42
}
如果主 goroutine 先执行到接收,就等待计算结果;如果子 goroutine 先执行到发送,就等待主 goroutine 来接。这里不需要 time.Sleep,通信本身已经表达了等待关系。
发送完成说明值已被接收,不代表接收方已经处理完这个值。 如果发送方还要等待数据库写入或文件保存结束,就需要额外的确认信号。
有缓冲:允许发送方暂时领先
有缓冲 channel 允许一部分值先放进去,接收方稍后再取:
ch := make(chan string, 2)
ch <- "first"
ch <- "second"
// ch <- "third" // 没有其他接收者时,会阻塞在这里。
fmt.Println(<-ch) // first
ch <- "third" // 腾出一个位置后,可以继续发送。
fmt.Println(<-ch) // second
fmt.Println(<-ch) // third
未关闭时,缓冲满了就等待接收方腾位置;没有值可接收时,就等待发送方提供值。有缓冲只是改变了双方等待的时机,并没有消除等待。
这能表达一种简单的背压:消费者处理慢,队列逐渐填满,生产者最终被阻塞。扩大容量可以吸收短时突发,但无法解决长期生产速度大于消费速度的问题,还会增加排队延迟和内存占用。容量应该对应允许积压多少任务这样的具体约束。
len 返回当前缓冲中的元素数量,cap 返回容量:
ch := make(chan int, 3)
ch <- 10
ch <- 20
fmt.Println(len(ch), cap(ch)) // 2 3
并发场景下,len(ch) 只是读取时的状态。检查完 len(ch) < cap(ch) 后,其他发送者仍可能抢先填满缓冲,所以不能靠这个判断保证下一次发送不阻塞。下一章会用 select 表达“现在能发就发”。
关闭:告诉接收方后面没有数据了
close(ch) 表示不会再往这条通道发送新值。它不会清空缓冲,也不会等待接收方处理完成。
ch := make(chan int, 2)
ch <- 0
ch <- 7
close(ch)
for i := 0; i < 4; i++ {
value, ok := <-ch
fmt.Println(value, ok)
}
输出固定为:
0 true
7 true
0 false
0 false
前两次取到的是实际发送过的值,所以即使 channel 已关闭,ok 仍然是 true。只有关闭且剩余数据已取完时,接收才立即返回元素类型的零值和 false。因此,ok 不能简单理解成“channel 是否还开着”。
只写 value := <-ch,就无法区分发送过来的 0 与关闭后返回的 0。需要识别结束时,应当读取 ok,或者使用 range。
谁来关闭
通常由发送方关闭,因为发送方知道何时不再产生数据。更准确的责任边界是:由能够确认所有发送已经结束的一方负责关闭。
只有一个生产者时,它可以在发送循环结束后关闭;多个生产者共用一个 channel 时,不能让任意一个生产者退出就关闭。常见做法是由协调者通过 sync.WaitGroup 等待所有生产者结束,再统一关闭。
下面几种操作会 panic:
// 相互独立的错误示例。
var missing chan int
close(missing) // 关闭 nil channel
ch := make(chan int)
close(ch)
close(ch) // 重复关闭
// ch <- 1 // 向已关闭的 channel 发送
接收方不要为了“让上游退出”而关闭数据 channel:上游下一次发送反而会 panic。需要提前停止时,应通过单独的取消信号协调。
channel 不像文件,不要求用完就关闭来释放资源。如果接收次数已经确定,也没有人需要结束通知,可以不关闭。无人引用且没有被阻塞 goroutine 等继续持有的 channel 可以被垃圾回收;真正需要警惕的是永远等不到对端的 goroutine。
用 range 接收完整的数据流
接收数量不确定时,可以让发送方用关闭表示结束:
package main
import "fmt"
func main() {
numbers := make(chan int, 2)
go func() {
defer close(numbers)
for n := 1; n <= 5; n++ {
numbers <- n
}
}()
for n := range numbers {
fmt.Println(n)
}
fmt.Println("接收结束")
}
这里依次输出 1 到 5,然后输出“接收结束”。range 每轮只有一个值,没有索引;它也不是只遍历进入循环时缓冲中已有的数据。
缓冲暂时为空时,循环会等待后续值。只有 channel 关闭且数据取完,循环才退出。把容量改成 0,这段程序依然成立;如果删掉 close(numbers),则输出五个数后还会继续等待。
这个例子也有一个前提:消费者会读完。如果消费者中途 break,生产者可能卡在后续发送,defer close(numbers) 也执行不到。要支持提前退出,就要让发送操作也能响应取消,下一章会补上这一点。
单向 channel:把权限写进函数签名
生产者通常只需要发送,消费者通常只需要接收。函数可以通过方向限制表达这种关系:
func produce(out chan<- int) {
defer close(out)
for n := 1; n <= 3; n++ {
out <- n
}
}
func consume(in <-chan int) {
for n := range in {
fmt.Println(n)
}
}
chan<- int 只能发送,也可以关闭;<-chan int 只能接收,不能发送或关闭。调用方可以把双向的 chan int 传给这两个函数,底层仍然是同一条通道。
生产者也可以直接返回只接收的 channel:
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
调用方用 for n := range generate(1, 2, 3) 取值即可。方向限制能防止误操作,但并不自动保证生命周期安全;这个函数仍然要求调用方读完所有数据。
nil、阻塞与关闭要分开看
这几种状态放在一起,更容易排查代码卡在哪里:
| channel 状态 | 发送 | 接收 | 关闭 |
|---|---|---|---|
nil | 永久阻塞 | 永久阻塞 | panic |
| 未关闭、无缓冲 | 等待接收方 | 等待发送方 | 成功 |
| 未关闭、有缓冲 | 满时等待,否则可发送 | 空时等待,否则可接收 | 成功 |
| 已关闭 | panic | 先取剩余值,再持续返回零值与 false | panic |
对 nil channel 的等待不会因为稍后给变量赋上新 channel 而转移过去。进入操作时等待的就是那个 nil 值。这和 select 每轮重新读取变量、动态禁用分支是两回事。
阻塞本身很正常:任务就是可能要等。问题在于还有没有人能解除等待。局部 goroutine 泄漏不一定触发整个程序的死锁报告,服务可能仍在运行,只是留下的 goroutine 越来越多。
完成通知与同步边界
不需要传递业务数据时,可以用 chan struct{} 表达事件:
finished := make(chan struct{})
var answer int
go func() {
answer = 42
close(finished)
}()
<-finished
fmt.Println(answer) // 42
这里的关闭除了让接收继续执行,也建立了同步关系。按照 Go 内存模型,关闭前的写入,对观察到这次关闭的接收方可见;所以主 goroutine 可以在接收后读取 answer。
关闭一个信号 channel 可以通知所有等待它的接收者,包括随后开始接收的 goroutine;向 channel 发送一个值则只会满足一次接收。通知所有人也不等于所有人已经运行完毕,需要等退出时还得另做同步。
同样,传递 slice、map 或指针时,channel 不会深拷贝它们指向的数据。发送后双方如果继续并发修改同一份数据,仍可能产生数据竞争。把“发送以后由接收方独占使用”作为约定,才是在代码中落实所有权交接。
需要传值、串联处理阶段时,channel 很自然;只是保护一个共享计数器或 map,用 mutex 通常更直接。选择之前,先说清谁发送、谁接收、谁宣布结束,以及任意一方提前退出时另一方怎么办。
延伸阅读
- Golang 中文学习文档:管道:本章的参考阅读,可对照其中的基础示例练习。
- Go 语言规范:Channel types、Close:查阅类型、通信与关闭的准确语义。
- 下一章:Go 多路等待:select。