内存泄漏在任何编程语言中都可能是一个重大问题,Go 也不例外。尽管是一种垃圾收集语言,Go 仍然容易受到内存泄漏的影响,这可能导致性能下降并导致操作系统内存不足。
为了保护自己,Linux 操作系统实施了一个内存不足 (OOM) killer,它可以识别并终止消耗过多内存并导致系统变得无响应的进程。
在这篇博文中,我们将探讨 Go 中内存泄漏的最常见原因,并演示如何使用Grafana Pyroscope(一种开源的持续分析解决方案)来查找和修复这些泄漏。
内存泄漏通常通过监视程序或系统随时间的内存使用情况来检测。
通过Grafana Cloud查找内存泄漏原因如今,系统的复杂性使得很难缩小代码中发生内存泄漏的位置。但是,这些泄漏可能会导致重大问题:
性能下降。随着内存泄漏的发生,系统的可用内存越来越少,这会导致程序变慢或崩溃,从而导致性能下降。
系统不稳定。如果内存泄漏足够严重,可能会导致整个系统变得不稳定,从而导致崩溃或其他系统故障。
增加成本和资源使用。随着内存泄漏的发生,系统可能会使用更多的资源来管理内存,这会降低系统资源对其他程序的总体可用性。
由于这些原因,尽快检测并修复它们很重要。
开发人员通常会因未能正确关闭资源或避免无限制地创建资源而造成内存泄漏,这也适用于 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,以便您可以在请求结束时正确释放资源。
连续分析可能是查找内存泄漏的有用方法,特别是在内存泄漏发生时间较长或发生速度太快而无法手动观察的情况下。连续分析涉及定期对程序的内存和 goroutines 使用情况随时间进行采样,以识别可能表明内存泄漏的模式和异常。
通过分析 goroutine 和内存采样文件,您可以识别 Go 应用程序中的内存泄漏。以下是使用 Pyroscope 执行此操作的步骤。
(注意:虽然这篇博文主要关注 Go,但 Pyroscope 也支持其他语言的内存分析。)
假设您已经进行了监控,第一步是使用日志、指标或跟踪找出系统的哪个部分出现问题。
这可以通过多种方式体现:
应用程序或 Kubernetes 记录重启。
应用程序或主机内存使用情况。
SLO 违反示例跟踪。
一旦确定了系统的部分和开始分析时间,就可以使用连续的分析来确定有问题的功能。
要开始分析 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
}
建议你先看看 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.
}
现在您可以重现并理解您的问题,是时候迭代修复并部署它进行测试了。您可以再次利用持续分析来监控您的更改并确认您的期望。