-
Notifications
You must be signed in to change notification settings - Fork 19
Go1.26 goroutine leak #1750
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Go1.26 goroutine leak #1750
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
352 changes: 352 additions & 0 deletions
352
source/_posts/2026/20260128a_Go_1.26_リリース連載_Goroutine_Leak_Profiles.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,352 @@ | ||
| --- | ||
| title: "Go 1.26 リリース連載 Goroutine Leak Profiles" | ||
| date: 2026/01/28 00:00:00 | ||
| postid: a | ||
| tag: | ||
| - Go | ||
| - Go1.26 | ||
| category: | ||
| - Programming | ||
| thumbnail: /images/2026/20260128a/thumbnail.jpg | ||
| author: 武田大輝 | ||
| lede: "Go 1.26 で追加された Goroutine Leak Profiles について紹介します。" | ||
| --- | ||
|
|
||
| <img src="/images/2026/20260128a/top.jpg" alt="" height="1024" width="1024"> | ||
|
|
||
| ## はじめに | ||
|
|
||
| Go 1.26 リリース連載の 2 本目です。 | ||
|
|
||
| 本記事では Go 1.26 で追加された Goroutine Leak Profiles について紹介します。 | ||
|
|
||
| ## アップデートの概要 | ||
|
|
||
| goroutine のリークを検知するためのプロファイルが [`runtime/pprof`](https://pkg.go.dev/runtime/pprof@go1.26rc2) に追加されました。 | ||
| これまでは Uber 社が公開している [goleak](https://github.com/uber-go/goleak) などを用いてリークを検知するのが一般的なプラクティスでしたが、Go 標準のプロファイル機能を利用して検知ができるようになりました。 | ||
|
|
||
| [リリースノート](https://go.dev/doc/go1.26#goroutineleak-profiles) を参照し、ポイントだけ先にまとめると次の通りです。 | ||
|
|
||
| - `runtime/pprof` に新しいプロファイル `goroutineleak` が追加された | ||
| - `net/http/pprof` のエンドポイントとして `/debug/pprof/goroutineleak` も追加された | ||
| - Go 1.26 では `GOEXPERIMENT=goroutineleakprofile` を付けてビルドした場合のみ利用可能となる(実験的機能) | ||
| - Go 1.27 でデフォルト有効化が検討されている | ||
|
|
||
| 関連する主要な Proposal や PR は次の通りです。 | ||
|
|
||
| - Proposal | ||
| <https://github.com/golang/go/issues/74609> | ||
| <https://github.com/golang/go/issues/75280> | ||
| - Design Document | ||
| <https://go.googlesource.com/proposal/+/master/design/74609-goroutine-leak-detection-gc.md> | ||
| - PR | ||
| <https://github.com/golang/go/pull/74622> | ||
| - Change List | ||
| <https://go-review.googlesource.com/c/go/+/688335> | ||
|
|
||
| ## そもそもの話 | ||
|
|
||
| 本題へ入る前に、初心者向けに goroutine リークと Go のプロファイリング(`runtime/pprof`)について、軽くおさらいします。 | ||
|
|
||
| ### goroutine リークとは | ||
|
|
||
| goroutine リーク(goroutine leak)は、ざっくり言うと「本来は不要になった goroutine が、適切に終了されず放置されたまま(実質的に)生き続ける」状態です。 | ||
|
|
||
| リークした goroutine はスタックや参照しているヒープオブジェクトを保持し続け、長期的にはメモリ使用量や GC 負荷に影響します。通常 goroutine リークは、プログラムがすぐに停止したりエラーになったりしないため、問題に気付きにくいという点でやっかいです。 | ||
|
|
||
| 典型例は次のような3ケースです。 | ||
|
|
||
| ```go ① 受信側がいないのに送信する | ||
| func leak() { | ||
| ch := make(chan int) | ||
| go func() { | ||
| // 送信を待つ | ||
| ch <- 1 | ||
| }() | ||
|
|
||
| // 何かしらの処理でエラーが発生した場合、受信が行われず goroutine がリークする | ||
| if err := something(); err != nil { | ||
| return | ||
| } | ||
| fmt.Println(<-ch) | ||
| } | ||
| ``` | ||
|
|
||
| ```go ② 送信側がいないのに受信する | ||
| func leak() { | ||
| ch := make(chan int) | ||
|
|
||
| go func() { | ||
| // 受信を待つ | ||
| fmt.Println(<-ch) | ||
| }() | ||
|
|
||
| // 何かしらの処理でエラーが発生した場合、送信が行われず goroutine がリークする | ||
| if err := something(); err != nil { | ||
| return | ||
| } | ||
|
|
||
| ch <- 1 | ||
| } | ||
| ``` | ||
|
|
||
| ```go ③ close されない channel を使う | ||
| func leak() { | ||
| ch := make(chan int) | ||
|
|
||
| go func() { | ||
| // channel が close されるまで受信し続ける | ||
| for x := range ch { | ||
| fmt.Println(x) | ||
| } | ||
| }() | ||
|
|
||
| ch <- 1 | ||
| } | ||
| ``` | ||
|
|
||
| ### プロファイリング(runtime/pprof)とは | ||
|
|
||
| `runtime/pprof` は、Go ランタイムが持つ各種プロファイルを取得するための標準ライブラリです。 | ||
| 具体的には次のようなプロファイルが用意されています。 | ||
|
|
||
| | 名前 | 種別 | 説明 | | ||
| | -------------- | ------------------ | ---------------------------------------------------------------- | | ||
| | `goroutine` | スナップショット型 | 現在存在するすべての goroutine のスタックトレース | | ||
| | `heap` | スナップショット型 | 生存オブジェクトに対するメモリ割り当てのサンプリング | | ||
| | `allocs` | スナップショット型 | 過去に行われたすべてのメモリ割り当てのサンプリング | | ||
| | `block` | スナップショット型 | channel やロックなどの同期処理で待ち状態になったスタックトレース | | ||
| | `mutex` | スナップショット型 | 競合しているミューテックスを保持している側のスタックトレース | | ||
| | `threadcreate` | スナップショット型 | OS スレッド生成時のスタックトレース | | ||
| | `profile` | 期間収集型 | 一定期間にわたって収集された CPU プロファイル | | ||
| | `trace` | 期間収集型 | 一定期間の実行トレース(スケジューラ/ネットワーク等) | | ||
|
|
||
| スナップショット型のプロファイルは取得時点におけるプログラムの状態を切り取って記録するのに対し、期間収集型のプロファイルは一定期間にわたる実行状況を収集・記録します。 | ||
|
|
||
| 各プロファイルを取得する方法はいくつかありますが、`net/http/pprof` を使うと、これらを HTTP のエンドポイント経由で公開・取得できます。 | ||
|
|
||
| ```go | ||
| import ( | ||
| "net/http" | ||
| _ "net/http/pprof" | ||
| ) | ||
|
|
||
| func main() { | ||
| http.ListenAndServe("127.0.0.1:6060", nil) | ||
| } | ||
| ``` | ||
|
|
||
| 各種プロファイルは `go tool pprof` コマンドで HTTP エンドポイント(`/debug/pprof/${プロファイル名}`)を指定することで確認できます。 | ||
| たとえば heap プロファイルを確認するコマンドは次の通りです。 | ||
|
|
||
| ```bash | ||
| $ go tool pprof http://localhost:6060/debug/pprof/heap | ||
| Fetching profile over HTTP from http://localhost:6060/debug/pprof/heap | ||
| ... | ||
| File: main | ||
| Build ID: 0692a5c786cbac9e9d379f2de2112b6066d8673c | ||
| Type: inuse_space | ||
| Time: 2026-01-19 15:15:56 UTC | ||
| Entering interactive mode (type "help" for commands, "o" for options) | ||
| ``` | ||
|
|
||
| このコマンドを実行すると、指定した HTTP エンドポイントからプロファイルを取得し、解析用の対話モード(pprof シェル)が起動します。 | ||
|
|
||
| 対話モードでは、`top` や `list` などのコマンドを使ってプロファイルの内容を確認できます。 | ||
|
|
||
| ```bash | ||
| (pprof) top | ||
| Showing nodes accounting for 3636.87kB, 100% of 3636.87kB total | ||
| Showing top 10 nodes out of 38 | ||
| flat flat% sum% cum cum% | ||
| 902.59kB 24.82% 24.82% 2097.87kB 57.68% compress/flate.NewWriter | ||
| 650.62kB 17.89% 42.71% 1195.29kB 32.87% compress/flate.(*compressor).init | ||
| 544.67kB 14.98% 57.68% 544.67kB 14.98% compress/flate.newDeflateFast (inline) | ||
| 513.12kB 14.11% 71.79% 513.12kB 14.11% compress/flate.(*huffmanEncoder).generate | ||
| 513kB 14.11% 85.90% 513kB 14.11% runtime.mallocgc | ||
| 512.88kB 14.10% 100% 512.88kB 14.10% sync.(*Pool).pinSlow | ||
| 0 0% 100% 513.12kB 14.11% compress/flate.(*Writer).Close | ||
| 0 0% 100% 513.12kB 14.11% compress/flate.(*compressor).close | ||
| 0 0% 100% 513.12kB 14.11% compress/flate.(*compressor).encSpeed | ||
| 0 0% 100% 513.12kB 14.11% compress/flate.(*huffmanBitWriter).indexTokens | ||
| ``` | ||
|
|
||
| なお `go tool pprof` を利用せずとも、`debug=1` や `debug=2` を指定すれば、ブラウザや curl からプロファイルをテキスト形式で確認できます。 | ||
|
|
||
| ```bash | ||
| $ curl http://localhost:6060/debug/pprof/heap?debug=1 | ||
| heap profile: 2: 2064 [14: 3192928] @ heap/1048576 | ||
| 0: 0 [1: 48] @ 0x200a9c 0x200ac1 0x213a50 0x206bcc 0x20ad60 0xa9654 | ||
| # 0x200a9b net/textproto.NewReader+0x8b /usr/local/go/src/net/textproto/reader.go:38 | ||
| # 0x200ac0 net/http.newTextprotoReader+0xb0 /usr/local/go/src/net/http/request.go:1044 | ||
| # 0x213a4f net/http.readRequest+0x2f /usr/local/go/src/net/http/request.go:1080 | ||
| # 0x206bcb net/http.(*conn).readRequest+0x1db /usr/local/go/src/net/http/server.go:1005 | ||
| # 0x20ad5f net/http.(*conn).serve+0x31f /usr/local/go/src/net/http/server.go:1995 | ||
| ... | ||
| ``` | ||
|
|
||
| ## 実際に試してみる | ||
|
|
||
| 前提となる goroutine リークやプロファイルが理解できたところで、実際に `goroutineleak` プロファイルを試してみましょう。 | ||
| 先ほどリークの例として挙げた「close されない channel を使う」コードを利用します。 | ||
|
|
||
| ```go | ||
| package main | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "net/http" | ||
| _ "net/http/pprof" | ||
| ) | ||
|
|
||
| func leak() { | ||
| ch := make(chan int) | ||
| go func() { | ||
| // channel が close されるまで受信し続ける | ||
| for x := range ch { | ||
| fmt.Println(x) | ||
| } | ||
| }() | ||
| ch <- 1 | ||
| } | ||
|
|
||
| func main() { | ||
| leak() | ||
| if err := http.ListenAndServe("127.0.0.1:6060", nil); err != nil { | ||
| panic(err) | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| Go 1.26 では実験的な機能(Experimental)となるため、まず `GOEXPERIMENT=goroutineleakprofile` を付けてビルド後、起動します。 | ||
| まずは `debug=1` を付与して簡易的なテキスト形式で確認してみましょう。 | ||
|
|
||
| ```bash | ||
| $ curl http://localhost:6060/debug/pprof/goroutineleak?debug=1 | ||
| goroutineleak profile: total 1 | ||
| 1 @ 0xa261c 0x26e20 0x26994 0x244f6c 0xa9ab4 | ||
| # 0x244f6b main.leak.func1+0x6b /workspaces/tech-blog-go-1.26-feature/profile/main.go:13 | ||
| ``` | ||
|
|
||
| 1つの goroutine がリークしていることがわかります。 | ||
|
|
||
| `debug=2` を付与して確認すると、現在存在する goroutine の完全なスタックトレースの一覧を確認できます。 | ||
| リークを確認するには `(leaked)` が付いている goroutine を見つけることが重要です。 | ||
| `goroutine 21 [chan receive (leaked)]:` という出力のとおり、channel の受信でブロックが発生し、リークしていることがわかります。 | ||
|
|
||
| ```bash | ||
| curl http://localhost:6060/debug/pprof/goroutineleak?debug=2 | ||
| ... | ||
| goroutine 21 [chan receive (leaked)]: | ||
| main.leak.func1() | ||
| /workspaces/tech-blog-go-1.26-feature/profile/main.go:13 +0x6c | ||
| created by main.leak in goroutine 1 | ||
| /workspaces/tech-blog-go-1.26-feature/profile/main.go:12 +0x74 | ||
| ... | ||
| ``` | ||
|
|
||
| それでは次に、先ほどのコードにクローズ処理を入れることでリークを解消して試してみます。 | ||
|
|
||
| ```go | ||
| package main | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "net/http" | ||
| _ "net/http/pprof" | ||
| ) | ||
|
|
||
| func leak() { | ||
| ch := make(chan int) | ||
| // クローズ処理を追加 | ||
| defer close(ch) | ||
| go func() { | ||
| for x := range ch { | ||
| fmt.Println(x) | ||
| } | ||
| }() | ||
| ch <- 1 | ||
| } | ||
|
|
||
| func main() { | ||
| leak() | ||
| if err := http.ListenAndServe("127.0.0.1:6060", nil); err != nil { | ||
| panic(err) | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| プロファイルからも goroutine リークが発生していないことが確認できます。 | ||
|
|
||
| ```bash | ||
| $ curl http://localhost:6060/debug/pprof/goroutineleak?debug=1 | ||
| goroutineleak profile: total 0 | ||
| ``` | ||
|
|
||
| ## リーク検知の背後にある思想やしくみ | ||
|
|
||
| リーク検知の設計思想などをつかむために Proposal([#74609](https://github.com/golang/go/issues/74609))や [Design Doc](https://go.googlesource.com/proposal/+/master/design/74609-goroutine-leak-detection-gc.md) を見てみます。 | ||
|
|
||
| リークを検知するためのアプローチのざっくりとした理解としては、通常の GC とは異なるリーク検知用の GC サイクルを実行し、到達可能な(実行される可能性のある)goroutine をマークすることで、どこからも到達できない goroutine を検出するようなイメージになります。 | ||
|
|
||
| リーク判定のために GC サイクルを走らせるため、本番環境における利用は原則さけ、ローカルや本番に近しい環境での調査に使うのがよさそうです。 | ||
|
|
||
| なお、このアプローチは「検知されたものは確実にリークである(偽陽性が出ない)」ことを重視しており、逆に「リークしているけど検知できない(偽陰性)」ケースはあり得るとされています。 | ||
|
|
||
| > The advantage of this approach over other goroutine leak detection techniques is that it can be leveraged, with a minimal performance cost, in regular Go systems, e.g., production services. It is also theoretically sound, i.e., there are no false positives. Its primary limitation is that its effectiveness is reduced the more heap resources are over-exposed in memory, i.e., pair-wise reachable. | ||
|
|
||
| ここまで見るとテストの実行前後で goroutine スタックを比較することでリークを検知する `goleak` のアプローチとは根本的に異なることがわかります。 | ||
|
|
||
| その意味ではどちらかがどちらかに取って代わるものではなく、もしかしたら相互補完的な関係性にあると言えるかもしれません(例. ユニットテストや CI では `goleak` を利用し、より本番に近い環境での診断には `goroutineleak` プロファイルを使用するなど)。 | ||
|
|
||
| ## リーク検知されないケース | ||
|
|
||
| 先ほど説明した通り `goroutineleak` プロファイルによる検知は「偽陽性を出さない」方向に設計されており、リークしているけど検知できないケースがあります。 | ||
| たとえばグローバル変数として channel を保持する場合は、到達可能(実行される可能性がある)と判断されます。 | ||
|
|
||
| ```go | ||
| package main | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "net/http" | ||
| _ "net/http/pprof" | ||
| ) | ||
|
|
||
| // グローバルに保持される channel | ||
| var gch = make(chan int) | ||
|
|
||
| func leak() { | ||
| go func() { | ||
| for x := range gch { | ||
| fmt.Println(x) | ||
| } | ||
| }() | ||
| gch <- 1 | ||
| } | ||
|
|
||
| func main() { | ||
| leak() | ||
| if err := http.ListenAndServe("127.0.0.1:6060", nil); err != nil { | ||
| panic(err) | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| 次のとおりこのコードはリークとして検知されません。 | ||
|
|
||
| ```bash | ||
| $ curl http://localhost:6060/debug/pprof/goroutineleak?debug=1 | ||
| goroutineleak profile: total 0 | ||
| ``` | ||
|
|
||
| ## おわりに | ||
|
|
||
| ここまで見てきたとおり、Go 1.26 で追加された `goroutineleak` プロファイルは、なかなか追いづらかった goroutine リークを検知できる標準機能です。 | ||
|
|
||
| Go 1.26 時点では実験的機能という位置付けですが、Go 1.27 でデフォルトで有効になるのが待ち遠しいですね。 | ||
|
|
||
| ## 参考 | ||
|
|
||
| - [Dynamic Partial Deadlock Detection and Recovery via Garbage Collection](https://dl.acm.org/doi/10.1145/3676641.3715990) | ||
| - [Detecting Goroutine Leaks via the Go Garbage Collector](https://medium.com/@aman.kohli1/detecting-goroutine-leaks-via-the-go-garbage-collector-deep-dive-180128dd81cc) | ||
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [textlint] <eslint.rules.ja-technical-writing/max-comma> reported by reviewdog 🐶
This sentence exceeds the maximum count of comma. Maximum is 3. (ja-technical-writing/max-comma)