发布于 2026-01-06 0 阅读
0

在 Go 语言中查找和修复内存泄漏

在 Go 语言中查找和修复内存泄漏

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

Go 语言的 Google Cloud 客户端库通常在底层使用 gRPC 连接到 Google Cloud API。创建 API 客户端时,库会初始化与 API 的连接,然后保持该连接打开,直到您调用Close该库Client



client, err := api.NewClient()
// Check err.
defer client.Close()


Enter fullscreen mode Exit fullscreen mode

客户端可以安全地并发使用,所以你应该保留同一个客户端直到你不再使用它。但是,如果你应该删除客户端却Client没有删除,会发生什么呢?Close

你遇到了内存泄漏。底层连接始终无法清理。


Google 拥有大量 GitHub 自动化机器人,用于管理数百个 GitHub 代码库。我们的一些机器人通过运行在Cloud Run上的Go 服务器代理请求。我们的内存使用情况呈现出典型的锯齿状内存泄漏特征:

pprof.Index我首先通过在服务器上添加处理程序来开始调试:



mux.HandleFunc("/debug/pprof/", pprof.Index)


Enter fullscreen mode Exit fullscreen mode

pprof提供运行时性能分析数据,例如内存使用情况。更多信息请参阅 Go 博客上的“Profileling Go Programs”一文。

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



$ go build
$ PROJECT_ID=my-project PORT=8080 ./serverless-scheduler-proxy


Enter fullscreen mode Exit fullscreen mode

接下来,我向服务器发送了一些请求:



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


Enter fullscreen mode Exit fullscreen mode

具体的有效载荷和端点是针对我们服务器的,与本文无关。

为了了解内存使用情况的基线,我收集了一些初始pprof数据:



curl http://localhost:8080/debug/pprof/heap > heap.0.pprof


Enter fullscreen mode Exit fullscreen mode

检查输出结果,可以看到一些内存使用情况,但目前为止没有发现任何明显的严重问题(这是好事!我们才刚刚启动服务器!):



$ 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


Enter fullscreen mode Exit fullscreen mode

下一步是向服务器发送大量请求,看看我们是否可以(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


Enter fullscreen mode Exit fullscreen mode

收集和分析更多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


Enter fullscreen mode Exit fullscreen mode

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) 
}


Enter fullscreen mode Exit fullscreen mode

我们以前从未对每个请求都进行调用client.Close()并创建请求!所以,我添加了一个调用,问题就解决了:ClientClose



defer client.Close()


Enter fullscreen mode Exit fullscreen mode

我提交了修复程序,它自动部署了,锯齿波立刻消失了!

哇哦!🎉🎉🎉


大约在同一时间,一位用户在我们的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


Enter fullscreen mode Exit fullscreen mode

糟糕!竟然有 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()/'


Enter fullscreen mode Exit fullscreen mode

它完美吗?不。它大大减轻了工作量吗?是的!

第一部分(直到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)
    }
    // ...
}


Enter fullscreen mode Exit fullscreen mode

从总体上看,我们会初始化客户端并检查是否存在错误。每当检查错误时,都会出现一个右大括号 ( })。我利用这一信息实现了自动编辑。

不过,这条sed命令仍然很棘手:



sed -i '/New[^(]*Client/,/}/s/}/}\ndefer client.Close()/'


Enter fullscreen mode Exit fullscreen mode

-i说要直接编辑文件。我没问题,因为git这样万一我搞砸了,它还能帮我补救。

接下来,我使用该命令在检查错误时假定的右大括号 ( ) 之后s插入内容。defer client.Close()}

但是,我不想替换每一个 },我只想替换调用后的第一个NewClient。为此,您可以搜索指定一个地址范围。sed

地址范围可以包含起始和结束模式,以便在应用后续命令之前进行匹配。在本例中,起始模式是 `<type>` /New[^(]*Client/,匹配NewClient`<type>` 调用;结束模式(用 `<t>` 分隔,)是/}/`<t>`,匹配下一个花括号。这意味着我们的搜索和替换操作只会应用于 `<type>` 调用NewClient和右花括号之间!

根据上面的错误处理模式,我们可以知道,if err != nil条件语句的右大括号正是我们要插入Close调用的地方。


自动编辑完所有样本后,我运行程序goimports修复格式。然后,我逐个检查了编辑后的文件,确保一切正常:

  • 在服务器应用程序中,我们应该关闭客户端,还是应该保留客户端以备将来请求?
  • 这个名字Client真的是它本身的名字吗client?还是另有其他含义?
  • 是否有多个ClientClose

完成这些操作后,我剩下180 个已编辑的文件


最后一步是努力防止此类问题再次发生。我们目前想到几种方法:

  1. 更好的示例。见上文。
  2. 改进的 GoDoc。我们更新了库生成器,在生成的库中添加注释,以便CloseClient在使用完毕后进行修改。请参阅https://github.com/googleapis/google-cloud-go/issues/3031
  3. 更好的库。有没有办法实现Close客户端自动化?终结器呢?有什么方法可以做得更好吗?请在https://github.com/googleapis/google-cloud-go/issues/4498告诉我们。

希望你对 Go 语言、内存泄漏、pprofgRPC 和 Bash 有了些了解。我很想听听你遇到的内存泄漏问题以及修复方法!如果你有任何关于如何改进我们的示例的想法,请提交 issue 告诉我们。

文章来源:https://dev.to/googlecloud/finding-and-fixing-memory-leaks-in-go-1k1h