Go坑:time.After可能导致的内存泄露问题分析

科技资讯 投稿 6300 0 评论

Go坑:time.After可能导致的内存泄露问题分析

一、Time 包中定时器函数

go v1.20.4

定时函数:NewTicker,NewTimer 和 time.After 介绍

    NewTimer: 表示在一段时间后才执行,默认情况下执行一次。如果想再次执行,需要调用 time.Reset( 方法,这时类似于 NewTicker 定时器了。可以调用 stop 方法停止执行。
  func NewTimer(d Duration *Timer
  // NewTimer 创建一个新的 Timer,它将至少持续时间 d 之后,在向通道中发送当前时间
  // d 表示间隔时间
  
 type Timer struct {
  	C <-chan Time
	r runtimeTimer
  }

重置 NewTimer 定时器的 Reset( 方法,它是定时器在持续时间 d 到期后,用这个方法重置定时器让它再一次运行,如果定时器被激活返回 true,如果定时器已过期或停止,在返回 false。

func (t *Timer Reset(d Duration bool
    用 Reset 方法需要注意的地方:

如果程序已经从 t.C 接收到了一个值,则已知定时器已过期且通道值已取空,可以直接调用 time.Reset 方法;

综合上面 2 种情况,正确使用 time.Reset 方法就是:

if !t.Stop( {
	<-t.C
}
t.Reset(d
    Stop 方法
func (t *Timer Stop( bool
// 如果定时器已经过期或停止,返回 false,否则返回 true

Stop 方法能够阻止定时器触发,但是它不会关闭通道,这是为了防止从通道中错误的读取值。

if !t.Stop( {
 <-t.C
}

    NewTicker: 表示每隔一段时间运行一次,可以执行多次。可以调用 stop 方法停止执行。

func NewTicker(d Duration *Ticker

NewTicker 返回一个 Ticker,这个 Ticker 包含一个时间的通道,每次重置后会发送一个当前时间到这个通道上。

d 表示每一次运行间隔的时间。

  • time.After: 表示在一段时间后执行。其实它内部调用的就是 time.Timer 。

    func After(d Duration <-chan Time
    
  • ​ 跟它还有一个相似的函数 time.AfterFunc,后面运行的是一个函数。

    package main
    
    import (
    	"fmt"
    	"time"
    
    
    func main( {
    	ticker := time.NewTicker(time.Second
    	defer ticker.Stop(
    	done := make(chan bool
    	go func( {
    		time.Sleep(10 * time.Second
    		done <- true
    	}(
    	for {
    		select {
    		case <-done:
    			fmt.Println("Done!"
    			return
    		case t := <-ticker.C:
    			fmt.Println("Current time: ", t
    		}
    	}
    }
    

    二、time.After 导致的内存泄露

    基本用法

    time.After 方法是在一段时间后返回 time.Time 类型的 channel 消息,看下面源码就清楚返回值类型:

    // https://github.com/golang/go/blob/go1.20.4/src/time/sleep.go#LL156C1-L158C2
    func After(d Duration <-chan Time {
    	return NewTimer(d.C
    }
    
    // https://github.com/golang/go/blob/go1.20.4/src/time/sleep.go#LL50C1-L53C2
    type Timer struct {
    	C <-chan Time
    	r runtimeTimer
    }
    

    从代码可以看出它底层就是 NewTimer 实现。

    package main
    
    import (
    	"fmt"
    	"time"
    
    
    func main( {
    	ch2 := make(chan string, 1
    
    	go func( {
    		time.Sleep(time.Second * 2
    		ch2 <- "hello"
    	}(
    
    	select {
    	case res := <-ch2:
    		fmt.Println(res
    	case <-time.After(time.Second * 1:
    		fmt.Println("timeout"
    	}
    }
    

    有问题代码

    上面的代码运行是没有什么问题的,不会导致内存泄露。

    在有些情况下,select 需要配合 for 不断检测通道情况,问题就有可能出在 for 循环这里。

    timeafter.go:

    package main
    
    import (
    	"fmt"
    	"net/http"
    	_ "net/http/pprof"
    	"time"
    
    
    func main( {
    	fmt.Println("start..."
    	ch2 := make(chan string, 120
    
    	go func( {
    		// time.Sleep(time.Second * 1
    		i := 0
    		for {
    			i++
    			ch2 <- fmt.Sprintf("%s %d", "hello", i
    		}
    
    	}(
    
    	go func( {
    		// http 监听8080, 开启 pprof
    		if err := http.ListenAndServe(":8080", nil; err != nil {
    			fmt.Println("listen failed"
    		}
    	}(
    
    	for {
    		select {
    		case _ = <-ch2:
    			// fmt.Println(res
    		case <-time.After(time.Minute * 3:
    			fmt.Println("timeout"
    		}
    	}
    }
    

    在终端上运行代码:go run timeafter.go

    go tool pprof -http=:8081 http://localhost:8080/debug/pprof/heap,

    本机运行一段时间后比较卡,也说明程序有问题。可以在运行一段时间后关掉运行的 Go 程序,避免电脑卡死。

    用pprof分析问题代码

    再来看看哪段代码内存使用最高,还是用 pprof 来查看,浏览 http://localhost:8081/ui/source,

    如果不强行关闭运行程序,这里内存还会往上涨。

    在程序中加了 for 循环,for 循环都会不断调用 select,而每次调用 select,都会重新初始化一个新的定时器 Timer(调用time.After,一直调用它就会一直申请和创建内存),这个新的定时器会增加到时间堆中等待触发,而定时器启动前,垃圾回收器不会回收 Timer(Go源码注释中有解释,也就是说 time.After 创建的内存资源需要等到定时器执行完后才被 GC 回收,一直增加内存 GC 却不回收,内存肯定会一直涨。

    当然,内存一直涨最重要原因的还是 for 循环里一直在申请和创建内存,其它是次要 。

    // https://github.com/golang/go/blob/go1.20.4/src/time/sleep.go#LL150C1-L158C2
    
    // After waits for the duration to elapse and then sends the current time
    // on the returned channel. 
    // It is equivalent to NewTimer(d.C.
    // The underlying Timer is not recovered by the garbage collector
    // until the timer fires. If efficiency is a concern, use NewTimer
    // instead and call Timer.Stop if the timer is no longer needed.
    func After(d Duration <-chan Time {
    	return NewTimer(d.C
    }
    // 在经过 d 时段后,会发送值到通道上,并返回通道。
    // 底层就是 NewTimer(d.C。
    // 定时器Timer启动前不会被垃圾回收器回收,定时器执行后才会被回收。
    // 如果担心效率问题,可以使用 NewTimer 代替,如果不需要定时器可以调用 Timer.Stop 停止定时器。
    

    在上面的程序中,time.After(time.Minute * 3 设置了 3 分钟,也就是说 3 分钟后才会执行定时器任务。而这期间会不断被 for 循环调用 time.After,导致它不断创建和申请内存,内存就会一直往上涨。

    解决问题

    既然是 for 循环一直调用 time.After 导致内存暴涨问题,那不循环调用 time.After 行不行?

    package main
    
    import (
    	"fmt"
    	"net/http"
    	_ "net/http/pprof"
    	"time"
    
    
    func main( {
    	fmt.Println("start..."
    	ch2 := make(chan string, 120
    
    	go func( {
    		// time.Sleep(time.Second * 1
    		i := 0
    		for {
    			i++
    			ch2 <- fmt.Sprintf("%s %d", "hello", i
    		}
    
    	}(
    
    	go func( {
    		// http 监听8080, 开启 pprof
    		if err := http.ListenAndServe(":8080", nil; err != nil {
    			fmt.Println("listen failed"
    		}
    	}(
    	// time.After 放到 for 外面
    	timeout := time.After(time.Minute * 3
    	for {
    		select {
    		case _ = <-ch2:
    			// fmt.Println(res
    		case <-timeout:
    			fmt.Println("timeout"
    			return
    		}
    	}
    }
    

    在终端上运行代码,go run timeafter1.go

    go tool pprof -http=:8081 http://localhost:8080/debug/pprof/heap,

    在 Go 的源码中 After 函数注释说了为了更有效率,可以使用 NewTimer,那我们使用这个函数来改造上面的代码,

    package main
    
    import (
    	"fmt"
    	"net/http"
    	_ "net/http/pprof"
    	"time"
    
    
    func main( {
    	fmt.Println("start..."
    	ch2 := make(chan string, 120
    
    	go func( {
    		// time.Sleep(time.Second * 1
    		i := 0
    		for {
    			i++
    			ch2 <- fmt.Sprintf("%s %d", "hello", i
    		}
    
    	}(
    
    	go func( {
    		// http 监听8080, 开启 pprof
    		if err := http.ListenAndServe(":8080", nil; err != nil {
    			fmt.Println("listen failed"
    		}
    	}(
    
    	duration := time.Minute * 2
    	timer := time.NewTimer(duration
    	defer timer.Stop(
    	for {
    		timer.Reset(duration // 这里加上 Reset(
    		select {
    		case _ = <-ch2:
    			// fmt.Println(res
    		case <-timer.C:
    			fmt.Println("timeout"
    			return
    		}
    	}
    }
    

    在上面的实现中,也把 NewTimer 放在循环外面,并且每次循环中都调用了 Reset 方法重置定时时间。

    go run timeafter1.go,然后多次运行 go tool pprof -http=:8081 http://localhost:8080/debug/pprof/heap,查看 pprof,我这里测试每次数据都是空白,说明程序正常运行。

    三、网上一些错误分析

    上面这种分析说明,最主要的还是没有说清楚内存暴涨的真正内因。如果用 pprof 的 source 分析查看,就一目了然,那就是 NewTimer 里的 2 个变量创建和申请内存导致的。

    四、参考

      https://pkg.go.dev/time#pkg-overview
    • https://github.com/golang/go/blob/go1.20.4/src/time/sleep.go
    • https://www.cnblogs.com/jiujuan/p/14588185.html pprof 基本使用
    • 《100 Go Mistakes and How to Avoid Them》 作者:Teiva Harsanyi

    编程笔记 » Go坑:time.After可能导致的内存泄露问题分析

    赞同 (29) or 分享 (0)
    游客 发表我的评论   换个身份
    取消评论

    表情
    (0)个小伙伴在吐槽