今天看到了一篇有关 timer 泄露的文章,觉得很有意思,于是把它记录下来。

一般没有问题的写法

说道 time.After() 会导致内存泄露,很多人一定会觉得奇怪,因为代码里经常会用到它,也没见有内存泄漏啊?

是的,一般我们这样写的话是没有问题的

func main() {
    ch := make(chan int)
    go func() {
        ch <- 1
    }()

    select {
    case _ = <-ch:
    case <-time.After(time.Second * 1):
        fmt.Println("timeout")
    }
}

有问题的写法

那么,什么样的写法有问题呢? 当使用 for loop 的时候,比如这样

for {
        select {
        case _ = <-ch:
            // do something...
            continue
        case <-time.After(300 * time.Millisecond):
            fmt.Printf("time.After() fire!\n")
        }
    }

很不幸的是,上面这样的写法也非常常见,我自己就写过这样的代码。那么它真的会造成内存泄露吗?试一下便知道

前一篇博客中已经介绍了如何使用 pprof 对 Go 程序进行 profiling,简单提一下步骤

  1. 在代码中引入 _ "net/http/pprof", 并开启一个http server 导出 metrics
  2. 运行你的 binary
  3. 执行 go tool pprof -http=:8081 http://localhost:6060/debug/pprof/heap
  4. 浏览器就会自动打开 localhost:8081 显示结果了

测试代码如下:

package main

import (
	"fmt"
	"net/http"
	_ "net/http/pprof"
	"time"
)

func main() {

	go func() {
		ip := "127.0.0.1:6060"
		if err := http.ListenAndServe(ip, nil); err != nil {
			fmt.Printf("start pprof failed on %s\n", ip)
		}
	}()

	ch := make(chan int, 10)
	go func() {
		in := 1
		for {
			in++
			ch <- in
		}
	}()

	for {
		select {
		case _ = <-ch:
			// do something...
			continue
		case <-time.After(3 * time.Minute):
			fmt.Printf("time.After() fire!\n")
		}
	}
}

使用 pprof 后,可见 timer 内存占用居然超过了 1GB !!

同时,从操作系统里也能看到 binary 运行时和运行后,内存的大幅变化。

原因

那么是什么原因导致的呢? 通过参考资料的解释,是 select 语句每次执行都是创建了新的 timer,当有 for 循环时,每次都在不断地创建 timer,而 timer 直到超时以后才会被 GC 回收,否则就算当前的作用域执行完了,或者 goroutine 退出,timer依然存在内存。

Todo: 如果没看到这篇文章,我怎么知道 select 语句每次都是新建 timer呢? 似乎应该有能力从 Go 编译源码的步骤,或者生成的汇编代码看出来,否则永远都是别人告诉你有坑你才知道。

优化

上面的例子故意把 timeout 设置为 3分钟,同时有不断的 <- ch输入,所以会有大量的 timer 没有被 GC 回收,如果把 timeout 的时间设置小一点呢?

还是同样的代码,如果把 timeout 设置为 300ms,内存占用如下

大概在 200MB 左右,可见,减小 timeout 时间,在一定程度上能减少内存泄露的量。但是这个还是治标不治本的方法。

解决办法

一个解决办法是不在 select 內部创建 timer,把它放到外部,并每次 reset timer

package main

import (
	"fmt"
	"net/http"
	_ "net/http/pprof"
	"time"
)

func main() {

	go func() {
		ip := "127.0.0.1:6060"
		if err := http.ListenAndServe(ip, nil); err != nil {
			fmt.Printf("start pprof failed on %s\n", ip)
		}
	}()

	ch := make(chan int, 10)
	go func() {
		in := 1
		for {
			in++
			ch <- in
		}
	}()

	dur := 300 * time.Millisecond
	myTimer := time.NewTimer(dur)
	defer myTimer.Stop()

	for {

		myTimer.Reset(dur)

		select {
		case _ = <-ch:
			// do something...
			continue
		case <-myTimer.C:
			fmt.Printf("timer fired\n")
		}
	}

}

但是,当 select 语句在一个单独的 goroutine 里面,外部是在 for loop 中调用这个 goroutine,似乎就没有很好的办法了,除非把 timer 作为参数传入。

参考资料

  1. https://eddycjy.com/posts/go/go-tips-timer-memory/
  2. https://segmentfault.com/a/1190000024523708
  3. https://oftime.net/