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

相关推荐
Estar.Lee11 分钟前
时间操作[取当前北京时间]免费API接口教程
android·网络·后端·网络协议·tcp/ip
喜欢猪猪12 分钟前
Django:从入门到精通
后端·python·django
一个小坑货13 分钟前
Cargo Rust 的包管理器
开发语言·后端·rust
bluebonnet2717 分钟前
【Rust练习】22.HashMap
开发语言·后端·rust
uhakadotcom40 分钟前
如何实现一个基于CLI终端的AI 聊天机器人?
后端
Iced_Sheep1 小时前
干掉 if else 之策略模式
后端·设计模式
XINGTECODE2 小时前
海盗王集成网关和商城服务端功能golang版
开发语言·后端·golang
程序猿进阶2 小时前
堆外内存泄露排查经历
java·jvm·后端·面试·性能优化·oom·内存泄露
FIN技术铺2 小时前
Spring Boot框架Starter组件整理
java·spring boot·后端
凡人的AI工具箱2 小时前
15分钟学 Go 第 60 天 :综合项目展示 - 构建微服务电商平台(完整示例25000字)
开发语言·后端·微服务·架构·golang