go channel 的应用可以说满是知识点,算是 golang 中的一个难点。新手使用时只要稍一不谨慎,就会造成各种问题。比如阻塞、panic、内存泄漏。接下来我将通过代码详细阐述这些问题及其解决方案。
问题一:channel 为什么阻塞了
【知识点】go channel 如果没有设置缓冲队列,无论读取还是写入,都会阻塞。
如下代码所示:
func TestBlocking(t *testing.T) {
errCh := make(chan error) // 1
fmt.Println("make(chan error)")
errCh <- errors.New("chan error") // 2
fmt.Println("finish", <-errCh)
// Output:
// make(chan error)
}
复制代码
上述代码会一直阻塞。因为 1 处创建了一个无缓存队列的 channel,所以代码一直阻塞在 2 处。一种解决方案是创建 channel 时使用缓冲队列(如将 1 处代码替换为 errCh := make(chan error, 1)
);一种是使用 go routine 进行发送或读取操作,以防止阻塞(如下代码所示)。
func TestWithoutBlocking(t *testing.T) {
errCh := make(chan error)
fmt.Println("make(chan error)")
go func() { errCh <- errors.New("chan error") }
fmt.Println("finish", <-errCh)
}
复制代码
问题二:什么情况下关闭 channel 会造成 panic ?
先看示例:
// 1.未初始化时关闭
func TestCloseNilChan(t *testing.T) {
var errCh chan error
close(errCh)
// Output:
// panic: close of nil channel
}
// 2.重复关闭
func TestRepeatClosingChan(t *testing.T) {
errCh := make(chan error)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
close(errCh)
close(errCh)
}()
wg.Wait()
// Output:
// panic: close of closed channel
}
// 3.关闭后发送
func TestSendOnClosingChan(t *testing.T) {
errCh := make(chan error)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
close(errCh)
errCh <- errors.New("chan error")
}()
wg.Wait()
// Output:
// panic: send on closed channel
}
// 4.发送时关闭
func TestCloseOnSendingToChan(t *testing.T) {
errCh := make(chan error)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
defer close(errCh)
go func() {
errCh <- errors.New("chan error") // 由于 chan 没有缓冲队列,代码会一直在此处阻塞
}()
time.Sleep(time.Second) // 等待向 errCh 发送数据
}()
wg.Wait()
// Output:
// panic: send on closed channel
}
复制代码
【知识点】综上,在以下 4 种情况关闭 channel 会引发 panic:
- 未初始化时关闭
- 重复关闭
- 关闭后发送
- 发送时关闭
从 golang 的报错中我们可以知道,golang 认为第3种和第4种情况属于一种情况。
因此,在使用 channel 时为了避免重复关闭、关闭后发送的问题。我们应该遵守如下的“channel 关闭守则”:
- 只在发送端关闭 channel(防止关闭后继续发送)。
- 存在多个发送者时不要关闭发送者 channel,而是使用专门的 stop channel(因为多个发送者都在发送,且不可能同时关闭多个发送者,否则会造成重复关闭。发送者和接收者多对一时,接收者关闭 stop channel;多对多时,由任意一方关闭 stop channel,双方监听 stop channel 终止后及时停止发送和接收)。
既然关闭 channel 这么麻烦,那么我们有没有必要关闭 channel 呢?不关闭又如何?
问题三:有没有必要关闭 channel?不关闭又如何?
我们考虑以下两种情况:
情况一:channel 的发送次数等于接收次数
func TestIsCloseChannelNecessary_on_equal(t *testing.T) {
fmt.Println("NumGoroutine:", runtime.NumGoroutine())
ich := make(chan int)
// sender
go func() {
for i := 0; i < 3; i++ {
ich <- i
}
}()
// receiver
go func() {
for i := 0; i < 3; i++ {
fmt.Println(<-ich)
}
}()
time.Sleep(time.Second)
fmt.Println("NumGoroutine:", runtime.NumGoroutine())
// Output:
// NumGoroutine: 2
// 0
// 1
// 2
// NumGoroutine: 2
}
复制代码
channel 的发送次数等于接收次数时,发送者 go routine 和接收者 go routine 分别都会在发送或接收结束时结束各自的 go routine。而上述代码中的 ich 会由于没有代码使用被垃圾收集器回收。因此这种情况下,不关闭 channel,没有任何副作用。
情况二:channel 的发送次数大于/小于接收次数
func TestIsCloseChannelNecessary_on_less_sender(t *testing.T) {
fmt.Println("NumGoroutine:", runtime.NumGoroutine())
ich := make(chan int)
// sender
go func() {
for i := 0; i < 2; i++ {
ich <- i
}
}()
// receiver
go func() {
for i := 0; i < 3; i++ {
fmt.Println(<-ich)
}
}()
time.Sleep(time.Second)
fmt.Println("NumGoroutine:", runtime.NumGoroutine())
// Output:
// NumGoroutine: 2
// 0
// 1
// NumGoroutine: 3
}
复制代码
以上述代码为例,channel 的发送次数小于接收次数时,接收者 go routine 由于等待发送者发送一直阻塞。因此接收者 go routine 一直未退出,ich 也由于一直被接收者使用无法被垃圾回收。未退出的 go routine 和未被回收的 channel 都造成了内存泄漏的问题。
综上,绝大多数情况下,我们无法准确判断 channel 的发送次数和接收次数,因此我们应该在合适的时机关闭 channel。那么如何判断 channel 是否关闭呢?
问题四:如何判断 channel 关闭?
【知识点】go channel 关闭后,读取该 channel 永远不会阻塞,且只会输出对应类型的零值。
如下代码所示:
func TestReadFromClosedChan(t *testing.T) {
var errCh = make(chan error)
go func() {
defer close(errCh)
errCh <- errors.New("chan error")
}()
go func() {
for i := 0; i < 3; i++ {
fmt.Println(i, <-errCh)
}
}()
time.Sleep(time.Second)
// Output:
// 0 chan error
// 1 <nil>
// 2 <nil>
}
复制代码
以上述代码为例,nil 可能也是需要 channel传输的值之一,通常我们无法通过判断是否为类型的零值确定 channel 是否关闭。所以为了避免输出无意义的值,我们需要一种合理的方式判断 channel 是否关闭。golang 官方为我们提供了两种方式。
解决方案一:使用 channel 的多重返回值(如 err, ok := <-errCh )
func TestReadFromClosedChan2(t *testing.T) {
var errCh = make(chan error)
go func() {
defer close(errCh)
errCh <- errors.New("chan error")
}()
go func() {
for i := 0; i < 3; i++ {
if err, ok := <-errCh; ok {
fmt.Println(i, err)
}
}
}()
time.Sleep(time.Second)
// Output:
// 0 chan error
}
复制代码
err, ok := <-errCh 的第二个返回值 ok 表示 errCh 是否已经关闭。如果已关闭,则返回 true。
解决方案二:使用 for range 简化语法
func TestReadFromClosedChan(t *testing.T) {
var errCh = make(chan error)
go func() {
defer close(errCh)
errCh <- errors.New("chan error")
}()
go func() {
i := 0
for err := range errCh {
fmt.Println(i, err)
i++
}
}()
time.Sleep(time.Second)
// Output:
// 0 chan error
}
复制代码
for range 语法会自动判断 channel 是否结束,如果结束则自动退出 for 循环。
问题五:如何优雅的关闭 channel?
我们从前文也了解到,如果发生重复关闭、关闭后发送等问题,会造成 channel panic。那么如何优雅的关闭 channel,是我们关心的一个问题。
golang 官方为我们提供了一种方式,可以用来尽量避免这个问题。golang 允许我们使用 <- 控制 channel 发送方向,防止我们在错误的时候关闭 channel。
func TestOneSenderOneReceiver(t *testing.T) {
ich := make(chan int)
go sender(ich)
go receiver(ich)
}
func sender(ich chan<- int) {
for i := 0; i < 100; i++ {
ich <- i
}
}
func receiver(ich <-chan int) {
fmt.Println(<-ich)
close(ich) // 此处代码会在编译期报错
}
复制代码
使用这种方法时,由于 close()
函数只能接受 chan<- T
类型的 channel,如果我们尝试在接收方关闭 channel,编译器会报错,所以我们可以在编译期提前发现错误。
除此之外,我们也可以使用如下的结构体(抄自go101《如何优雅的关闭 go channels》,做了一点修改,链接为此文的中文翻译):
type Channel struct {
C chan interface{}
closed bool
mut sync.Mutex
}
func NewChannel() *Channel {
return NewChannelSize(0)
}
func NewChannelSize(size int) *Channel {
return &Channel{
C: make(chan interface{}, size),
closed: false,
mut: sync.Mutex{},
}
}
func (c *Channel) Close() {
c.mut.Lock()
defer c.mut.Unlock()
if !c.closed {
close(c.C)
c.closed = true
}
}
func (c *Channel) IsClosed() bool {
c.mut.Lock()
defer c.mut.Unlock()
return c.closed
}
func TestChannel(t *testing.T) {
ch := NewChannel()
println(ch.IsClosed())
ch.Close()
ch.Close()
println(ch.IsClosed())
}
复制代码
该方案可以解决重复关闭锁的问题以及锁是否关闭的问题。通过 Channel.IsClosed()
判断是否关闭 channel ,又可以安全的发送和接收。当然我们也可以把 sync.Mutex
换成 sync.Once
,来只让 channel 关闭一次。具体可以参考《如何优雅的关闭 go channels》。
有时候我们的代码已经使用了原生的 chan
,或者我们不想使用单独的数据结构,也可以使用下述的几种方案。通常情况下,我们只会遇到四种需要关闭 channel 的情况(以下内容时我对《如何优雅的关闭 go channels》中方法的总结):
- 一个发送者,一个接收者:发送者关闭 channel。
- 一个发送者,多个接收者:发送者关闭 channel。
- 多个发送者,一个接收者:接收者接收完毕后,使用专用的 stop channel 关闭;发送者使用 select 监听 stop channel 是否关闭。
- 多个发送者,多个接收者:任意一方使用专用的 stop channel 关闭;发送者、接收者都使用 select 监听 stop channel 是否关闭。
因此我们只需要熟记面对这四种情况时如何关闭 channel 即可。为避免单纯地抄袭,具体的代码实现可以去参考《如何优雅的关闭 go channels》这篇文章(划到中间位置,找“保持channel closing principle的优雅方案”关键字即可)。
总述
代码不会撒谎。事实证明,使用 go channel 坑确实不少。新手使用时只要稍一不谨慎,就会造成各种问题。即便是老手,也少不了会造成内存泄漏的问题。后续我会再写一篇文章来详细讨论 go channel 可能造成的内存泄漏的问题。