Go 是一门带 GC 的语言,因此,大家很容易认为它不会有内存泄露问题。 大部分时候确实不会,但如果有些时候使用不注意,也会导致泄露。

本文案例来自谷歌云的代码,探讨如何找到并修复 Go 中的内存泄露。(确切来说是因为资源泄露导致的内存泄露,除了本文介绍的,还有一些其他泄露的情况)

这篇文章回顾了我如何发现内存泄漏、如何修复它,以及我如何修复 Google 示例 Go 代码中的类似问题,以及我们如何改进我们的库以防止将来发生这种情况。

Google Cloud Go 客户端库 [1] 通常在后台使用 gRPC 来连接 Google Cloud API。创建 API 客户端时,库会初始化与 API 的连接,然后保持该连接处于打开状态,直到你调用 Client.Close 。

客户端可以安全地同时使用,所以你应该保持相同 Client 直到你的任务完成。但是,如果在应该 Close 的时候不 Close client 会发生什么呢?

会出现内存泄漏。底层连接永远不会被清理。

Google 有一堆 GitHub 自动化机器人来帮助管理数百个 GitHub 存储库。我们的一些机器人通过在 Cloud Run [2] 上运行的 Go 服务器 [3] 代理它们的请求。我们的内存使用看起来像一个经典的锯齿形内存泄漏:

我通过向服务器添加 pprof.Index 处理程序开始调试:

`pprof` [4] 提供运行时 profiling 数据,如内存使用情况。有关更多信息,请参阅 Go 官方博客上的 profiling Go 程序 [5] 。

然后,我在本地构建并启动了服务器:

然后向服务器发送一些请求:

确切的有效负载和端点特定于我们的服务器,与本文无关。

为了获得正在使用的内存的基线,我收集了一些初始 pprof 数据:

检查输出,你可以看到一些内存使用情况,但没有什么会立即成为一个大问题(这很好!我们刚刚启动了服务器!):

下一步是向服务器发送一堆请求,看看我们是否可以 (1) 重现可能的内存泄漏和 (2) 确定泄漏是什么。

发送 500 个请求:

收集和分析更多 pprof 数据:

google.golang.org/grpc/internal/transport.newBufWriter 使用大量内存真的很突出!这是泄漏与什么相关的第一个迹象:gRPC。查看我们的应用程序源代码,我们唯一使用 gRPC 的地方是 Google Cloud Secret Manager [6] :

在每个请求创建 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 示例是什么样子有助于理解(省略导入和客户端初始化后的所有内容):

在高层次上,我们初始化客户端并检查是否有错误。每当你检查错误时,都会有一个右花括号 ( } )。我使用这些信息来自动化编辑。

但是,该 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] 。

最后一项工作是努力使其不再发生在用户身上。我们想到了几种方法:

  1. 更好的示例代码;
  2. 更好的 GoDoc。我们更新了库生成器,在生成库时加上注释,告知 client 需要调用 Close;
  3. 更好的库。有没有办法可以自动 Close 客户端?Finalizers?知道何能做得更好吗?欢迎在 https://github.com/googleapis/google-cloud-go/issues/4498 上交流;

我希望你对 Go、内存泄漏 pprof 、gRPC 和 Bash 有所了解。我很想听听你关于发现的内存泄漏以及修复它们的方法的故事!如果你对我们如何改进我们的 库 [14] 或 示例 [15] 有任何想法,请通过提交 issue 告诉我们。

参考资料

[5]
profiling Go 程序: https://go.dev/blog/pprof

[9]
cloud.google.com 上: https://cloud.google.com/