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 的多重返回值
gofunc 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 简化语法
gofunc 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 循环。
-
添加额外通知信号
gofunc 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 关闭一次。