定位並修復 Go 中的記憶體洩漏
- 原文地址:https://dev.to/googlecloud/finding-and-fixing-memory-leaks-in-go-1k1h
- 原文作者:Tyler Bui-Palsulich
- 本文永久連結:https://github.com/gocn/translator/blob/master/2021/w42_Finding_and_fixing_memory_leaks_in_Go.md
- 譯者:Fivezh
這篇文章回顧了我是如何發現記憶體洩漏、如何修復它、如何修復 Google
中的 Go
示例程式碼中的類似問題,以及我們是如何改進我們的基礎庫防止未來再次發生這種情況。
Google
雲的 Go
的客戶端基礎庫通常在底層使用 gRPC
來連線 Google
雲的介面。當你建立一個客戶端時,庫會初始化一個與該 介面的連線,然後讓這個連線保持開啟狀態,直到你在該客戶端上呼叫 Close
操作。
client, err := api.NewClient()
// Check err.
defer client.Close()
客戶端併發地使用是安全的,所以應該在使用完之前保留同一個客戶端。但是,如果在應該關閉客戶端的時候而沒有關閉,會發生什麼呢?
你會得到一個記憶體洩漏:底層連線從未被釋放過。
Google
有一堆的 GitHub
自動化機器人,幫助管理數以百計的 GitHub
倉庫。我們的一些機器人通過雲上執行的上的Go 服務代理它們的請求。我們的記憶體使用情況看起來就是典型的鋸齒狀記憶體洩漏情況。
我通過在程式中新增 pprof.Index
開始除錯:
mux.HandleFunc("/debug/pprof/", pprof.Index)
pprof
提供執行時的分析資料,比如記憶體使用量。訪問 Go
官方部落格中的 分析 Go
程式 獲取更多資訊.
然後,我在本地構建並啟動了該服務:
$ 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 雲祕鑰管理部分。
client, err := secretmanager.NewClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to create secretmanager client: %v", err)
}
我們從未呼叫過client.Close()
,並且在每個請求中都建立了一個Client
! 所以,我新增了一個Close
呼叫,問題就解決了。
defer client.Close()
我提交了這個修復, 它 自動部署完成後, 毛刺顯現立即消失了!
哇嗚! ???
大約在同一時間,一個使用者在我們的雲上的 Go 例項程式碼庫上提出了一個問題,其中包含了cloud.google.com上文件的大部分 Go
示例程式。該使用者注意到我們在其中一個程式中忘記了 client.Close()
關閉客戶端!
我看到同樣的事情出現過幾次,所以我決定調查整個倉庫。
我從粗略估計有多少受影響的檔案開始。使用 grep
命令,我們可以得到一個包含 NewClient
風格呼叫的所有檔案的列表,然後把這個列表傳遞給另一個 grep
呼叫,只列出不包含Close
的檔案,同時忽略測試檔案。
$ grep -L Close $(grep -El 'New[^(]*Client' **/*.go) | grep -v test
譯者注:列出包含
New[^(]*Client
,但不包含Close
的所有 go 檔案
哇嗚! 總共有 207 個檔案,而整個 GoogleCloudPlatform/golang-samples 倉庫中有大約 1300 個 .go
檔案.
鑑於問題的規模,我認為一些簡單的自動化會很值得。我不想寫一個完整的 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
去搜尋地址範圍。
地址範圍可以包括開始和結束模式,以便在應用接下來的任何命令之前進行匹配。在這個例子中,開始是 /New[^(]*Client/
,匹配 NewClient
型別的呼叫,結束(用,
分隔)是/}/
,匹配下一個大括號。這意味著我們的搜尋和替換將只適用於對 NewClient
的呼叫和結尾的大括號之間。
通過了解上面的錯誤處理模式,if err != nil
條件的結束括號正是我們要插入 Close
呼叫的地方。
一旦自動編輯了所有的檔案,執行 goimports
來修復格式化。然後,檢查了每個編輯過的檔案,確保它做了正確的事情。
- 在伺服器應用程式中,我們是應該真正關閉客戶端,還是應該為未來的請求保留它?
- 客戶端的名字實際上是
client
,還是別的什麼? - 是否有更多的客戶端需要
Close
?
一旦完成這些,我留下了180 個編輯的檔案
最後的任務是努力使使用者不再發生這種情況。我們想到了幾種方法:
- 更好的示例程式。
- 更好的
GoDoc
。我們更新了我們的庫生成器,在生成的庫中加入了一個註釋,說當你用完後要Close
客戶端。參見https://github.com/googleapis/google-cloud-go/issues/3031。 - 更好的基礎庫。有什麼辦法可以讓我們自動
Close
客戶?Finalizer 方法?有什麼想法我們可以做得更好嗎?請在https://github.com/googleapis/google-cloud-go/issues/4498 上告訴我們。
希望你能學到一些關於 Go
、記憶體洩漏、pprof
、 gRPC
和 Bash
的知識。我很想聽聽你關於你所發現的記憶體洩露的故事,以及你是如何解決這些問題的! 如果你對我們的程式碼庫或示例程式有什麼想法,歡迎提交問題讓我們知道。
- 加微信實戰群請加微信(註明:實戰群):gocnio
相關文章
- PHP 記憶體洩漏分析定位PHP記憶體
- Android記憶體洩漏檢測與修復技巧Android記憶體
- 記憶體洩漏-原因、避免和定位記憶體
- 記一次 Ruby 記憶體洩漏的排查和修復記憶體
- 記憶體洩漏定位工具之 valgrind 使用記憶體
- Pprof定位Go程式記憶體洩露Go記憶體洩露
- vue使用中的記憶體洩漏Vue記憶體
- Android中的記憶體洩漏模式Android記憶體模式
- [譯] Swift 中的記憶體洩漏Swift記憶體
- 手把手教你定位Flutter PlatformView記憶體洩漏FlutterPlatformView記憶體
- 分析記憶體洩漏和goroutine洩漏記憶體Go
- 記憶體洩漏的原因記憶體
- 如何避免JavaScript中的記憶體洩漏?JavaScript記憶體
- Go 記憶體洩漏?不是那麼簡單!Go記憶體
- jvm 記憶體洩漏JVM記憶體
- Android 記憶體洩漏Android記憶體
- Java記憶體洩漏Java記憶體
- js記憶體洩漏JS記憶體
- Android記憶體洩漏Android記憶體
- 記憶體洩漏的定位與排查:Heap Profiling 原理解析記憶體
- 小心遞迴中記憶體洩漏遞迴記憶體
- 記一次 Java 應用記憶體洩漏的定位過程Java記憶體
- WebView引起的記憶體洩漏WebView記憶體
- [譯]理解閉包中的記憶體洩漏記憶體
- 翻譯 | 理解Java中的記憶體洩漏Java記憶體
- JavaScript中的垃圾回收和記憶體洩漏JavaScript記憶體
- 如何檢查Javascript中的記憶體洩漏JavaScript記憶體
- Java應用程式中的記憶體洩漏及記憶體管理Java記憶體
- valgrind 記憶體洩漏分析記憶體
- 記憶體的分配與釋放,記憶體洩漏記憶體
- 【記憶體洩漏和記憶體溢位】JavaScript之深入淺出理解記憶體洩漏和記憶體溢位記憶體溢位JavaScript
- JVM——記憶體洩漏與記憶體溢位JVM記憶體溢位
- Android中常見的記憶體洩漏Android記憶體
- .NET 記憶體洩漏的爭議記憶體
- Swift的ARC和記憶體洩漏Swift記憶體
- MAT工具定位分析Java堆記憶體洩漏問題方法Java記憶體
- 記憶體洩漏除錯工具記憶體除錯
- ThreadLocal真會記憶體洩漏?thread記憶體