如何使用 Grafana Pyroscope 解決 Go 中的內存泄漏問題

內存泄漏在任何編程語言中都可能是一個重大問題,Go 也不例外。儘管是一種垃圾收集語言,Go 仍然容易受到內存泄漏的影響,這可能導致性能下降並導致操作系統內存不足。

爲了保護自己,Linux 操作系統實施了一個內存不足 (OOM) killer,它可以識別並終止消耗過多內存並導致系統變得無響應的進程。

在這篇博文中,我們將探討 Go 中內存泄漏的最常見原因,並演示如何使用 Grafana Pyroscope(一種開源的持續分析解決方案)來查找和修復這些泄漏。

傳統的可觀測性

內存泄漏通常通過監視程序或系統隨時間的內存使用情況來檢測。

通過 Grafana Cloud 查找內存泄漏原因

如今,系統的複雜性使得很難縮小代碼中發生內存泄漏的位置。但是,這些泄漏可能會導致重大問題:

由於這些原因,儘快檢測並修復它們很重要。

Go 內存泄漏的常見原因

開發人員通常會因未能正確關閉資源或避免無限制地創建資源而造成內存泄漏,這也適用於 Goroutines。Goroutines 可以被視爲一種資源,因爲它們會消耗系統資源(例如內存和 CPU 時間),如果管理不當,可能會導致內存泄漏。

Goroutines 是由 Go 運行時管理的輕量級執行線程,它們可以在 Go 程序執行期間動態創建和銷燬。

在 Go 中,理論上您可以創建無限數量的 goroutine,因爲 Go 運行時可以創建和管理數百萬個 goroutine,而不會產生顯着的性能開銷,實際限制取決於可用的系統資源,例如內存、CPU 和 I/O 資源。

開發人員在使用 goroutines 時常犯的一個錯誤是在沒有正確管理它們的生命週期的情況下創建了太多 goroutines。這可能會導致內存泄漏,因爲未使用的 goroutine 可能會繼續消耗系統資源,即使不再需要它們之後也是如此。

如果一個 goroutine 被創建並且從未終止,它可以繼續無限期地執行並在內存中保存對對象的引用,防止它們被垃圾收集。這可能會導致程序的內存使用量隨着時間的推移而增長,從而可能導致內存泄漏。

在您希望並行化單個 HTTP 請求完成的工作的情況下,創建多個 goroutine 並將工作分派給它們是合法的。

package main

import (
 "log"
 "net/http"
 "time"

 _ "net/http/pprof"
)

func main() {
 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
  responses := make(chan []byte)
  go longRunningTask(responses)
           // do some other tasks in parallel
 })
 log.Fatal(http.ListenAndServe(":8081", nil))
}

func longRunningTask(responses chan []byte) {
 // fetch data from database
 res := make([]byte, 100000)
 time.Sleep(500 * time.Millisecond)
 responses <- res
}

上面的簡單代碼顯示了一個從並行連接到數據庫的 HTTP 服務器泄漏 goroutines 和內存的示例。由於 HTTP 請求不等待響應通道,因此 longRunningTask 會永遠阻塞,本質上會泄漏 goroutine 和使用它創建的資源。

爲防止這種情況,重要的是確保所有 goroutine 在不再需要時被正確終止。這可以使用各種技術來完成,例如使用通道在 goroutine 應該退出時發出信號,使用上下文將取消信號傳播到 goroutine,以及使用 sync.WaitGroupgoroutine 確保所有邏輯在退出程序之前都已完成。

爲了避免無限制地創建 goroutines,我還建議使用工作池。當應用程序承受壓力時,創建過多的 goroutine 會導致性能不佳,因爲 Go 運行時必須管理它們的生命週期。

Go 中 goroutines 和資源泄漏的另一個常見表現是沒有正確釋放 Timer 或 Ticker。關於該 time.After 函數的 Go 文檔實際上暗示了這種可能性:

“在等待持續時間過去之後,然後在返回的通道上發送當前時間。它等同於 NewTimer(d).C。在計時器觸發之前,垃圾收集器不會回收底層計時器。如果效率是一個問題,請改用 NewTimer 並在不再需要計時器時調用 Timer.Stop。”

建議您在 goroutine 中使用它們時始終堅持使用 timer.NewTimer 和 timer.NewTicker,以便您可以在請求結束時正確釋放資源。

如何使用 Pyroscope 查找和修復內存泄漏

連續分析可能是查找內存泄漏的有用方法,特別是在內存泄漏發生時間較長或發生速度太快而無法手動觀察的情況下。連續分析涉及定期對程序的內存和 goroutines 使用情況隨時間進行採樣,以識別可能表明內存泄漏的模式和異常。

通過分析 goroutine 和內存採樣文件,您可以識別 Go 應用程序中的內存泄漏。以下是使用 Pyroscope 執行此操作的步驟。

(注意:雖然這篇博文主要關注 Go,但 Pyroscope 也支持其他語言的內存分析。)

第 1 步:確定內存泄漏的來源

假設您已經進行了監控,第一步是使用日誌、指標或跟蹤找出系統的哪個部分出現問題。

這可以通過多種方式體現:

  1. 應用程序或 Kubernetes 記錄重啓。

  2. 應用程序或主機內存使用情況。

  3. SLO 違反示例跟蹤。

一旦確定了系統的部分和開始分析時間,就可以使用連續的分析來確定有問題的功能。

第 2 步:將 Pyroscope 與您的應用程序集成

要開始分析 Go 應用程序,您需要在您的應用程序中包含我們的 Go 模塊:

go get github.com/pyroscope-io/client/pyroscope

然後將以下代碼添加到您的應用程序中:

package main

import "github.com/pyroscope-io/client/pyroscope"

func main() {
    // These 2 lines are only required if you're using mutex or block profiling
    // Read the explanation below for how to set these rates:
    runtime.SetMutexProfileFraction(5)
    runtime.SetBlockProfileRate(5)

    pyroscope.Start(pyroscope.Config {
        ApplicationName: "simple.golang.app",

        // replace this with the address of pyroscope server
        ServerAddress: "http://pyroscope-server:4040",

        // you can disable logging by setting this to nil
        Logger: pyroscope.StandardLogger,

        // optionally, if authentication is enabled, specify the API key:
        // AuthToken:    os.Getenv("PYROSCOPE_AUTH_TOKEN"),

        // you can provide static tags via a map:
        Tags: map[string] string {
            "hostname": os.Getenv("HOSTNAME")
        },

        ProfileTypes: [] pyroscope.ProfileType {
            // these profile types are enabled by default:
            pyroscope.ProfileCPU,
                pyroscope.ProfileAllocObjects,
                pyroscope.ProfileAllocSpace,
                pyroscope.ProfileInuseObjects,
                pyroscope.ProfileInuseSpace,

                // these profile types are optional:
                pyroscope.ProfileGoroutines,
                pyroscope.ProfileMutexCount,
                pyroscope.ProfileMutexDuration,
                pyroscope.ProfileBlockCount,
                pyroscope.ProfileBlockDuration,
        },
    })

    // your code goes here
}

第 3 步:深入瞭解關聯的配置文件

建議你先看看 goroutines 隨時間推移,看看是否有任何問題引起關注,然後切換到內存調查。

在這種情況下,很明顯是我們的 longRunningTask 問題,我們可能應該看看這個。但在現實生活中,您必須探索並將您在火焰圖上看到的內容與您對應用程序的期望聯繫起來。

有趣的是,火焰圖中的 goroutine 堆棧跟蹤實際上顯示了函數的當前狀態——在我們的示例中,它被阻止發送到通道。

對於內存,採樣文件會向您顯示已分配內存的函數以及分配了多少內存,但不會顯示誰在保留它。只能由您來找出代碼中錯誤保留內存的位置。

第四步:通過測試確認和預防。

假設您現在已經確定問題出在哪裏,您可能想着手修復它——但我建議您首先編寫一個測試來複現問題。

這樣您就可以避免讓其他工程師再次犯同樣的錯誤。既然你確定你確實找到了問題,你就會有一個可靠的反饋循環來證明它確實已經解決了。

Go 有一個強大的測試框架,您可以使用它來編寫基準測試或測試來重現您的場景。

在基準測試期間,您甚至可以利用 - benchmem

go test -bench=. -benchmem

要輸出內存分配,您還可以根據需要使用 runtime.ReadMemStats 編寫一些自定義邏輯。

您還可以使用 goleak 包驗證執行後沒有 goroutines 泄漏。

func TestA(t *testing.T) {
 defer goleak.VerifyNone(t)

 // test logic here.
}

第 5 步:修復內存泄漏

現在您可以重現並理解您的問題,是時候迭代修復並部署它進行測試了。您可以再次利用持續分析來監控您的更改並確認您的期望。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ObKG19MBu2LgkPe26dz4fQ