go timer 洩漏

redrobot發表於2024-11-12

https://www.cnblogs.com/jiujuan/p/17369964.html

一、Time 包中定時器函式#

go v1.20.4

定時函式:NewTicker,NewTimer 和 time.After 介紹#

time 包中有 3 個比較常用的定時函式:NewTicker,NewTimer 和 time.After:

  • NewTimer: 表示在一段時間後才執行,預設情況下執行一次。如果想再次執行,需要呼叫 time.Reset() 方法,這時類似於 NewTicker 定時器了。可以呼叫 stop 方法停止執行。
Copy
  func NewTimer(d Duration) *Timer
  // NewTimer 建立一個新的 Timer,它將至少持續時間 d 之後,在向通道中傳送當前時間
  // d 表示間隔時間
  
 type Timer struct {
  	C <-chan Time
	r runtimeTimer
  }

重置 NewTimer 定時器的 Reset() 方法,它是定時器在持續時間 d 到期後,用這個方法重置定時器讓它再一次執行,如果定時器被啟用返回 true,如果定時器已過期或停止,在返回 false。

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

如果程式已經從 t.C 接收到了一個值,則已知定時器已過期且通道值已取空,可以直接呼叫 time.Reset 方法;

如果程式尚未從 t.C 接收到值,則要先停止定時器 t.Stop(),再從 t.C 中取出值,最後呼叫 time.Reset 方法。

綜合上面 2 種情況,正確使用 time.Reset 方法就是:

Copy
if !t.Stop() {
	<-t.C
}
t.Reset(d)
  • Stop 方法
Copy
func (t *Timer) Stop() bool
// 如果定時器已經過期或停止,返回 false,否則返回 true

Stop 方法能夠阻止定時器觸發,但是它不會關閉通道,這是為了防止從通道中錯誤的讀取值。

為了確保呼叫 Stop 方法後通道為空,需要檢查 Stop 方法的返回值並把通道中的值清空,如下:

Copy
if !t.Stop() {
 <-t.C
}
  • NewTicker: 表示每隔一段時間執行一次,可以執行多次。可以呼叫 stop 方法停止執行。

    Copy
    func NewTicker(d Duration) *Ticker
    

    NewTicker 返回一個 Ticker,這個 Ticker 包含一個時間的通道,每次重置後會傳送一個當前時間到這個通道上。

    d 表示每一次執行間隔的時間。

  • time.After: 表示在一段時間後執行。其實它內部呼叫的就是 time.Timer 。

    Copy
    func After(d Duration) <-chan Time
    

​ 跟它還有一個相似的函式 time.AfterFunc,後面執行的是一個函式。

NewTicker 程式碼例子:

Copy
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 訊息,看下面原始碼就清楚返回值型別:

Copy
// 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 實現。

一般可以用來實現超時檢測:

Copy
package main

import (
	"fmt"
	"time"
)

func main() {
	ch1 := make(chan string, 1)

	go func() {
		time.Sleep(time.Second * 2)
		ch1 <- "hello"
	}()

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

有問題程式碼#

上面的程式碼執行是沒有什麼問題的,不會導致記憶體洩露。

那問題會出在什麼地方?

在有些情況下,select 需要配合 for 不斷檢測通道情況,問題就有可能出在 for 迴圈這裡。

修改上面的程式碼,加上 for + select,為了能顯示的看出問題,加上 pprof + http 程式碼,

timeafter.go:

Copy
package main

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

func main() {
	fmt.Println("start...")
	ch1 := make(chan string, 120)

	go func() {
		// time.Sleep(time.Second * 1)
		i := 0
		for {
			i++
			ch1 <- 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 _ = <-ch1:
			// 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

執行之後它會自動在瀏覽器上彈出 pprof 的瀏覽介面,http://localhost:8081/ui/

本機執行一段時間後比較卡,也說明程式有問題。可以在執行一段時間後關掉執行的 Go 程式,避免電腦卡死。

用pprof分析問題程式碼#

在瀏覽器上檢視 pprof 圖,http://localhost:8081/ui/

image-20230503221355903

從上圖可以看出,記憶體使用暴漲(不關掉程式還會繼續漲)。而且暴漲的記憶體集中在 time.After 上,上面分析了 time.After 實質呼叫的就是 time.NewTimer,從圖中也可以看出。它呼叫 time.NewTimer 不斷建立和申請記憶體,何以看出這個?繼續看下面分析,

再來看看哪段程式碼記憶體使用最高,還是用 pprof 來檢視,瀏覽 http://localhost:8081/ui/source

timeafter.go

image-20230503221853968

上面呼叫的 Go 原始碼 NewTimer,

image-20230503222220479

image-20230503222531086

從上圖資料分析可以看出最佔用記憶體的那部分程式碼,src/time/sleep.go/NewTimer 裡的 c 和 t 分配和申請記憶體,最佔用記憶體。

如果不強行關閉執行程式,這裡記憶體還會往上漲。

為什麼會出現記憶體一直漲呢?

在程式中加了 for 迴圈,for 迴圈都會不斷呼叫 select,而每次呼叫 select,都會重新初始化一個新的定時器 Timer(呼叫time.After,一直呼叫它就會一直申請和建立記憶體),這個新的定時器會增加到時間堆中等待觸發,而定時器啟動前,垃圾回收器不會回收 Timer(Go原始碼註釋中有解釋),也就是說 time.After 建立的記憶體資源需要等到定時器執行完後才被 GC 回收,一直增加記憶體 GC 卻不回收,記憶體肯定會一直漲。

當然,記憶體一直漲最重要原因還是 for 迴圈裡一直在申請和建立記憶體,其它是次要 。

Copy
// 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 行不行?

修改後的程式碼如下:

Copy
package main

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

func main() {
	fmt.Println("start...")
	ch1 := make(chan string, 120)

	go func() {
		// time.Sleep(time.Second * 1)
		i := 0
		for {
			i++
			ch1 <- 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 _ = <-ch1:
			// fmt.Println(res)
		case <-timeout:
			fmt.Println("timeout")
			return
		}
	}
}

在終端上執行程式碼,go run timeafter1.go

等待半分鐘左右,在另外一個終端上執行 go tool pprof -http=:8081 http://localhost:8080/debug/pprof/heap

自動在瀏覽器上彈出介面 http://localhost:8081/ui/ ,我這裡測試,介面沒有任何資料顯示,說明修改後的程式執行良好。

在 Go 的原始碼中 After 函式註釋說了為了更有效率,可以使用 NewTimer ,那我們使用這個函式來改造上面的程式碼,

Copy
package main

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

func main() {
	fmt.Println("start...")
	ch1 := make(chan string, 120)

	go func() {
		// time.Sleep(time.Second * 1)
		i := 0
		for {
			i++
			ch1 <- 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 _ = <-ch1:
			// 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,我這裡測試每次資料都是空白,說明程式正常執行。

三、網上一些錯誤分析#

for迴圈每次select的時候,都會例項化一個一個新的定時器。該定時器在多少分鐘後,才會被啟用,但是啟用後已經跟select無引用關係,被gc給清理掉。換句話說,被遺棄的time.After定時任務還是在時間堆裡面,定時任務未到期之前,是不會被gc清理的

上面這種分析說明,最主要的還是沒有說清楚記憶體暴漲的真正內因。如果用 pprof 的 source 分析檢視,就一目瞭然,那就是 NewTimer 裡的 2 個變數建立和申請記憶體導致的。


也歡迎到我的公眾號 【九卷技術錄】 Go坑:time.After可能導致的記憶體洩露問題分析 討論

四、參考#

  • 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 1.23 版本在北京時間 2024 年 8 月 14 日凌晨 1:03 釋出

https://juejin.cn/post/7405531068662759434

https://juejin.cn/post/7405531068662759434

在深入探討 Go 1.23 版本對 TimerTicker 定時器進行的最佳化之前,有的讀者可能需要了解這兩種定時器的基礎知識。以下是關於這兩種定時器的基本介紹:

  • Timer 是一個一次性的定時器,用於在未來的某一時刻執行一次操作。常用於單次延遲執行任務

  • Tciker 是一個週期性的定時器,用於在固定的時間間隔重複執行任務。它在每個間隔時間到來時,向其通道(Channel)傳送當前時間。常用於重複執行任務

更多關於 TimerTicker 的詳細介紹,可以參考我之前的文章:Go 定時器:Timer 和 Ticker

垃圾回收的改進

  • Go 1.23 之前的行為: 如果一個 TimerTicker 沒有被顯式呼叫 Stop 方法,即使程式不再引用它們,它們也不會立即被垃圾回收。Timer 會在觸發後被回收,而 Ticker 則從來不會被自動回收。
  • Go 1.23 新行為: 如果程式不再引用一個 TimerTicker(即沒有其他部分的程式碼持有它們的引用),即使沒有呼叫 Stop 方法,它們也會有資格立即被垃圾回收。這可以減少記憶體洩漏的風險,因為不再需要顯式呼叫 Stop 也可以保證資源會被回收。

這一更新提高了記憶體管理效率。以前,如果你建立了一個 TimerTicker,但忘記呼叫 Stop,這些物件會一直佔用記憶體,直到程式結束。而現在,只要程式不再引用這些物件,它們就會被回收,這樣可以避免記憶體洩漏的問題。

計時器通道行為的變化

  • Go 1.23 之前的行為:TimerTicker 關聯的通道帶有一個元素緩衝區,這導致 ResetStop 方法在呼叫後,可能仍會接收到之前準備好的舊值,造成使用上的困難。
  • Go 1.23 新行為: 計時器通道變成了無緩衝的(容量為 0)。這意味著在呼叫 ResetStop 方法後,Go 可以保證不會再接收到舊的值。這使得 ResetStop 的使用更加可靠。
  • 副作用: 由於通道現在是無緩衝的,lencap 操作返回的值變成了 0,而不是 1。這可能會影響那些依賴輪詢通道長度來判斷是否能成功接收值的程式碼。為了適應這種變化,程式碼應該使用 非阻塞 的接收操作來替代。

這一更新讓定時器操作更加可靠和安全。 在 Go 1.23 之前,TimerTicker 的通道是有緩衝的,這意味著即使你呼叫了 ResetStop,通道中仍可能殘留舊的定時訊號,這會導致潛在的競態條件問題。現在改為無緩衝通道後,Go 保證了呼叫 ResetStop 後,通道不會再收到舊的資料


作者:陳明勇
連結:https://juejin.cn/post/7405531068662759434
來源:稀土掘金
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。

相關文章