定位并修复 Go 中的内存泄露
Go 是一门带 GC 的语言,因此,大家很容易认为它不会有内存泄露问题。大部分时候确实不会,但如果有些时候使用不注意,也会导致泄露。
本文案例来自谷歌云的代码,探讨如何找到并修复 Go 中的内存泄露。(确切来说是因为资源泄露导致的内存泄露,除了本文介绍的,还有一些其他泄露的情况)
这篇文章回顾了我如何发现内存泄漏、如何修复它,以及我如何修复 Google 示例 Go 代码中的类似问题,以及我们如何改进我们的库以防止将来发生这种情况。
Google Cloud Go 客户端库[1] 通常在后台使用 gRPC 来连接 Google Cloud API。创建 API 客户端时,库会初始化与 API 的连接,然后保持该连接处于打开状态,直到你调用 Client.Close
。
client, err := api.NewClient()
// Check err.
defer client.Close()
客户端可以安全地同时使用,所以你应该保持相同Client
直到你的任务完成。但是,如果在应该 Close 的时候不 Close client 会发生什么呢?
会出现内存泄漏。底层连接永远不会被清理。
Google 有一堆 GitHub 自动化机器人来帮助管理数百个 GitHub 存储库。我们的一些机器人通过在 Cloud Run[2] 上运行的Go 服务器[3]代理它们的请求。我们的内存使用看起来像一个经典的锯齿形内存泄漏:
我通过向服务器添加 pprof.Index
处理程序开始调试:
mux.HandleFunc("/debug/pprof/", pprof.Index)
`pprof`[4]提供运行时 profiling 数据,如内存使用情况。有关更多信息,请参阅 Go 官方博客上的 profiling Go 程序[5]。
然后,我在本地构建并启动了服务器:
$ go build
$ PROJECT_ID=my-project PORT=8080 ./serverless-scheduler-proxy
然后向服务器发送一些请求:
for i in {1..5}; do
curl --header "Content-Type: application/json" --request POST --data '{"name": "HelloHTTP", "type": "testing", "location": "us-central1"}' localhost:8080/v0/cron
echo " -- $i"
done
确切的有效负载和端点特定于我们的服务器,与本文无关。
为了获得正在使用的内存的基线,我收集了一些初始pprof
数据:
curl http://localhost:8080/debug/pprof/heap > heap.0.pprof
检查输出,你可以看到一些内存使用情况,但没有什么会立即成为一个大问题(这很好!我们刚刚启动了服务器!):
$ go tool pprof heap.0.pprof
File: serverless-scheduler-proxy
Type: inuse_space
Time: May 4, 2021 at 9:33am (EDT)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top10
Showing nodes accounting for 2129.67kB, 100% of 2129.67kB total
Showing top 10 nodes out of 30
flat flat% sum% cum cum%
1089.33kB 51.15% 51.15% 1089.33kB 51.15% google.golang.org/grpc/internal/transport.newBufWriter (inline)
528.17kB 24.80% 75.95% 528.17kB 24.80% bufio.NewReaderSize (inline)
512.17kB 24.05% 100% 512.17kB 24.05% google.golang.org/grpc/metadata.Join
0 0% 100% 512.17kB 24.05% cloud.google.com/go/secretmanager/apiv1.(*Client).AccessSecretVersion
0 0% 100% 512.17kB 24.05% cloud.google.com/go/secretmanager/apiv1.(*Client).AccessSecretVersion.func1
0 0% 100% 512.17kB 24.05% github.com/googleapis/gax-go/v2.Invoke
0 0% 100% 512.17kB 24.05% github.com/googleapis/gax-go/v2.invoke
0 0% 100% 512.17kB 24.05% google.golang.org/genproto/googleapis/cloud/secretmanager/v1.(*secretManagerServiceClient).AccessSecretVersion
0 0% 100% 512.17kB 24.05% google.golang.org/grpc.(*ClientConn).Invoke
0 0% 100% 1617.50kB 75.95% google.golang.org/grpc.(*addrConn).createTransport
下一步是向服务器发送一堆请求,看看我们是否可以 (1) 重现可能的内存泄漏和 (2) 确定泄漏是什么。
发送 500 个请求:
for i in {1..500}; do
curl --header "Content-Type: application/json" --request POST --data '{"name": "HelloHTTP", "type": "testing", "location": "us-central1"}' localhost:8080/v0/cron
echo " -- $i"
done
收集和分析更多pprof
数据:
$ curl http://localhost:8080/debug/pprof/heap > heap.6.pprof
$ go tool pprof heap.6.pprof
File: serverless-scheduler-proxy
Type: inuse_space
Time: May 4, 2021 at 9:50am (EDT)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top10
Showing nodes accounting for 94.74MB, 94.49% of 100.26MB total
Dropped 26 nodes (cum <= 0.50MB)
Showing top 10 nodes out of 101
flat flat% sum% cum cum%
51.59MB 51.46% 51.46% 51.59MB 51.46% google.golang.org/grpc/internal/transport.newBufWriter
19.60MB 19.55% 71.01% 19.60MB 19.55% bufio.NewReaderSize
6.02MB 6.01% 77.02% 6.02MB 6.01% bytes.makeSlice
4.51MB 4.50% 81.52% 10.53MB 10.51% crypto/tls.(*Conn).readHandshake
4MB 3.99% 85.51% 4.50MB 4.49% crypto/x509.parseCertificate
3MB 2.99% 88.51% 3MB 2.99% crypto/tls.Client
2.50MB 2.49% 91.00% 2.50MB 2.49% golang.org/x/net/http2/hpack.(*headerFieldTable).addEntry
1.50MB 1.50% 92.50% 1.50MB 1.50% google.golang.org/grpc/internal/grpcsync.NewEvent
1MB 1% 93.50% 1MB 1% runtime.malg
1MB 1% 94.49% 1MB 1% encoding/json.(*decodeState).literalStore
google.golang.org/grpc/internal/transport.newBufWriter
使用大量内存真的很突出!这是泄漏与什么相关的第一个迹象:gRPC。查看我们的应用程序源代码,我们唯一使用 gRPC 的地方是 Google Cloud Secret Manager[6]:
client, err := secretmanager.NewClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to create secretmanager client: %v", err)
}
在每个请求创建 client
时,我们没有调用 client.Close()
!所以,我添加了一个Close
调用,问题就消失了:
defer client.Close()
我提交了修复,然后自动部署[7],锯齿立即消失了!
大约在同一时间,用户在我们的 Cloud 的 Go 示例存储库中[8]提交了一个问题,其中包含 cloud.google.com 上[9] 文档的大部分 Go 示例。用户注意到我们忘记调用 client.Close
了。
我曾多次看到同样的事情出现,所以我决定调查整个 repo。
我开始粗略估计有多少受影响的文件。使用grep
,我们可以获得包含NewClient
样式调用的所有文件的列表,然后将该列表传递给另一个调用grep
以仅列出不包含 Close
的文件,同时忽略测试文件:
$ grep -L Close $(grep -El 'New[^(]*Client' **/*.go) | grep -v test
竟然有 207 个文件……就上下文而言,我们 .go
在 GoogleCloudPlatform/golang-samples[10] 存储库中有大约 1300 个文件。
考虑到问题的规模,我认为一些自动化是值得的[11]。我不想写一个完整的 Go 程序来编辑文件,所以我使用 Bash:
$ grep -L Close $(grep -El 'New[^(]*Client' **/*.go) | grep -v test | xargs sed -i '/New[^(]*Client/,/}/s/}/}\ndefer client.Close()/'
它是完美的吗?不。它对工作量有很大的影响吗?是的!
第一部分(直到test
)与上面完全相同——获取所有可能受影响的文件的列表(那些似乎创建了Client
但从没调用 Close
的文件)。
然后,我将该文件列表传递给sed
进行实际编辑。xargs
调用你给它的命令,每一行都以 stdin
作为参数传递给给定的命令。
要理解该sed
命令,查看 golang-samples
repo 示例是什么样子有助于理解(省略导入和客户端初始化后的所有内容):
// accessSecretVersion accesses the payload for the given secret version if one
// exists. The version can be a version number as a string (e.g. "5") or an
// alias (e.g. "latest").
func accessSecretVersion(w io.Writer, name string) error {
// name := "projects/my-project/secrets/my-secret/versions/5"
// name := "projects/my-project/secrets/my-secret/versions/latest"
// Create the client.
ctx := context.Background()
client, err := secretmanager.NewClient(ctx)
if err != nil {
return fmt.Errorf("failed to create secretmanager client: %v", err)
}
// ...
}
在高层次上,我们初始化客户端并检查是否有错误。每当你检查错误时,都会有一个右花括号 ( }
)。我使用这些信息来自动化编辑。
但是,该sed
命令仍然很笨拙:
sed -i '/New[^(]*Client/,/}/s/}/}\ndefer client.Close()/'
-i
表示直接编辑文件。这不是问题,因为代码用 git 管理了。
接下来,我使用s
命令在检查错误defer client.Close()
后假定的右花括号 ( }
)之后插入。
但是,我不想替换每个 }
,我只想要在调用NewClient
后的第一个。要做到这一点,你可以给一个地址范围[12]的sed
搜索。
地址范围可以包括在应用接下来的任何命令之前要匹配的开始和结束模式。在这种情况下,开始是/New[^(]*Client/
,匹配NewClient
类型调用,结束(由 a 分隔,
)是/}/
,匹配下一个大括号。这意味着我们的搜索和替换仅适用于调用NewClient
和结束大括号之间!
通过了解上面的错误处理模式,if err != nil
条件的右大括号正是我们想要插入Close
调用的位置。
一旦我自动编辑了所有示例文件,我用goimports
开始修复格式。然后,我检查了每个编辑过的文件,以确保它做了正确的事情:
在服务器应用程序中,我们应该关闭客户端,还是应该保留它以备将来的请求使用? 是 Client
实际的名字client
还是别的什么?是否有一个以上的 Client
调用了Close
?
完成后,只剩下180 个已编辑的文件[13]。
最后一项工作是努力使其不再发生在用户身上。我们想到了几种方法:
更好的示例代码; 更好的 GoDoc。我们更新了库生成器,在生成库时加上注释,告知 client 需要调用 Close; 更好的库。有没有办法可以自动 Close
客户端?Finalizers?知道何能做得更好吗?欢迎在 https://github.com/googleapis/google-cloud-go/issues/4498 上交流;
我希望你对 Go、内存泄漏pprof
、gRPC 和 Bash 有所了解。我很想听听你关于发现的内存泄漏以及修复它们的方法的故事!如果你对我们如何改进我们的库[14]或示例[15]有任何想法,请通过提交 issue 告诉我们。
原文链接:https://dev.to/googlecloud/finding-and-fixing-memory-leaks-in-go-1k1h
参考资料
Google Cloud Go 客户端库: https://github.com/googleapis/google-cloud-go
[2]Cloud Run: https://cloud.google.com/run/docs/quickstarts/build-and-deploy/go
[3]Go 服务器: https://github.com/googleapis/repo-automation-bots/tree/main/serverless-scheduler-proxy
[4]pprof
: https://pkg.go.dev/net/http/pprof
profiling Go 程序: https://go.dev/blog/pprof
[6]Google Cloud Secret Manager: https://cloud.google.com/secret-manager/docs/quickstart
[7]自动部署: https://cloud.google.com/build/docs/deploying-builds/deploy-cloud-run
[8]Cloud 的 Go 示例存储库中: https://github.com/GoogleCloudPlatform/golang-samples
[9]cloud.google.com 上: https://cloud.google.com/
[10]GoogleCloudPlatform/golang-samples: https://github.com/GoogleCloudPlatform/golang-samples
[11]值得的: https://xkcd.com/1205/
[12]地址范围: https://www.gnu.org/software/sed/manual/html_node/Addresses.html
[13]180 个已编辑的文件: https://github.com/GoogleCloudPlatform/golang-samples/pull/2080
[14]库: https://github.com/googleapis/google-cloud-go
[15]示例: https://github.com/GoogleCloudPlatform/golang-samples
我是 polarisxu,北大硕士毕业,曾在 360 等知名互联网公司工作,10多年技术研发与架构经验!2012 年接触 Go 语言并创建了 Go 语言中文网!著有《Go语言编程之旅》、开源图书《Go语言标准库》等。
坚持输出技术(包括 Go、Rust 等技术)、职场心得和创业感悟!欢迎关注「polarisxu」一起成长!也欢迎加我微信好友交流:gopherstudio