Menu

Golang Channel:带缓冲、无缓冲、关闭与 range

Go 通道如何在 goroutine 之间传递值:无缓冲和带缓冲的通道、关闭与 range 遍历、方向类型、死锁错误,以及用通道搭建的流水线。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

发送和接收

通道是 goroutine 之间带类型的管道。ch <- v 表示发送,<-ch 表示接收。用 make 创建通道:

通道类型的零值是 nil,所以只写 var ch chan string 而不用 make,得到的通道会永远阻塞。请始终用 make 创建通道。

无缓冲通道用于同步

make(chan T) 创建一个无缓冲通道。发送会阻塞,直到接收方取走值;接收也会阻塞,直到发送方提供一个值。两个 goroutine 在这一点会合,因此无缓冲通道既是数据管道,也是同步工具。下面的 <-done 返回时,你就知道 worker 在发送之前的所有工作都已完成:

这里在 main 中读取 result 不需要互斥锁也是安全的。Go 的内存模型保证:worker 在发送之前做的所有事情,在对应的接收完成后对 main 都可见。chan struct{} 是纯信号的惯用类型,因为 struct{} 不占内存。

带缓冲通道

make(chan T, n) 给通道留出存放 n 个值的空间。缓冲区满之前,没有接收方发送也能成功;缓冲区空之前,接收都能成功。值按照放入的顺序取出。

缓冲区让发送方和接收方解耦,短时间的突发流量不会让发送方停下来。但它解决不了生产者一直比消费者快的问题,只是推迟了发送方阻塞的时刻。选择缓冲区大小要有依据(发送方的数量、已知的批量大小),而不是为了让死锁消失。

len(ch) 只是一个快照。等你根据它采取行动时,另一个 goroutine 可能已经改变了它,所以不要用它来判断发送是否会阻塞。这种情况请使用带 default 分支的 select。

关闭与 range

close(ch) 告诉接收方不会再有值发送过来。关闭之后:

  • 缓冲区中已有的值仍会被送达,
  • 之后每次接收都会立即返回零值,
  • v, ok := <-ch 会得到 ok == false,
  • for v := range ch 会结束。

close 之后的第一次接收仍然拿到 "last",且 ok == true。第二次拿到零值 "" 和 false。

会导致 panic 的规则:

  • 向已关闭的通道发送会 panic,报错 send on closed channel,
  • 关闭已经关闭的通道会 panic,
  • 关闭 nil 通道会 panic。

所以只有发送方关闭通道,而且只关闭一次。有多个发送方时,没有哪个发送方知道其他发送方何时结束;这时让一个单独的 goroutine 等待所有发送方(用 sync.WaitGroup),在 Wait 返回后关闭通道。只有在接收方需要等待结束信号时才需要关闭。没有关闭、也没有任何引用的通道会像其他值一样被垃圾回收。

方向类型

函数可以声明它对某个通道只发送或只接收,编译器随后会拒绝另一种操作。

类型含义允许的操作
chan T双向发送、接收、关闭
chan<- T只发送发送、关闭
<-chan T只接收接收

把 chan T 传给函数时,它会隐式转换为任意一种受限类型。这就是上面的 produce 可以返回 <-chan int 的原因:调用方可以用 range 遍历它,但不能向它发送,也不能关闭它。在所有适用的函数参数上都使用方向类型。它们说明了所有权,并把误用变成编译错误。

死锁

如果每个 goroutine 都被阻塞,并且没有任何东西能唤醒它们,运行时就会终止程序:

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
	/tmp/main.go:7 +0x38
exit status 2

goroutine 转储会告诉你哪个操作卡住了(这里是 chan send,也可能是 chan receive、sync.WaitGroup.Wait、select)。常见原因:

  • 向无缓冲通道发送,却没有正在运行的接收方,
  • 对一个永远不会关闭的通道使用 range,
  • WaitGroup 计数器永远到不了零,
  • 两个 goroutine 互相等待对方。

运行时只能检测出所有 goroutine 都卡住的情况。在还有其他 goroutine 存活的服务器中(HTTP 监听器、定时器),同样的 bug 不会让程序崩溃,被阻塞的 goroutine 只会悄悄泄漏。

nil 通道

在 nil 通道上发送和接收都会永远阻塞。这听起来毫无用处,但在 select 中是一个常用技巧:把通道变量设为 nil 就能禁用它所在的分支。下面的代码合并两个通道,并在每个通道关闭后不再监听它:

如果没有这些 nil 赋值,已关闭的通道总是处于就绪状态,循环就会在零值上空转。

流水线

通道可以组合成流水线:每个阶段都是一个 goroutine,从一个通道接收、向下一个通道发送,并在输入结束时关闭自己的输出。

每个阶段并发运行,而输出顺序是确定的(1、16、81),因为每个阶段都是单个 goroutine,会保持顺序。关闭会逐级传递:generate 关闭后,square 的 range 结束,它随即关闭自己的输出,依此类推,一直传到 main。

这条流水线的弱点:如果 main 提前停止读取,各个阶段就会永远阻塞在发送上。真实的流水线会接收一个 context.Context 或 done 通道,并在每次发送时用 select 同时监听它。

通道还是互斥锁

通道用于转移数据的所有权和发出事件信号。对于许多 goroutine 就地读取和更新的共享状态(比如缓存或计数器),用 sync.Mutex 保护更简单。内部带互斥锁的结构体,往往比一个拥有状态并通过通道响应请求的 goroutine 更清晰。哪种写法能让代码更短、所有权更明显,就用哪种。

速查表

操作nil 通道打开的通道已关闭的通道
ch <- v永远阻塞阻塞,直到被接收或缓冲区有空间panic
<-ch永远阻塞阻塞,直到有值可取先取缓冲的值,之后返回零值
v, ok := <-ch永远阻塞ok 为 true取完后 ok 为 false
close(ch)panic关闭panic
len(ch)、cap(ch)0、0已缓冲的值数量、缓冲区大小剩余的值数量、缓冲区大小

常见问题

Go 中带缓冲通道和无缓冲通道有什么区别?

无缓冲通道(make(chan int))没有存储空间:发送会一直阻塞,直到另一个 goroutine 接收,所以每次发送同时也是一次交接和一个同步点。带缓冲通道(make(chan int, 3))最多可以存放 3 个值;只有缓冲区满时发送才会阻塞,只有缓冲区空时接收才会阻塞。

Go 中从已关闭的通道读取会发生什么?

从已关闭的通道接收永远不会阻塞。它会先取完缓冲区中剩余的值,之后一直返回元素类型的零值。用 v, ok := <-ch 来区分:通道关闭且为空后,ok 为 false。for v := range ch 循环会在这时结束。

Go 中应该由谁来关闭通道?

由发送方关闭,而且只在接收方需要知道不会再有值到来时才关闭(例如为了结束 range 循环)。向已关闭的通道发送会 panic,重复关闭通道也会 panic,所以由接收方关闭通道会和发送方产生竞争。释放通道并不需要关闭它;无论是否关闭,垃圾回收器都会回收不再可达的通道。

“fatal error: all goroutines are asleep”是什么意思?

程序中的每个 goroutine 都阻塞在某个通道操作或锁上,而且没有任何东西能让它们继续,所以运行时会终止程序。最常见的原因是在 main 中向无缓冲通道发送数据,却没有其他 goroutine 接收,或者对一个永远不会关闭的通道使用 range。

Coddy 编程语言插图

和 Coddy 一起学编程

开始