新手使用 go channel 需要注意的问题

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:

  1. 未初始化时关闭
  2. 重复关闭
  3. 关闭后发送
  4. 发送时关闭

从 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 可能造成的内存泄漏的问题。

猜你喜欢

转载自juejin.im/post/7031004528929406984