如何防止 goroutine 泄露(二)

本文首發於個人博客,若是以爲有用,歡迎點贊收藏,讓更多的朋友看到。html

上篇文章說到,防止 goroutine 泄露可從兩個角度出發,分別是代碼層面的預防與運行層面的監控檢測。今天,咱們來談第二點。git

簡述

前文已經介紹了一種簡單檢測 goroutine 是否泄露的方法,即經過 runtime.NumGoroutine 獲取當前運行中的 goroutine 數量粗略估計。但 NumGoroutine 是否真的能肯定咱們代碼存在泄露,除此以外,還有沒有其餘更優的方式嗎。github

注:爲了更好的演示效果,下面將會用常駐的 http 做爲示例。golang

NumGoroutine

runtime.NumGoroutine 能夠獲取當前進程中正在運行的 goroutine 數量,觀察這個數字能夠初步判斷出是否存在 goroutine 泄露異常。數據庫

一個示例,以下:bash

package main

import (
	"net/http"
	"runtime"
	"strconv"
)

func write(w http.ResponseWriter, data []byte) {
	_, _ = w.Write(data)
}

func count(w http.ResponseWriter, r *http.Request) {
	write([]byte(strconv.Itoa(runtime.NumGoroutine())))
}

func main() {
	http.HandleFunc("/_count", count)
	http.ListenAndServe(":6080", nil)
}
複製代碼

功能很簡單,設置 _count 路由請求處理函數 count,它負責輸出服務當前 goroutine 數量。啓動服務後訪問 localhost:6080/_count 便可。微信

但只是一個數值,咱們就能確認是否泄露了嗎?網絡

首先,若是這個數值很大,是否是就能說明出現了泄露。個人答案是否。理由很簡單,高併發狀況下的 goroutine 數量確定很高的,但並不是出現了泄露,可能只是當前的服務的承載能力還不夠。咱們能夠在數量基礎上引入時間,即若是 goroutine 隨着時間增長,數量在不斷上升,而基本沒有降低,基本能夠肯定存在泄露。咱們能夠定時採集不一樣時刻的數據來分析。併發

演示案例

爲了更好的演示效果,咱們爲服務再增長一個處理函數 query, 並綁定路由 /query 上。假設它負責從多個數據表中查出數據返回給用戶。這個例子在後面的演示會一直使用。app

代碼以下:

func query(w http.ResponseWriter, r *http.Request) {
	c := make(chan byte)

	go func() {
		c <- 0x31
	}()

	go func() {
		c <- 0x32
	}()

	go func() {
		c <- 0x33
	}()

	rs := make([]byte, 0)
	for i := 0; i < 2; i++ {
		rs = append(rs, <-c)
	}

	write(w, rs)
}
複製代碼

在 query 中,咱們啓動了 3 個 goroutine 執行數據庫查詢,經過 channel 傳遞返回數據。這裏的問題是,query 函數中只從 channel 中接收兩次數據就退出了循環,這會致使其中一個 goroutine 因缺乏接收者而沒法釋放。

咱們能夠屢次請求 localhost:6080/query,而後經過 _count 查看服務當前的 goroutine 數量。手動麻煩,能夠用 ab 命令進行作個簡單壓測。

$ ab -n 1000 -c 100 localhost:6080/query
複製代碼

命令的意思是,總共訪問 1000 次,併發訪問 100 次。

pprof

前面的例子比較簡單,發現泄露後,咱們能夠馬上肯定存在的問題。但若是比較複雜的項目,咱們就很難發現問題代碼的出現位置了。

如何解決呢?

咱們能夠引入一個輔助工具,pprof。它是由 Go 官方提供的可用於收集程序運行時報告的工具,其中包含 CPU、內存等信息。固然,也能夠獲取運行時 goroutine 堆棧信息,如此一來,咱們就能夠很容易看出哪裏致使了 goroutine 泄露。

runtime/pprof

咱們能夠再加入一個名爲 goroutineStack 的 handler,用於查看程序中 goroutine 的堆棧信息,,地址爲 _goroutine

實現代碼以下:

import "runtime/pprof"

func goroutineStack(w http.ResponseWriter, r *http.Request) {
	_ = pprof.Lookup("goroutine").WriteTo(w, 1)
}
複製代碼

訪問 _goroutine,將會獲得相似以下的信息:

goroutine profile: total 1004
948 @ 0x102e70b 0x102e7b3 0x10068ed 0x10066c5 0x1233b37 0x10595d1
# 0x1233b36 main.query.func2+0x36 /Users/polo/Public/Work/go/src/study/goroutine/leak/06/main.go:20

45 @ 0x102e70b 0x102e7b3 0x10068ed 0x10066c5 0x1233ae7 0x10595d1
# 0x1233ae6 main.query.func1+0x36 /Users/polo/Public/Work/go/src/study/goroutine/leak/06/main.go:16

7 @ 0x102e70b 0x102e7b3 0x10068ed 0x10066c5 0x1233b87 0x10595d1
# 0x1233b86 main.query.func3+0x36 /Users/polo/Public/Work/go/src/study/goroutine/leak/06/main.go:24

1 @ 0x102e70b 0x1029ba9 0x1029256 0x108b7da 0x108b8ed 0x108c216 0x112f80f 0x113b348 0x11f5f6a 0x10595d1
# 0x1029255 internal/poll.runtime_pollWait+0x65 /usr/local/go/src/runtime/netpoll.go:173
# 0x108b7d9 internal/poll.(*pollDesc).wait+0x99 /usr/local/go/src/internal/poll/fd_poll_runtime.go:85
# 0x108b8ec internal/poll.(*pollDesc).waitRead+0x3c /usr/local/go/src/internal/poll/fd_poll_runtime.go:90
# 0x108c215 internal/poll.(*FD).Read+0x1d5 /usr/local/go/src/internal/poll/fd_unix.go:169
# 0x112f80e net.(*netFD).Read+0x4e /usr/local/go/src/net/fd_unix.go:202
# 0x113b347 net.(*conn).Read+0x67 /usr/local/go/src/net/net.go:177
# 0x11f5f69 net/http.(*connReader).backgroundRead+0x59 /usr/local/go/src/net/http/server.go:676

1 @ 0x102e70b 0x1029ba9 0x1029256 0x108b7da 0x108b8ed 0x108c216 0x112f80f 0x113b348 0x11f63ec 0x10fb596 0x10fbf76 0x10fc174 0x119ebbf 0x119eaeb 0x11f315c 0x11f7672 0x11fb23e 0x10595d1
# 0x1029255 internal/poll.runtime_pollWait+0x65 /usr/local/go/src/runtime/netpoll.go:173
# 0x108b7d9 internal/poll.(*pollDesc).wait+0x99 /usr/local/go/src/internal/poll/fd_poll_runtime.go:85
# 0x108b8ec internal/poll.(*pollDesc).waitRead+0x3c /usr/local/go/src/internal/poll/fd_poll_runtime.go:90
# 0x108c215 internal/poll.(*FD).Read+0x1d5 /usr/local/go/src/internal/poll/fd_unix.go:169
# 0x112f80e net.(*netFD).Read+0x4e /usr/local/go/src/net/fd_unix.go:202
# 0x113b347 net.(*conn).Read+0x67 /usr/local/go/src/net/net.go:177
# 0x11f63eb net/http.(*connReader).Read+0xfb /usr/local/go/src/net/http/server.go:786
# 0x10fb595 bufio.(*Reader).fill+0x105 /usr/local/go/src/bufio/bufio.go:100
# 0x10fbf75 bufio.(*Reader).ReadSlice+0x35 /usr/local/go/src/bufio/bufio.go:341
# 0x10fc173 bufio.(*Reader).ReadLine+0x33 /usr/local/go/src/bufio/bufio.go:370
# 0x119ebbe net/textproto.(*Reader).readLineSlice+0x6e /usr/local/go/src/net/textproto/reader.go:55
# 0x119eaea net/textproto.(*Reader).ReadLine+0x2a /usr/local/go/src/net/textproto/reader.go:36
# 0x11f315b net/http.readRequest+0x8b /usr/local/go/src/net/http/request.go:958
# 0x11f7671 net/http.(*conn).readRequest+0x161 /usr/local/go/src/net/http/server.go:966
# 0x11fb23d net/http.(*conn).serve+0x49d /usr/local/go/src/net/http/server.go:1788

1 @ 0x102e70b 0x1029ba9 0x1029256 0x108b7da 0x108b8ed 0x108ce80 0x112fd92 0x1142c5e 0x1141967 0x11ff7df 0x121da4c 0x11fed5f 0x11fea16 0x11ff534 0x1233a91 0x102e317 0x10595d1
# 0x1029255 internal/poll.runtime_pollWait+0x65 /usr/local/go/src/runtime/netpoll.go:173
# 0x108b7d9 internal/poll.(*pollDesc).wait+0x99 /usr/local/go/src/internal/poll/fd_poll_runtime.go:85
# 0x108b8ec internal/poll.(*pollDesc).waitRead+0x3c /usr/local/go/src/internal/poll/fd_poll_runtime.go:90
# 0x108ce7f internal/poll.(*FD).Accept+0x19f /usr/local/go/src/internal/poll/fd_unix.go:384
# 0x112fd91 net.(*netFD).accept+0x41 /usr/local/go/src/net/fd_unix.go:238
# 0x1142c5d net.(*TCPListener).accept+0x2d /usr/local/go/src/net/tcpsock_posix.go:139
# 0x1141966 net.(*TCPListener).AcceptTCP+0x46 /usr/local/go/src/net/tcpsock.go:247
# 0x11ff7de net/http.tcpKeepAliveListener.Accept+0x2e /usr/local/go/src/net/http/server.go:3232
# 0x11fed5e net/http.(*Server).Serve+0x22e /usr/local/go/src/net/http/server.go:2826
# 0x11fea15 net/http.(*Server).ListenAndServe+0xb5 /usr/local/go/src/net/http/server.go:2764
# 0x11ff533 net/http.ListenAndServe+0x73 /usr/local/go/src/net/http/server.go:3004
# 0x1233a90 main.main+0xb0 /Users/polo/Public/Work/go/src/study/goroutine/leak/06/main.go:40
# 0x102e316 runtime.main+0x206 /usr/local/go/src/runtime/proc.go:201

1 @ 0x122ce28 0x122cc30 0x1229694 0x1233723 0x11fc194 0x11fde37 0x11fe8eb 0x11fb3e6 0x10595d1
# 0x122ce27 runtime/pprof.writeRuntimeProfile+0x97 /usr/local/go/src/runtime/pprof/pprof.go:707
# 0x122cc2f runtime/pprof.writeGoroutine+0x9f /usr/local/go/src/runtime/pprof/pprof.go:669
# 0x1229693 runtime/pprof.(*Profile).WriteTo+0x3e3 /usr/local/go/src/runtime/pprof/pprof.go:328
# 0x1233722 study/goroutine/leak/06/leak.GoroutineStack+0x92 /Users/polo/Public/Work/go/src/study/goroutine/leak/06/leak/handlers.go:19
# 0x11fc193 net/http.HandlerFunc.ServeHTTP+0x43 /usr/local/go/src/net/http/server.go:1964
# 0x11fde36 net/http.(*ServeMux).ServeHTTP+0x126 /usr/local/go/src/net/http/server.go:2361
# 0x11fe8ea net/http.serverHandler.ServeHTTP+0xaa /usr/local/go/src/net/http/server.go:2741
# 0x11fb3e5 net/http.(*conn).serve+0x645 /usr/local/go/src/net/http/server.go:1847
複製代碼

首先是第一行,以下:

goroutine profile: total 1004
複製代碼

統計信息,和 NumGoroutine 的返回結果相同。當前共有 1004 個 goroutine 在運行。

接下來的部分,主要是具體介紹每一個 goroutine 的狀況,相同函數的 goroutine 會被合併統計,並按數量從大到小排序。輸出前三段就是咱們在 query 函數中開啓的三個 goroutine。

948 @ 0x102e70b 0x102e7b3 0x10068ed 0x10066c5 0x1233b37 0x10595d1
# 0x1233b36 main.query.func2+0x36 /Users/polo/Public/Work/go/src/study/goroutine/leak/06/main.go:20

45 @ 0x102e70b 0x102e7b3 0x10068ed 0x10066c5 0x1233ae7 0x10595d1
# 0x1233ae6 main.query.func1+0x36 /Users/polo/Public/Work/go/src/study/goroutine/leak/06/main.go:16

7 @ 0x102e70b 0x102e7b3 0x10068ed 0x10066c5 0x1233b87 0x10595d1
# 0x1233b86 main.query.func3+0x36 /Users/polo/Public/Work/go/src/study/goroutine/leak/06/main.go:24
複製代碼

分別是 main.query.func一、main.query.func2 以及 main.query.func3,對應於它們,當前仍在運行中的 goroutine 數量分別是 4五、94八、7。看樣子泄露的 goroutine 函數分佈並不是均勻。

幾個函數都是匿名的,若是咱們須要肯定具體位置,能夠經過堆棧實現。好比 func1,明確指出了位於的所在文件和代碼行數。

http/net/pprof

前面部分是經過本身編寫代碼把 goroutine 的分析統計指標加入到了 HTTP 服務中。其實,官方已經實現了這個功能,而且涉及的不只僅是 goroutine,還有 CPU、內存等。

它的操做很簡單,咱們只須要在服務啓動時導入 net/http/pprof 便可。接着訪問地址 /debug/pprof/goroutine?debug=1,將會能夠看到與上一節輸出的相同內容。

gops

熟悉 Java 的朋友都知道 jps 這個命令。經過它,咱們能夠查看當前機器上有哪些 Java 程序在運行。Go 也有相似的命令,gops,它支持列出當前環境下的 Go 進程,並支持對 Go 程序的診斷。默認狀況下,gops 可列出並不支持對進程進行成診斷。

今天,咱們將只看它和 goroutine 相關的部分。

一個示例,以下:

$ gops
97778 96800 gops    go1.11.1 /usr/local/go/bin/gops
97605 73594 leaker* go1.11.1 /Users/polo/Public/Work/go/src/study/goroutine/leak/06/leaker
複製代碼

個人環境下當前只有兩個 go 進程在運行。

仔細觀察後,咱們會發現 leaker 進程相比 gops 後面多個 * 的標號,而 * 表示這個程序支持經過 gops 診斷。這是由於咱們在 leaker 加入了診斷支持的代碼,以下:

func main() {
	if err := agent.Listen(agent.Options{ShutdownCleanup: true}); err != nil {
		log.Fatalln(err)
	}

	...
}
複製代碼

執行以下命令,查看當前的 goroutine 數量。

$ gops stats 97605
goroutines: 1004
OS threads: 14
GOMAXPROCS: 8
num CPU: 8
複製代碼

其中,97605 是進程 PID。

結果顯示,當前在運行的 goroutine 有 1004 個。並且,咱們還注意到 OS 級別的線程才 14 個,可見 goroutine 的輕量。

gops 也能夠查看堆棧,咱們只需執行 gops stack PID 便可,這個就不具體演示了。要說明的是,這種方式並不會對運行相同函數的 goroutine 作聚合統計,不知道是我沒找到仍是自己不支持。若是的確不支持,也能夠本身聚合,但畢竟沒那麼方便。

Leak Test

除了出現問題後的檢測調試,但若是咱們能把泄露檢測過程加入到自動化測試中,在正式上線前就避免,豈不是更完美。咱們能夠經過一個開源包實現,包的名稱是 leaktest,即泄露測試的意思。

利用 leaktest,咱們測試下前面寫的 http 處理函數 query。由於要檢測 handler 是否泄露,若是通過網絡就會丟失服務端的相關信息,這時,咱們能夠藉助 Go 中的 net/http/test 包完成測試。

代碼以下:

func Test_Query(t *testing.T) {
	defer leaktest.Check(t)()

	//建立一個請求
	req, err := http.NewRequest("GET", "/query", nil)
	if err != nil {
		t.Fatal(err)
	}

	rr := httptest.NewRecorder()

	//直接使用 query(rr,req)
	query(rr, req)

	// 其餘測試
	// ...
}
複製代碼

測試執行輸出以下:

=== RUN   Test_Query
--- FAIL: Test_Query (5.01s)
    leaktest.go:162: leaktest: context canceled
    leaktest.go:168: leaktest: leaked goroutine: goroutine 20 [chan send]:
        study/goroutine/leak/06.query.func2(0xc0001481e0)
        	/Users/polo/Public/Work/go/src/study/goroutine/leak/06/main.go:24 +0x37
        created by study/goroutine/leak/06.query
        	/Users/polo/Public/Work/go/src/study/goroutine/leak/06/main.go:23 +0x7e
FAIL
複製代碼

從輸出信息中,咱們能夠明確地知道出現了泄露,而且經過輸出堆棧很快就能定位出現問題的代碼。測試代碼很是簡單,在測試函數開始經過 defer 執行 leaktest 的 Check。

它提供的三個檢測函數,分別是 Check、CheckTimeout 和 CheckContext,從前到後的實現一個比一個底層。Check 默認會等待五秒再執行檢測,若是須要改變這個時間,可使用 CheckTimeout 函數。

leaktest 的實現原理也和堆棧有關,源碼很少,若是有興趣能夠讀讀,源碼文件地址

總結

本系列文章分別從代碼實現和監控檢測兩個角度介紹瞭如何避免 goroutine 的泄露。Go 的併發下降了併發程序的開發難度,但併發一直都是個比較複雜的話題,爲了用好它,必要學習仍是不可缺乏的。

參考資料

Goroutine leak
gops 工做原理
gops - Go 語言程序查看和診斷工具
gops — Go 程序診斷分析工具
性能調式:分析並優化 Go 程序
Debugging Go Routine leaks
視頻-Debugging Go routine leaks
HTTP 測試輔助工具


波羅學的微信公衆號
相關文章
相關標籤/搜索