Go - 聊聊Channel内存泄漏

Go - 聊聊Channel内存泄漏

一、什么是内存泄漏

内存泄漏是指程序运行过程中,内存因为某些原因无法释放或没有释放。简单来讲就是,有代码占着茅坑不拉屎,让内存资源造成了浪费。如果泄漏的内存越堆越多,就会占用程序正常运行的内存。比较轻的影响是程序开始运行越来越缓慢;严重的话,可能导致大量泄漏的内存堆积,最终导致程序没有内存可以运行,最终导致 OOM (Out Of Memory,即内存溢出)。

但通常来讲,内存泄漏都是极其不易发现的,所以为了保证程序的健康运行,我们需要重视如何避免写出内存泄漏的代码。

二、Channel内存泄漏的两种场景

2.1 场景一:select-case 误用导致的内存泄露

都说 golang 10 次内存泄漏,9 次是 go routine 泄漏。可见 go channel 内存泄漏的常见性。go channel 内存泄露主要分两种情况 。

2.1.1 错误示例

go 复制代码
​
func main() {
    fmt.Println("NumGoroutine:", runtime.NumGoroutine())
​
    chanLeakOfMemory()
​
    time.Sleep(time.Second * 3) // 等待 goroutine 执行
    fmt.Println("NumGoroutine:", runtime.NumGoroutine())
}
​
func chanLeakOfMemory() {
    errCh := make(chan error) // (1)
    
    go func() { // (5)
        time.Sleep(2 * time.Second)
        errCh <- errors.New("chan error") // (2)
        fmt.Println("finish sending")
    }()
    
    var err error
    select {
    case <-time.After(time.Second): // (3) 大家也经常在这里使用 <-ctx.Done()
        fmt.Println("超时")
    case err = <-errCh: // (4)
        if err != nil {
            fmt.Println(err)
        } else {
            fmt.Println(nil)
        }
    }
}

输出结果如下:

原因:由于 (1) 处创建的 errCh 是不含缓存队列的 channel,然后(3)的超时导致函数退出, channel 只有发送方发送,发送方会阻塞。

由于外部的 goroutine 已经退出了,errCh 没有接收者,导致 (2) 处一直阻塞。因此 (2) 处代码所在的协程一直未退出,造成了内存泄漏。

2.1.2 解决方案

可能会有人想要使用 defer close(errCh) 关闭 channel。比如把 (1) 处代码改为如下形式:

go 复制代码
 errCh := make(chan error)
 defer close(errCh)

由于 (2) 处代码没有接收者,所以一直阻塞。 直到运行了 close(errCh) 后,有 goroutine 在向关闭的 errCh 发送,则程序就会发生 panic。有人想在 (5) 处 goroutine 的第一句加上 defer close(errCh) 由于 (2) 处阻塞defer close(errCh) 会一直得不到执行。

正确的修改方式:

我们只需要为 channel 增加一个缓存队列,即把 (1) 处代码改为 errCh := make(chan error, 1) 即可。

2.2 场景二:channel 发送/接收次数不等内存泄漏

2.2.1 错误示例

go 复制代码
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
   // 

以上述代码为例,channel 的发送次数小于接收次数时,接收者 go routine 由于等待发送者发送一直阻塞。因此接收者 go routine 一直未退出,ich 也由于一直被接收者使用无法被垃圾回收。未退出的 go routine 和未被回收的 channel 都造成了内存泄漏的问题。

2.2.2 解决方案

  • 方案一、使用 channel 的多重返回值

    go 复制代码
    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++ {
              // 判断channel是否关闭
             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 简化语法

    go 复制代码
    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 循环。
  • 添加额外通知信号

    go 复制代码
    func TestIsCloseChannelNecessary_on_less_sender(t *testing.T) {
        fmt.Println("NumGoroutine:", runtime.NumGoroutine())
        ich := make(chan int)
        stop := make(chan struct{})
    ​
        // sender
        go func() {
            for i := 0; i < 2; i++ {
                ich <- i
            }
            close(stop)
        }()
    ​
        // receiver
        go func() {
            for i := 0; i < 3; i++ {
                select {
                case <-stop:
                    fmt.Println("stop")
                    return
                case data := <-ich:
                    fmt.Println(data)
                }
            }
        }()
    ​
        time.Sleep(time.Second)
        fmt.Println("NumGoroutine:", runtime.NumGoroutine())
    }

三、如何优雅地关闭 channel

如果发生重复关闭、关闭后发送等问题,会造成 channel panic。那么如何优雅地关闭 channel,是我们关心的一个问题。

除此之外,我们也可以使用如下的结构体(抄自go101《如何优雅地关闭 go channels[1]》,做了一点修改,链接为此文的中文翻译):

go 复制代码
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 channel 内存泄漏问题

如何优雅地关闭Go channel

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

相关推荐
攸攸太上6 分钟前
Spring Gateway学习
java·后端·学习·spring·微服务·gateway
罗曼蒂克在消亡23 分钟前
graphql--快速了解graphql特点
后端·graphql
潘多编程25 分钟前
Spring Boot与GraphQL:现代化API设计
spring boot·后端·graphql
大神薯条老师1 小时前
Python从入门到高手4.3节-掌握跳转控制语句
后端·爬虫·python·深度学习·机器学习·数据分析
2401_857622662 小时前
Spring Boot新闻推荐系统:性能优化策略
java·spring boot·后端
AskHarries2 小时前
如何优雅的处理NPE问题?
java·spring boot·后端
计算机学姐3 小时前
基于SpringBoot+Vue的高校运动会管理系统
java·vue.js·spring boot·后端·mysql·intellij-idea·mybatis
猿java4 小时前
Cookie和Session的区别
java·后端·面试
程序员陆通4 小时前
Spring Boot RESTful API开发教程
spring boot·后端·restful
无理 Java4 小时前
【技术详解】SpringMVC框架全面解析:从入门到精通(SpringMVC)
java·后端·spring·面试·mvc·框架·springmvc