go言語で複数のgoroutineのleakを検出する(goleak使用)
関連
概要
goleakパッケージでのgoroutine leakを検出してみました。
goroutine数の確認
runtime package - runtime - Go Packages
上のパッケージを使って、goroutineの数を調べてみます。
何もしないmain関数
printlnするだけのmainのgoroutine数を調べてみます。
https://goplay.tools/snippet/TO3jpRrT8Ju
package main import "runtime" func main() { println(runtime.NumGoroutine()) }
私の環境では、1
とでました。
main関数だけなのでこれは納得です。
Test関数
次にテスト関数でgoroutine数を調べてみます
https://goplay.tools/snippet/kPaUslIP_R1
package main import ( "fmt" "runtime" "testing" ) func TestSample(t *testing.T) { fmt.Println("num goroutine: ", runtime.NumGoroutine()) }
=== RUN TestSample num goroutine: 2 --- PASS: TestSample (0.00s) PASS
私の環境では2
とでました。
ここで2
となる理由については詳しく調べませんが、runtime.main()以外にも他のgoroutineが動いていることがあり、
それが検出されているそうです。
参考にさせていただ記事: jxck.hatenablog.com
goroutineを起動(leakしないけど不安なコード)
ここから本題です。
文字列に「a1」とつけて返す関数GetResourceA
をgoroutineで起動して終了を待つ関数Execute
を作り、
各所でgoroutine数を測ってみます。
以下のは、leakしないものの本番導入するのは不安なコードです。(説明は後述)
https://goplay.tools/snippet/cOGJG5PmCG7
package main import ( "fmt" "runtime" "testing" ) func TestSample(t *testing.T) { fmt.Println("begin num goroutine: ", runtime.NumGoroutine()) got, err := Execute("100") t.Log(got) t.Log(err) fmt.Println("end num goroutine: ", runtime.NumGoroutine()) } type response struct { id string res string err error } func Execute(id string) (string, error) { chA := make(chan response) fmt.Println("begin0 num goroutine: ", runtime.NumGoroutine()) go func(id string) { fmt.Println("resA num goroutine: ", runtime.NumGoroutine()) a, err := GetResourceA(id) fmt.Println("resA-1 num goroutine: ", runtime.NumGoroutine()) if err != nil { chA <- response{err: err} return } chA <- response{id: id, res: a, err: err} }(id) fmt.Println("begin1 num goroutine: ", runtime.NumGoroutine()) var resA string errs := make([]error, 0, 1) rr := <-chA fmt.Println("begin3 num goroutine: ", runtime.NumGoroutine()) if rr.err != nil { errs = append(errs, rr.err) } else { resA = rr.res } fmt.Println("begin6 num goroutine: ", runtime.NumGoroutine()) if len(errs) > 0 { return "", fmt.Errorf("%v", errs) } fmt.Println("res:", resA) fmt.Println("begin7 num goroutine: ", runtime.NumGoroutine()) return "", nil } func GetResourceA(id string) (string, error) { return id + "a1", nil }
結果は以下のようになりました
=== RUN TestSample begin num goroutine: 2 begin0 num goroutine: 2 begin1 num goroutine: 3 resA num goroutine: 3 resA-1 num goroutine: 3 begin3 num goroutine: 2 begin6 num goroutine: 2 res: 100a1 begin7 num goroutine: 2 prog.go:13: prog.go:14: <nil> end num goroutine: 2 --- PASS: TestSample (0.00s) PASS
- goroutineで
GetResourceA
を待ち合わせている間はgoroutine数が2から3になって、また2に戻っていることがわかります
つまり以下のようになります
go func(id string) { fmt.Println("resA num goroutine: ", runtime.NumGoroutine()) ←goroutineが新たに起動したので3 a, err := GetResourceA(id) fmt.Println("resA-1 num goroutine: ", runtime.NumGoroutine()) ←ここではまだ結果が返ってきてないので3 <略> }(id) <略> rr := <-chA ←ここで結果が返ってきたのでこの下の結果は2 fmt.Println("begin3 num goroutine: ", runtime.NumGoroutine()) ← 2
複数のgoroutineを起動(leakしないけど不安なコード)
上のgoroutineを2つにしてみます。 また、contextパッケージでtimeoutもできるようにします。
- Timeoutは
time.Millisecond
なので1ミリ秒です(短い)
呼びだした二つの関数のどちらかでエラーがでたらそれを出力します。
https://goplay.tools/snippet/r65dbSgNCYj
package main import ( "context" "fmt" "runtime" "testing" "time" ) func TestSample(t *testing.T) { fmt.Println("begin num goroutine: ", runtime.NumGoroutine()) id := "100" got, err := Execute(context.Background(), time.Millisecond, id) t.Log(got) t.Log(err) fmt.Println("end num goroutine: ", runtime.NumGoroutine()) } type response struct { id string res string err error } func Execute(ctx context.Context, timeout time.Duration, id string) (string, error) { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() chA := make(chan response) chB := make(chan response) fmt.Println("begin0 num goroutine: ", runtime.NumGoroutine()) go func(id string) { fmt.Println("resA num goroutine: ", runtime.NumGoroutine()) a, err := GetResourceA(ctx, id) fmt.Println("resA-1 num goroutine: ", runtime.NumGoroutine()) if err != nil { chA <- response{err: err} return } chA <- response{id: id, res: a, err: err} }(id) fmt.Println("begin1 num goroutine: ", runtime.NumGoroutine()) go func(id string) { fmt.Println("resB num goroutine: ", runtime.NumGoroutine()) b, err := GetResourceB(ctx, id) fmt.Println("resB-1 num goroutine: ", runtime.NumGoroutine()) if err != nil { chB <- response{err: err} return } chB <- response{id: id, res: b, err: err} }(id) fmt.Println("begin2 num goroutine: ", runtime.NumGoroutine()) var resA, resB string errs := make([]error, 0, 2) for i := 0; i < 2; i++ { fmt.Println("begin2-2 num goroutine: ", runtime.NumGoroutine()) select { case rr := <-chA: fmt.Println("begin3 num goroutine: ", runtime.NumGoroutine()) if rr.err != nil { errs = append(errs, rr.err) continue } resA = rr.res case rr := <-chB: fmt.Println("begin4 num goroutine: ", runtime.NumGoroutine()) if rr.err != nil { errs = append(errs, rr.err) continue } resB = rr.res case <-ctx.Done(): fmt.Println("begin5 num goroutine: ", runtime.NumGoroutine()) errs = append(errs, ctx.Err()) } } fmt.Println("begin6 num goroutine: ", runtime.NumGoroutine()) if len(errs) > 0 { // GetResourceAとGetResourceBのどちらかで失敗したらエラー return "", fmt.Errorf("%v", errs) } fmt.Println("res:", resA, resB) fmt.Println("begin7 num goroutine: ", runtime.NumGoroutine()) return "", nil } func GetResourceA(ctx context.Context, id string) (string, error) { return id + "a1", nil } func GetResourceB(ctx context.Context, id string) (string, error) { return id + "b1", nil }
=== RUN TestSample begin num goroutine: 2 begin0 num goroutine: 2 begin1 num goroutine: 3 begin2 num goroutine: 4 begin2-2 num goroutine: 4 resB num goroutine: 4 resB-1 num goroutine: 4 begin4 num goroutine: 3 begin2-2 num goroutine: 3 resA num goroutine: 4 resA-1 num goroutine: 3 begin3 num goroutine: 2 begin6 num goroutine: 2 res: 100a1 100b1 begin7 num goroutine: 2 prog.go:16: prog.go:17: <nil> end num goroutine: 2 --- PASS: TestSample (0.00s) PASS
- 結果は上の通り、goroutineが2つ起動したタイミングでgoroutine数は4となり、各goroutineが戻ってきた(selectのcaseに入った)時点で、徐々に減らし、最後はまた2になって終わります
ここまでは何の問題もないように思えます。
複数のgoroutineを起動(明確にleakする場合)
上の関数GetResourceB
の中で、10秒Sleepさせてみます。
contextのTimeout時間がtime.Millisecond
なので、これだと確実にTimeoutになります。
このコードを実行すると以下のようになります。
https://goplay.tools/snippet/f_40NzONfko
func GetResourceB(ctx context.Context, id string) (string, error) { time.Sleep(10 * time.Second) return id + "b1", nil }
=== RUN TestSample begin num goroutine: 2 begin0 num goroutine: 2 begin1 num goroutine: 3 begin2 num goroutine: 4 begin2-2 num goroutine: 4 resA num goroutine: 4 resA-1 num goroutine: 4 begin3 num goroutine: 3 begin2-2 num goroutine: 3 resB num goroutine: 4 begin5 num goroutine: 3 begin6 num goroutine: 3 prog.go:15: prog.go:16: [context deadline exceeded] end num goroutine: 3 --- PASS: TestSample (0.00s) PASS
GetResourceB
がTimeout以内に終わらなかったので、goroutineが回収されずにgoroutineが3のまま終わっています。- goroutine leakしている状態です
- もし
GetResourceB
がなかなか終わらず、かつメモリを多量に消費する関数であれば、`Execute``関数が呼び出されるたびにメモリが回収されずやがては全体のメモリを圧迫することになります
goroutine leakを検出する
上のgoroutine leakを検出するのに、良いパッケージとしてgo.uber.org/goleak
というものがあります。
Test関数の最後にgoleak.VerifyNone(t)
するだけで検出されます
package main import ( "context" "fmt" "runtime" "testing" "time" "go.uber.org/goleak" ) func TestSample(t *testing.T) { defer goleak.VerifyNone(t) fmt.Println("begin num goroutine: ", runtime.NumGoroutine()) got, err := Execute(context.Background(), time.Millisecond) t.Log(got) t.Log(err) fmt.Println("end num goroutine: ", runtime.NumGoroutine()) }
https://goplay.tools/snippet/3AOHnWHx9Ww
=== RUN TestSample begin num goroutine: 2 begin0 num goroutine: 2 begin1 num goroutine: 3 begin2 num goroutine: 4 begin2-2 num goroutine: 4 resB num goroutine: 4 resA num goroutine: 3 resA-1 num goroutine: 4 begin3 num goroutine: 3 begin2-2 num goroutine: 3 begin5 num goroutine: 3 begin6 num goroutine: 3 prog.go:18: prog.go:19: [context deadline exceeded] end num goroutine: 3 leaks.go:78: found unexpected goroutines: [Goroutine 8 in state sleep, with time.Sleep on top of the stack: goroutine 8 [sleep]: time.Sleep(0x2540be400?) /usr/local/go-faketime/src/runtime/time.go:194 +0x111 main.GetResourceB(...) /tmp/sandbox2930251588/prog.go:105 main.Execute.func2({0x4dda2e, 0x3}) /tmp/sandbox2930251588/prog.go:53 +0xff created by main.Execute /tmp/sandbox2930251588/prog.go:51 +0x3af ] --- FAIL: TestSample (0.43s) FAIL
leaks.go:78: found unexpected goroutines:
とはっきり出してくれました
複数のgoroutineを起動(leakさせないように改修)
上のleakの原因はcontextのTimeoutになってmain関数が終わったのに、
GetResourceB
が終わらない(キャンセルされない)ためでした。この修正は簡単で、以下のようにTimeoutでcontextのキャンセルがあった時に、
GetResourceB
がそのキャンセルを受け取ればよいだけですつまり以下のように修正すればよいです
https://goplay.tools/snippet/YFxohza2aFc
func GetResourceB(ctx context.Context, id string) (string, error) { // time.Sleep(10 * time.Second) select { case <-ctx.Done(): fmt.Println("context Done in GetResourceB") return "", ctx.Err() case <-time.After(10 * time.Second): fmt.Println("sleep finished in GetResourceB") } return id + "b1", nil }
上のselect処理は10秒経過するのか、
ctx.Done()
を受け取るか、のどちらかが来れば終わりますこれにより、10秒経過前にcontextがキャンセルされれば
ctx.Done()
を受信して終わります。この方法は「Go言語による並行処理」にも書いてあります。
- 私の持つ初版では「4.12 contextパッケージ」にありました。
上のコードを実行してみます。
=== RUN TestSample begin num goroutine: 2 begin0 num goroutine: 2 begin1 num goroutine: 3 begin2 num goroutine: 4 begin2-2 num goroutine: 4 resA num goroutine: 4 resA-1 num goroutine: 4 begin3 num goroutine: 3 begin2-2 num goroutine: 3 resB num goroutine: 4 begin5 num goroutine: 3 begin6 num goroutine: 3 prog.go:18: prog.go:19: [context deadline exceeded] context Done in GetResourceB resB-1 num goroutine: 3 end num goroutine: 2 --- PASS: TestSample (1.00s) PASS
goroutine leakが検出されず、goroutine数は起動時と同じ2で終わっています
context Done in GetResourceB
が出ているので、contextのキャンセルを受け取ったことがわかります
ちなみに、time.After
を10秒ではなく、contextのTimeoutより短い10マイクロ秒にすると、GetResourceB
はキャンセルされずに終わります
https://goplay.tools/snippet/7WiqTULOmTR
=== RUN TestSample begin num goroutine: 2 begin0 num goroutine: 2 begin1 num goroutine: 3 resA num goroutine: 3 resA-1 num goroutine: 4 resB num goroutine: 4 begin2 num goroutine: 4 begin2-2 num goroutine: 3 begin3 num goroutine: 3 begin2-2 num goroutine: 3 sleep finished in GetResourceB resB-1 num goroutine: 3 begin4 num goroutine: 2 begin6 num goroutine: 2 res: 100a1 100b1 begin7 num goroutine: 2 prog.go:18: prog.go:19: <nil> end num goroutine: 2 --- PASS: TestSample (1.00s) PASS
- 今度は、
sleep finished in GetResourceB
が出力されました - キャンセルされずに関数が正常終了した場合です
まとめ
go.uber.org/goleak
を使うことでgoroutine leakの検出ができるようになりました- 今後も使ってみたいと思います