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

go channel 的应用可以说满是知识点,算是 golang 中的一个难点。新手使用时只要稍一不谨慎,就会造成各种问题。比如阻塞、panic、内存泄漏。接下来我将通过代码详细阐述这些问题及其解决方案。

目录

• channel 为什么阻塞了?

• 什么情况下关闭 channel 会造成 panic ?

• 有没有必要关闭 channel?不关闭又如何?

• 如何判断 channel 是否关闭?

• 如何优雅地关闭 channel ?

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,而是使用专门的 stop channel。(因为多个发送者都在发送,且不可能同时关闭多个发送者,否则会造成重复关闭。发送者和接收者多对一时,接收者关闭 stop channel;多对多时,由任意一方关闭 stop channel,双方监听 stop channel 终止后及时停止发送和接收)

这两点规律被称为“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 是否关闭呢?

如何判断 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++ {

         err, ok := <-errCh

         if ok {

            fmt.Println(i, err)

         } else {

            fmt.Println(i, err)

         }

      }

   }()

   time.Sleep(time.Second)


   // Output:

   // 0 chan error

   // 1 <nil>

   // 2 <nil>

}

err, ok := <-errCh 的第二个返回值 ok 表示 errCh 是否已经关闭。如果已关闭,则返回 false。

解决方案二:使用 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[1]》,做了一点修改,链接为此文的中文翻译):

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[2]》。

有时候我们的代码已经使用了原生的 chan,或者我们不想使用单独的数据结构,也可以使用下述的几种方案。通常情况下,我们只会遇到四种需要关闭 channel 的情况(以下内容是我对《如何优雅地关闭 go channels[3]》中方法的总结):

• 一个发送者,一个接收者:发送者关闭 channel,接收者使用 select 或 for range 判断 channel 是否关闭。

• 一个发送者,多个接收者:发送者关闭 channel,同上。

• 多个发送者,一个接收者:接收者接收完毕后,使用专用的 stop channel 关闭;发送者使用 select 监听 stop channel 是否关闭。

• 多个发送者,多个接收者:任意一方使用专用的 stop channel 关闭;发送者、接收者都使用 select 监听 stop channel 是否关闭。

因此我们只需要熟记面对这四种情况时如何关闭 channel 即可。为避免单纯地抄袭,具体的代码实现可以去参考《如何优雅地关闭 go channels[4]》这篇文章(划到中间位置,找“保持channel closing principle的优雅方案”关键字即可)。

总述

代码不会撒谎。事实证明,使用 go channel 要注意的问题确实不少。新手使用时只要稍一不谨慎,就会造成各种问题。即便是老手,在使用 go channel 时也少不了会造成内存泄漏的问题。后续我会再写一篇文章来详细讨论 go channel 可能造成的内存泄漏的问题。但这都不重要,重要的是:各位老爷,给个赞吧!

推荐

• ezmq,支持断线重连和消息重发的 golang amqp(RabbitMQ) 客户端[5]

• 知名大学开原课程收录计划[6]

• Linux 不入门到入门[7]

引用链接

[1] 如何优雅地关闭 go channels: https://www.jianshu.com/p/d24dfbb33781

[2] 如何优雅地关闭 go channels: https://www.jianshu.com/p/d24dfbb33781

[3] 如何优雅地关闭 go channels: https://www.jianshu.com/p/d24dfbb33781

[4] 如何优雅地关闭 go channels: https://www.jianshu.com/p/d24dfbb33781

[5] ezmq,支持断线重连和消息重发的 golang amqp(RabbitMQ) 客户端: https://gitee.com/super9du/ezmq

[6] 知名大学开原课程收录计划: https://github.com/super9du/ggs-ddu

[7] Linux 不入门到入门: https://super9du.github.io/linux-primer/

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,287评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,346评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,277评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,132评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,147评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,106评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,019评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,862评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,301评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,521评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,682评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,405评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,996评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,651评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,803评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,674评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,563评论 2 352

推荐阅读更多精彩内容

  • fmt格式化字符串 格式:%[旗标][宽度][.精度][arg索引]动词旗标有以下几种:+: 对于数值类型总是输出...
    皮皮v阅读 1,096评论 0 3
  • Go语言并发模型 Go 语言中使用了CSP模型来进行线程通信,准确说,是轻量级线程goroutine之间的通信。C...
    副班长国伟阅读 2,078评论 0 2
  • 一. 符号 =与:=:= : 用来初始化一个不存在的变量, 包括声明和初始化2个步骤= : 赋值符号, 当变量被:...
    lj72808up阅读 277评论 0 0
  • Channel 单纯地将函数并发执行是没有意义地,函数与函数需要交换数据才能体现并发执行函数地意义。Go语言的并发...
    TZX_0710阅读 326评论 0 0
  • 出处---Go编程语言 欢迎来到 Go 编程语言指南。本指南涵盖了该语言的大部分重要特性 Go 语言的交互式简介,...
    Tuberose阅读 18,422评论 1 46