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

相关推荐
写bug写bug9 分钟前
手把手教你使用JConsole
java·后端·程序员
苏三说技术13 分钟前
给你1亿的Redis key,如何高效统计?
后端
JohnYan36 分钟前
工作笔记- 记一次MySQL数据移植表空间错误排除
数据库·后端·mysql
程序员清风1 小时前
阿里二面:Kafka 消费者消费消息慢(10 多分钟),会对 Kafka 有什么影响?
java·后端·面试
CodeSheep1 小时前
宇树科技,改名了!
前端·后端·程序员
hstar95271 小时前
三十五、面向对象底层逻辑-Spring MVC中AbstractXlsxStreamingView的设计
java·后端·spring·设计模式·架构·mvc
楽码1 小时前
AI决策树:整理繁杂问题的简单方法
人工智能·后端·openai
星辰大海的精灵2 小时前
基于Dify+MCP实现通过微信发送天气信息给好友
人工智能·后端·python
import_random2 小时前
[深度学习]5大神经网络架构(介绍)
后端
pengyu2 小时前
【Java设计原则与模式之系统化精讲:壹】 | 编程世界的道与术(实战指导篇)
java·后端·设计模式