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 需要注意的问题

相关推荐
极光代码工作室17 分钟前
基于SpringBoot的流浪狗管理系统的设计与实现
java·spring boot·后端
Rust语言中文社区24 分钟前
【Rust日报】Dioxus 用起来有趣吗?
开发语言·后端·rust
小灰灰搞电子27 分钟前
Rust Slint实现颜色选择器源码分享
开发语言·后端·rust
boolean的主人41 分钟前
mac电脑安装nginx+php
后端
boolean的主人44 分钟前
mac电脑安装运行多个php版本
后端
oouy2 小时前
Java的三大特性:从懵圈到通透的实战指南
后端
狂炫冰美式2 小时前
3天,1人,从0到付费产品:AI时代个人开发者的生存指南
前端·人工智能·后端
Java水解3 小时前
PostgreSQL 自增序列SERIAL:从原理到实战
后端·postgresql
悟空码字3 小时前
单点登录:一次登录,全网通行
java·后端
倚肆3 小时前
Spring Boot Security 全面详解与实战指南
java·spring boot·后端