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先取剩余值,再持续返回零值与 falsepanic

对 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 通常更直接。选择之前,先说清谁发送、谁接收、谁宣布结束,以及任意一方提前退出时另一方怎么办。

延伸阅读