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

相关推荐
冲鸭ONE27 分钟前
for循环优化方式有哪些?
后端·性能优化
兮动人28 分钟前
DBeaver连接OceanBase数据库
后端
侯大宝41 分钟前
开箱即用的go-zero示例
go·go-zero
刘鹏3781 小时前
深入浅出Java中的CAS:原理、源码与实战应用
后端
Lx3521 小时前
《从头开始学java,一天一个知识点》之:循环结构:for与while循环的使用场景
java·后端
fliter1 小时前
RKE1、K3S、RKE2 三大 Kubernetes 发行版的比较
后端
aloha_1 小时前
mysql 某个客户端主机在短时间内发起了大量失败的连接请求时
后端
程序员爱钓鱼1 小时前
Go 语言高效连接 SQL Server(MSSQL)数据库实战指南
后端·go·sql server
xjz18421 小时前
Java AQS(AbstractQueuedSynchronizer)实现原理详解
后端
Victor3561 小时前
Zookeeper(97)如何在Zookeeper中实现分布式协调?
后端