プログラミング
Goランタイムの理解: プロファイリング
Understanding the Go Runtime: Profiling (internals-for-interns.com)
要約
この記事は、Go言語のランタイムにおけるプロファイリング機能について解説しています。CPU、ヒープ、ブロック、ミューテックス、ゴルーチンの5種類のプロファイルがあり、これらはすべて同じ基盤構造を共有しています。プロファイルデータは、gzip圧縮されたProtocol Buffer形式で保存され、`go tool pprof`で分析可能です。各プロファイルは、信号駆動、インプレース記録、またはオンデマンドスナップショットといった異なる収集モデルを採用しており、CPUプロファイリングは信号によって非同期に、ヒープやブロックなどはイベント発生時にインプレースで、ゴルーチンプロファイリングは要求時に実行されます。
全文翻訳
📚 Goランタイムの理解 (全11回)
▼1. ブートストラップ
2. メモリアロケータ
3. スケジューラ
4. ガベージコレクタ
5. システムモニター
6. ネットワークポーラー
7. スライス、マップ、チャネル
8. select文
9. スタックトレース
10. リフレクトパッケージ
11. プロファイリング
あなたは今ここにいます
前回の記事では、リフレクトパッケージを分解し、その魔法のほとんどはコンパイラが非常に良い注釈を残していること、つまりビルド時に読み取り専用データに凍結された型記述子と、それらをウォークする方法を知っているパッケージであることを発見しました。
記事全体が、mainが始まる前にすでにメモリ上にあったメタデータを読み取ることについてでした。
今日は視点を変えます。
プロファイリングとは、ランタイムが実行中のプログラムをキャッチすることです。何をしていて、どこに時間を費やしているのかをサンプリングし、それを`go tool pprof`で開けるものに集計します。
すべてのサンプルの心臓部はコールスタックであり、スタックトレースの記事で見たのと同じアンワインダーで生成されます。
したがって、プロファイリングは、ビルド時のスタック読み取りの上に座る、ライブモーメントキャプチャです。
しかし、Goにはプロファイルのタイプが1つだけあるわけではありません。5つ提供しています。
CPU、ヒープ、ブロック、ミューテックス、ゴルーチン。
最初は5つの無関係なサブシステムのように見えますが、すべて同じスケルトンを共有しており、一度それを見れば、すべてが1つのアイデアが5つの方法で繰り返されるものに崩壊します。
(6番目のゴルーチンリークプロファイルがGo 1.27で登場予定です。Alex Rios氏がそれについて素晴らしい投稿シリーズを書いています。しかし、今日は出荷されている5つに留めます。)
そこから始めましょう。
すべてが共有する形状
5つのプロファイル間の最も深い類似性は、実際に持ち帰るもの、つまりファイルです。
どのプロファイルを収集したとしても、ディスクに保存されるのは同じ形式であり、驚くほどシンプルです。pprofプロファイル、gzip圧縮されたプロトコルバッファです。
Go固有でさえありません。GoogleのC++プロファイラ(gperftools)が出力するものと同じ形式であり、`go tool pprof`はより広いpprofエコシステムと共有しています。
したがって、各プロファイルがどのように収集されるかを見る前に、それらがすべて収集されるものを見てみましょう。
トップレベルでは1つのProfileメッセージであり、重要な部分は繰り返されるフィールドの数だけです。
message Profile {
repeated ValueType sample_type = 1; // 各サンプル内の数値が何を意味するか
repeated Sample sample = 2; // 実際のデータ
repeated Mapping mapping = 3; // ロードされたバイナリ/共有ライブラリ
repeated Location location = 4; // PC、コード内の場所に解決済み
repeated Function function = 5; // 名前、ファイル、開始行
repeated string string_table = 6; // すべての文字列、重複排除済み
}
巧妙な部分は、インラインで保存されている量が非常に少ないことです。
Sampleはほとんど何もありません。値のリストと場所IDのリストです。リーフが最初です。
message Sample {
repeated uint64 location_id = 1; // コールスタック、参照として
repeated int64 value = 2; // 例: [サンプル数、CPUナノ秒]
repeated Label label = 3; // サンプルに添付された追加のキー/値タグ
}
これらの各ピースは、Profile内の独自のテーブルに存在し、すべてIDで相互に参照しています。
まとめると、全体は次のようになります(簡略化された視覚表現)。
図が可視化するのは間接参照です。
Sampleは関数名やスタックフレームを保持しません。場所IDのリストを保持します。スタックトレース内の各フレームに1つずつ、リーフが最初です。
それらのいずれかを場所テーブルにたどると、Locationに到達します。これは(Lineを介して)Functionを指し、そのアドレスが存在するロードされたバイナリまたは共有ライブラリであるMappingも記録します。
Functionは最終的に人間が読めるビット、つまり名前、ファイル、開始行を保持します。ただし、それらも文字列ではなく、下部にある1つの共有string_tableへの整数インデックスです。
したがって、これはルックアップの連鎖です。sample → location → function → string。
そして何も重複していません。同じLocation、Function、文字列は、コードのその場所に触れるすべてのサンプルによって参照されるため、1万個のサンプルに表示されるスタックは、関数名を一度だけ保存します。
また、各プロファイルの抽象的な「値」が意味を持つのはここです。sample_typeは、各サンプル内の数値が実際に何を測定するかを宣言します。
これが、形状の中でプロファイル間で実際に異なる唯一の部分です。同じコンテナ、列のラベルが異なります。そのため、記事の残りの部分で各プロファイルタイプに到達するにつれてそれを埋めていきます。
プロファイルとは何かについては、ここまでです。
次に、ランタイムが実際にどのようにそれを埋めるかを見てみましょう。
データがそこへ行く方法
どのプロファイルを収集している場合でも、1つのことは一定です。途中でランタイムがアンワインダーでコールスタックをキャプチャし、最後にすべてが先ほど見たpprofファイルとして出力されます。
しかし、スタックがそれらからそれらへどのように移動するかは、5つの間で同じではありません。そして、その違いが本当の話です。
5つは3つの収集モデルに分類されます。
CPUは非同期に記録します。
信号が実行中のスレッドを中断し、ハンドラがスタックをキャプチャしてリングバッファにログを記録し、別のバックグラウンドゴルーチンがバッファがいっぱいになるのをドレインします。
本物のストリーミングパイプラインです。
ヒープ、ブロック、ミューテックスはインプレースで記録します。
イベントが発生すると、スタックはパーセントスタックレコードの長寿命テーブルにハッシュされ、一致するレコードのカウンターがその場でインクリメントされます。
何もストリーミングされず、何もバックグラウンドでドレインされません。データはそこに座って蓄積され、プロファイルは要求時に、要求した瞬間に組み立てられます。
ゴルーチンは実行中にまったく記録しません。
トリガーもバッファもありません。
プロファイルを要求すると、ランタイムはすべてのライブゴルーチンのスタックをその場で、1つのスナップショットでウォークします。
したがって、プロファイルごとに本当に変わるものは単一のノブではなく、収集モデル全体です。
モデルごとにグループ化して、5つをウォークしてみましょう。
CPUプロファイリング: 信号駆動型
これは5つの中で最も特徴的です。そのトリガーはプログラムの外部から完全に発生するためです。
オペレーティングシステムが実行中のスレッドを信号で中断し、「今何をしていた?」と尋ねます。
`pprof.StartCPUProfile`を呼び出すと、ランタイムは実行中のスレッドを中断するために必要なタイマーを設定し(CPUを燃焼させた時間がサンプリングされるように)、結果を収集するためのバックグラウンドゴルーチンを開始します(src/runtime/pprof/pprof.go:888)。
そのバックグラウンドゴルーチンを保持してください。後でそれがなぜ重要なのかがわかります。
タイマーのアームは簡単な部分です。興味深いのは、タイマーのいずれかが発火したときに何が起こるかです。
ティックをキャッチする
すべてのタイマーティックで、プログラムは中断されます。
スレッドは、命令の途中で、ハンドラを実行するために何をしていても引き抜かれ、その後、ちょうど中断した場所から再開することが期待されます。
これにより、ハンドラは非常に厄介な位置に置かれます。メモリを割り当てることができず、通常のロックを取得できません(src/runtime/proc.go:5748)。
その後、ハンドラはアンワインダーでコールスタックをキャプチャし、サンプルの値とラベル(`pprof.Do`で添付できるもの)を記入し、それを保存します。
しかし、覚えておいてください。割り当てたりロックしたりすることはできません。そのため、どちらも要求しない場所に入れる必要があります。
まさにそれがあります。この制約のために設計された構造、ロックフリーで事前に割り当てられたリングバッファ(src/runtime/profbuf.go:91)です。これは、割り当てやロックなしで追加できます。
リングバッファであるため、有限です。ドレインされるよりも速くいっぱいになると、次のサンプルのスペースがなくなります。
そのため、中断されたスレッドをスペースのために待たせるのではなく、バッファはサンプルをドロップし、失われたサンプルの数を数えます(サンプルを失うのは問題ありません。スレッドをフリーズするのは問題外です)。
これは問題のように聞こえます。誰も床にサンプルを落としたくないでしょう。
そして、まさにそこで、最初に開始したバックグラウンドゴルーチンがその価値を発揮します。
そのジョブは、リングバッファをできるだけ速くドレインすることです。まだ何も書き出すのではなく、リングが決していっぱいにならず、サンプルが失われないようにするためです。
プルされた各バッチは、コールスタックをキーとするインメモリマップ(profMap)に折りたたまれ、同じスタックは重複排除され、その場で統合されます。