Go 製ソフトウェアでメモリ使用量の多い関数を特定する

みなさんこんにちは.SRE チームの内田(@uchan_nos)です.

この記事では Go 製ソフトウェアのどの関数がどれだけメモリを消費しているかを調べる方法を説明します.

Go 製ソフトウェアのヒープメモリの消費量を調べる方法はたくさん解説されているものの,スタックメモリの消費量について調べる方法を説明したサイトを見つけることはできませんでした. この記事では主にスタックメモリの消費量を調べる方法を説明します.

背景

SRE では Go 言語で自社データセンター向けのツール群をたくさん作っています. その中のソフトウェアの 1 つが,本番運用中に予想外にたくさんのメモリを使用してしまうという問題がありました. どの関数が原因なのかを突き止めるために,関数単位でメモリ使用量を調べる必要があります.

ソフトウェアが使っているメモリ量の概況は,Linux であれば top コマンドで調べることができます. コマンド出力のうち VIRT が確保済み仮想メモリの量,RES が使用中の物理メモリの量を表します. 今回は RES の量が予想以上に大きくなっており,その原因を調査するというのがミッションです.

Go 製ソフトウェアであれば pprof パッケージにより簡単にヒープメモリの使用状況は調査ができますので,まずは pprof を使ってみました. しかし,pprof によるヒープメモリの調査結果と top コマンドの RES の値が大きく食い違っており,原因はヒープではないと分かりました.

pprof の他に expvar というパッケージを使い,メモリの各種統計値を見てみました. するとスタックメモリの使用量を表す StackInuse 項目が,ヒープメモリの使用量を表す HeapAlloc に比べて大きな値になっていることが判明しました. RES の値が StackInuse + HeapAlloc に近い値だったため,メモリ使用の大半がスタックによるものだと推測し,スタックの使用状況を調べることにしました.

スタックの使用状況の調べ方

今回私が思いついたスタックの使用状況を調査するアイデアはこうです.

  1. 実行バイナリから,関数毎のスタック使用量を見積もる
  2. goroutine のスタックトレースを取得する
  3. 両者を組み合わせてどの goroutine が何バイトのスタックを使用しているかを得る

1 をどうやるかがこの記事の主題と言っても過言ではありません. アイデアは単純で,sub rsp というアセンブリ命令に着目するのです.

Go のコンパイラは,スタックフレームを確保するためにスタックポインタ(RSP)から必要な大きさを減じるための命令を関数の処理の先頭に埋め込みます. それは典型的に次のようなアセンブリ命令になります.

sub  rsp, 0x10

上記の例では RSP から 16 を引きます.すなわち,スタック領域として 16 バイトを確保するということです. Go コンパイラが生成した関数は,ぱっと眺める限り,関数の先頭でスタックフレームを確保した後は他の関数を呼ぶまで RSP は変化しません. 従って,実行ファイルの逆アセンブル結果から各関数について sub rsp を探せば 1 はほぼ達成です. 後は,call 命令のための 8 バイトを加算するなど,細かい調整をすれば,割と良い精度で関数のスタック使用量を見積もれます.

2 は簡単です.pprof を用いて /debug/pprof/goroutine?debug=1 とすれば得ることができます.

3 は,基本的には各 goroutine のスタックトレースに登場する関数のスタック量の総和を取れば計算できます. 後は細かい調整です. goroutine のスタックの初期値は 2KiB1 ですから,関数のスタック量の総和が 2KiB より小さければ 2KiB に切り上げます. スタックが溢れそうになると 2 倍ずつに増えてきます2ので,計算値が 2KiB より大きければ 2 のべき数に切り上げます.

gostackamount

以上のようなアイデアを手動実行するのは非現実的です. 実際にはツールを作っていますので,OSS で公開しました. 是非ご利用ください.

https://github.com/uchan-nos/gostackamount


  1. src/runtime/stack.go_StackMin 定数により決まる

  2. src/runtime/stack.go newsize := oldsize * 2