valgrindが検出するメモリリークの種類

C/C++のメモリリークチェックツールと言えばvalgrindが有名だが、メッセージの詳細を日本語で説明しているサイトはあまりないようなので調べてみた。

valgrindは、mallocやnewで確保されたメモリ領域がポインタによって辿れるかどうかをチェックすることでメモリリークを検出する。以下の例を見てみよう。

char *g_ptr;

void func(){
    char *p = new char[32];
    // (A)
}

int main(){
    func();
}

main関数から始まり、そこからfuncが呼ばれ、現在 (A) の位置にいるとしよう。このとき、メモリ上にはmain関数とfunc関数で定義された変数があり、また、グローバル変数であるg_ptrも有効な状態で存在している。func関数が抜けると変数pは無効になる。

このpやg_ptrのようなデータのことをroot-setと呼ぶ。valgrindの公式マニュアルにある公式な定義では、root-setは以下の2つからなる。

  • general purpose registers of all threads(全スタックの汎用レジスタ)
  • initialised, aligned, pointer-sized data words in accessible client memory, including stacks(スタックを含むアクセス可能なクライアントメモリにある、初期化された、整列された、ポインタサイズのデータワード)

この説明だと分かりにくいが、実際には

  • グローバル変数や関数内static変数などの静的データ
  • スタック中のデータ
  • レジスタ中のデータ

ということだと思う。また、メモリリークのチェックはプログラム終了時の状態を見るものなので、つまりmain関数も終わった状態なので、スタック領域は基本的にはない(実際にはmain関数が最後の関数というわけではないが、一般的なプログラムソースコードに書かれているような自動変数はすべてなくなっている)。

valgrindはプログラム終了時に、ヒープ領域中に確保されたブロックがroot-setから辿れるかどうかをチェックすることでメモリリークを検出する。また、その状況によって以下の4種類に分類される。場合によっては修正する必要がないケースもある。

  • definitely lost
  • indirectly lost
  • possibly lost
  • still reachable

definitely lost

definitely lostは、ヒープ中のブロックを参照するポインタがroot-set上にないということを意味する。以下に簡単な例を上げる。

int main(int, char**){
    char* p = new char[128];
}

このmain関数の中でnewによってヒープ領域に128バイト確保され、ポインタpはその領域へのポインタとなる。pはmain関数で定義されたもので、スタック領域に確保されている。そのため、main関数が終わればpは削除される。その結果、newで確保された領域は解放されないまま、どこからも指されていない状態になる。このような領域はdefinitely lostとなる。

main関数実行中
 root-set     Heap
+-------+    +--------------------------------------+
|   p---------> [128バイトデータ]                   |
+-------+    +--------------------------------------+
main関数終了後
 root-set     Heap
+-------+    +--------------------------------------+
|       |    |  [128バイトデータ]                   |
+-------+    +--------------------------------------+

indirectly lost

indirectly lostは、そのブロックを参照するポインタはあるがroot-setからは辿れない状態を指す。以下に例を挙げる。

class B {
};

class A {
public:
    A() : m_b(new B) {}
    ~A() {
        delete m_b;
    }
private:
    B* m_b;
};

int main(){
    A* a = new A;
}

この例では、クラスAをnewで確保し、さらにAのコンストラクタでクラスBを確保している。その結果、メモリの状態は以下のようになる。

 root-set     Heap
+-------+    +---------------------------------+
|   a---------> [A instance] ---> [B instance] |
+-------+    +---------------------------------+

main関数が終了し、ポインタaがなくなると、以下のようになる。

 root-set     Heap
+-------+    +---------------------------------+
|       |    |  [A instance] ---> [B instance] |
+-------+    +---------------------------------+

ここで、BインスタンスはAインスタンスが参照されているが、参照元を辿っていってもroot-setには辿りつけない。つまり、root-setからは辿れない。この状態をindirectly lostと呼ぶ。一方インスタンスAはどこからも参照されないのでdefinitely lostとなる。

indirectly lostもたいていの場合は修正するべきものだが、まずはdefinitely lostの修正を優先するのが良いだろう。というのも、indirectly lostの領域の解放は、それを参照している参照元インスタンスの責任である場合があるからだ。そのため、valgrindはデフォルトではindirectly lostについては詳細を表示しない。

この例でもAのデストラクタでBも解放するようになっているため、main関数でaをdeleteすればBも解放される。そのため、「Bインスタンスを解放する」という修正を直接行う必要はない。

一方、もしAのデストラクタがBを解放しない場合、aをdeleteするとBインスタンスだけが残る。

 root-set     Heap
+-------+    +------------------------------------+
|       |    |                       [B instance] |
+-------+    +------------------------------------+

すると今度はBインスタンスがdefinitely lostになるので、改めて修正方法を検討しよう。

still reachable

プログラム終了時にヒープ領域にデータが残っているが、それがroot-setから辿れる状態にあることをstill reachableと呼ぶ。以下に例を挙げる。

char* g_ptr1;

int main(){
    g_ptr1 = new char[128];
}

この例では、newで確保された領域がグローバル変数g_ptr1に参照されたままになる。このような状態はstill reachableとなる。

still reachableもデフォルトでは詳細は表示されない。still reachableは修正する必要がない場合が多い。例えばグローバル変数にシングルトンインスタンスとして使うようなデータが保持されている場合、必ずしもそれを解放する必要はないだろう。

possibly lost

possibly lostは、malloc/newで確保された領域の内の先頭以外のアドレスが参照されている場合に起こる。以下に例を挙げる。

char* g_ptr2;

int main(){
    char* p = new char[128];
    g_ptr2 = &p[10];
}

この例ではnewで確保された領域の先頭をpが参照しているが、g_ptr2はその領域の途中を指している。

 p   g_ptr2
 |    |
 v    v
 +----------------------+
 |Heap area             |
 +----------------------+

main関数が終わるとpはなくなり、g_ptr2だけが残る。すると、確保された領域の先頭ではない場所を参照するポインタのみが残る。このような状態をpossibly lostと呼ぶ。

possibly lostは一般に修正するべきものであり、valgrindもデフォルトで詳細を表示する。上に挙げた例は恣意的に起こした例だが、公式にマニュアルにはpossibly lostになる例がいくつか挙げれられている。一例としては、いくつかのstd::stringの実装は、確保された領域の先頭3ワードは文字列の長さ、領域のサイズ、参照カウントに使われるため、文字列データへのポインタは確保領域の途中から始まるらしい。

メッセージの表示・抑制

valgrindでメモリリークのチェックをするなら、--leak-check=fullを付けよう。でないとリークした領域を確保したソースコード位置など、修正に必要な情報が表示されない。

また、デフォルトでは詳細情報についてはdefinitely lostとpossibly lostしか表示されない。indirectly lostやstill reachableを表示するには--show-leak-kindsオプションを使う。引数はdefinite、indirect、possible、reachableをカンマ区切りで繋げる。また、すべて表示したい場合はallとする。

 # indirectly lostとstill reachableのみ表示する
 $ valgrind --leak-check=full --show-leak-kinds=indirect,reachable prog-name
 # すべての種類について表示する
 $ valgrind --leak-check=full --show-leak-kinds=all prog-name

特定のリークを無視したい場合

特定のリークを無視したい場合(誤検出である場合、依存ライブラリの問題であり自分では修正できない場合など)、表示を抑制することもできる。"valgrind suppressions"で調べれば色々出てくると思う。

まとめ

  • リークの種類の違いを理解しよう
    • definitely lostは修正するべき。
    • possible lostも修正するべき。
    • indirectly lostは直接修正するのではなく、definitely lostを直そう。それでダメならdefinitely lostに変わっているだろう。
    • still reachableは多くの場合修正する必要はない。
  • 必要に応じて--show-leak-kindsオプションを使って不要なメッセージを抑制しよう。
  • 特定の箇所に関する表示を抑制することもできる。