Go 1.19 でruntime/pprofのgoroutine profileにおいてSTWの時間が大幅に軽減されたらしいので調べてみた - unless’s blog

unless’s blog

日々のちょっとした技術的なことの羅列

Go 1.19 でruntime/pprofのgoroutine profileにおいてSTWの時間が大幅に軽減されたらしいので調べてみた

これはなに?

Go 1.19のRelease Notesを眺めていたら、runtime/pprof でgoroutine profileを取得する際のSTW(Stop-the-world)の一時停止時間が大幅に短縮され、アプリケーションに対する全体的なレイテンシーの影響が軽減されたという記述を見つけました

runtime/pprof
Stop-the-world pause times have been significantly reduced when collecting goroutine profiles, reducing the overall latency impact to the application.

tip.golang.org

個人的にどのような変更でSTWの時間が軽減されたのか気になったので追ってみました

変更を探す

1.19に入ったどの修正が対象なのかを探すために、Githubから runtime/pprof 配下の変更履歴を見てみたけど、対象っぽいものを見つけられませんでした

github.com

しかし、Release Notesに significantly reduced って記載するくらいなので、絶対に速度検証もしてるはずで、別の場所の変更で大幅に改善したのかと思い、 runtime 側を探してたらそれっぽいcommitを見つけられました

github.com

commitはこちら

github.com

Gerrit上だとこちら

https://go-review.googlesource.com/c/go/+/387415

関係するProposal

そもそものissueはこちら

github.com

大量のgoroutineを扱うアプリケーションだとgoroutine profileのレイテンシーが厳しいよって感じです
goroutineの量に比例してgoroutine profileの時間が伸びてるよって話

関係するProposalはこちら

github.com

内容としては SetMaxDumpGoroutine を追加して、dumpするgoroutineの数に制限をつけちゃえって感じです
PRも出されてます

github.com

でもこのProposalはdeclineされた模様
理由としては CL387415 で様子を見て、それでもだめならその時に考えよう
we should hold off on adding any new API right now, which we would be stuck with forever. って感じのようです

変更内容

そもそもgoroutine profileは、アプリ内のすべてのgoroutineの一貫したスナップショットを取得するためにSTWする必要があります
アプリ内の全ゴルーチンのスナップショットを取得する必要があり、allgs の反復中にSTWしたままにすると、アプリ内のgoroutineの数に比例して一時停止することになります

この変更ではその代わりに、STWしてる間に一定量のbookkeepingだけ行うようにしています
バリアを導入して、goroutineの実行を許可する前に、goroutineがprofileに表示されることをスケジューラーが確認し、そのスタックがSTW中とまったく同じように記録されるようにします
アプリが通常の操作を再開している間、allgs を反復処理し、その間にスケジュールされていない限り (自分自身をプロファイリングしていない限り)、それぞれをprofileに追加します
最後にもう一度STWしてバリアを取り除き、一定量のクリーンアップ作業を行います

これによる影響で、GoroutineProfile の固定オーバーヘッドとゴルーチンごとの CPU 時間コストの両方が増加してます
また、スケジューラーが他のゴルーチンを実行するために呼び出しを中断する可能性があるため、GoroutineProfile への呼び出しのwall-clock latencyも増加してます

この辺はトレードオフなので仕方ないところかなと

コードの変更点

Goroutine scheduler

コードの変更点を見ていく前にそもそもGoroutine schedulerの動きとか変数とかを把握していないと読みにくいので、最初にこの辺を読んでおくとスムーズかと思います

github.com

とりあえずこれが頭に入っていると、雰囲気は掴めるとは思います

// The main concepts are:
// G - goroutine.
// M - worker thread, or machine.
// P - processor, a resource that is required to execute Go code.
//     M must have an associated P to execute Go code, however it can be
//     blocked or in a syscall w/o an associated P.

Design Docのリンクも置いておきます

docs.google.com

いままで

いままではシンプルに世界を止めて、すべてのg分(allgs)を反復処理して、世界を再開させていた模様

github.com

変更点

gにstatus管理するためのフィールドが追加されました

github.com

github.com

StatusはgoroutineProfileState で(Absent,InProgress,Satisfied)があります

github.com

profileの状態管理用の変数も新たに切られていて

var goroutineProfile = struct {
    sema    uint32
    active  bool
    offset  atomic.Int64
    records []StackRecord
    labels  []unsafe.Pointer
}

github.com

STWして状態を書き込むようにしてます

github.com

その後、世界を再開させてから、profileされるべきgoroutineか確認して

github.com

goroutine profileに書き込むようになってます

github.com

オーバーヘッドの原因はこの辺でチェック処理が入ったことによる影響だと思われます

github.com

github.com

最後にクリーンアップ処理をSTWして行ってます

github.com

まとめ

ちょっと気になってたので、どのようにpprofのgoroutine profileのSTWの時間を削減したのか調べてみました
簡単にまとめると
STWしてgoroutineの数だけ反復処理をしてprofileを取得していた処理を
STWしてgoroutineの数だけbookkeepingだけ行い
世界を再開してからbookkeepingされた情報を元にgoroutine profileの書き込みを行うようにした
その影響でgoroutine profileの固定オーバーヘッドとCPU時間コストが少し増えた
って感じだと思われます

goroutineの数が多いアプリケーションを作ってる人にはうれしい修正だったのではないかと思いました 今後はgoroutineの数が多いアプリケーションを作っている人も安心してprofilingができるので、ぜひとも取得することをおすすめします

逆に、あまりgoroutineの数が多くないアプリケーションの場合はオーバーヘッドが増えただけかもしれません とはいえ、問題になるようなオーバーヘッドではないと思われるのでprofileはどんどん取得して問題はないかなと思います

それではみなさん、ハッピーはProfiling Lifeを!