在 Go 语言中查找和修复内存泄漏
这篇文章回顾了我如何发现内存泄漏、如何修复它、如何修复 Google 示例 Go 代码中的类似问题,以及我们如何改进我们的库以防止将来出现此类问题。
Go 语言的 Google Cloud 客户端库通常在底层使用 gRPC 连接到 Google Cloud API。创建 API 客户端时,库会初始化与 API 的连接,然后保持该连接打开,直到您调用Close该库Client。
client, err := api.NewClient()
// Check err.
defer client.Close()
客户端可以安全地并发使用,所以你应该保留同一个客户端直到你不再使用它。但是,如果你应该删除客户端却Client没有删除,会发生什么呢?Close
你遇到了内存泄漏。底层连接始终无法清理。
Google 拥有大量 GitHub 自动化机器人,用于管理数百个 GitHub 代码库。我们的一些机器人通过运行在Cloud Run上的Go 服务器代理请求。我们的内存使用情况呈现出典型的锯齿状内存泄漏特征:
pprof.Index我首先通过在服务器上添加处理程序来开始调试:
mux.HandleFunc("/debug/pprof/", pprof.Index)
pprof提供运行时性能分析数据,例如内存使用情况。更多信息请参阅 Go 博客上的“Profileling Go Programs”一文。
然后,我在本地构建并启动了服务器:
$ 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:
client, err := secretmanager.NewClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to create secretmanager client: %v", err)
}
我们以前从未对每个请求都进行调用client.Close()并创建请求!所以,我添加了一个调用,问题就解决了:ClientClose
defer client.Close()
我提交了修复程序,它自动部署了,锯齿波立刻消失了!
哇哦!🎉🎉🎉
大约在同一时间,一位用户在我们的Cloud Go 示例仓库中提交了一个问题,该仓库包含了cloud.google.com文档的大部分 Go 示例。该用户注意到我们忘记在其中一个示例中添加 `<script>`Close标签了!Client
我之前也见过几次同样的情况,所以我决定调查整个代码库。
我首先粗略估计了受影响的文件数量。使用 ` getstyle()` 函数grep,我们可以获取包含样式调用的所有文件列表NewClient,然后将该列表传递给另一个 `getstyle()` 函数,grep以便仅列出不包含样式调用的文件Close,忽略测试文件:
$ grep -L Close $(grep -El 'New[^(]*Client' **/*.go) | grep -v test
糟糕!竟然有 207 个文件……作为参考, GoogleCloudPlatform/golang-samples.go仓库中大约有 1300 个文件。
考虑到问题的规模,我认为采用一些自动化手段来快速入门是值得的。我不想用 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仓库中的示例通常是什么样子会有所帮助(省略导入语句和客户端初始化之后的所有内容):
// 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。为此,您可以为搜索指定一个地址范围。sed
地址范围可以包含起始和结束模式,以便在应用后续命令之前进行匹配。在本例中,起始模式是 `<type>` /New[^(]*Client/,匹配NewClient`<type>` 调用;结束模式(用 `<t>` 分隔,)是/}/`<t>`,匹配下一个花括号。这意味着我们的搜索和替换操作只会应用于 `<type>` 调用NewClient和右花括号之间!
根据上面的错误处理模式,我们可以知道,if err != nil条件语句的右大括号正是我们要插入Close调用的地方。
自动编辑完所有样本后,我运行程序goimports修复格式。然后,我逐个检查了编辑后的文件,确保一切正常:
- 在服务器应用程序中,我们应该关闭客户端,还是应该保留客户端以备将来请求?
- 这个名字
Client真的是它本身的名字吗client?还是另有其他含义? - 是否有多个
Client?Close
完成这些操作后,我剩下180 个已编辑的文件。
最后一步是努力防止此类问题再次发生。我们目前想到几种方法:
- 更好的示例。见上文。
- 改进的 GoDoc。我们更新了库生成器,在生成的库中添加注释,以便
Close您Client在使用完毕后进行修改。请参阅https://github.com/googleapis/google-cloud-go/issues/3031。 - 更好的库。有没有办法实现
Close客户端自动化?终结器呢?有什么方法可以做得更好吗?请在https://github.com/googleapis/google-cloud-go/issues/4498告诉我们。
希望你对 Go 语言、内存泄漏、pprofgRPC 和 Bash 有了些了解。我很想听听你遇到的内存泄漏问题以及修复方法!如果你有任何关于如何改进我们的库或示例的想法,请提交 issue 告诉我们。

