C++ でのメモリリーク検出器

C++Beginner
オンラインで実践に進む

はじめに

メモリリークは、シニアなプログラマでさえ、いつも最も難しい問題の一つです。メモリリークは、一定期間放置されると進行し続けます。割り当てられたメモリの基本的なリーク現象に加えて、例外ブランチのリークなど、多くの異なる種類のメモリリークがあります。このプロジェクトでは、メモリリーク検出器を実装する方法を学びます。

学ぶこと

  • 演算子 new を上書きする
  • 事前定義マクロ __FILE____LINE__
  • ヘッダーファイル内の静的変数
  • スマートポインタ std::shared_ptr

コードテスト

メモリリークは通常、解放されないままになった不注意に割り当てられたメモリに関連しています。現代のオペレーティングシステムでは、このような解放されていないメモリは、終了する 1 つのアプリケーションによって使用された通常のメモリの後に、オペレーティングシステムによって再利用されます。したがって、一時的なアプリケーションによるメモリリークは深刻な結果を引き起こさないでしょう。

ただし、サーバーなどのアプリケーションを書いている場合、それは常に稼働し続けます。メモリリークにつながるロジックがある場合、メモリのリークが増え続ける可能性が非常に高くなります。最終的に、システムのパフォーマンスが低下し、操作の失敗を引き起こす可能性さえあります。

メモリを継続的に割り当て続ける実行中のプログラムは非常に単純です:

int main() {
    // メモリを永久に割り当て、解放しません
    while(1) int *a = new int;
    return 0;
}

実行中のプログラムのメモリリークを検出するには、通常、アプリケーション内にチェックポイントを設定して、他のチェックポイントに対してメモリが異なるかどうかを分析します。 本質的には、短期間のプログラムメモリリーク検査と似ています。したがって、このプロジェクトのメモリリーク検出器は、短期間のメモリリークに対して実装されます。

以下のテストコードがメモリリークを検出できることを期待しています。このコードでは、割り当てられたメモリを解放せず、例外ブランチによるメモリリークを作成します:

//
//  main.cpp
//  LeakDetector
//
#include <iostream>

// ここでメモリリーク検査を実装します
// #include "LeakDetector.hpp"

// 例外ブランチのリーク動作をテストする
class Err {
public:
    Err(int n) {
        if(n == 0) throw 1000;
        data = new int[n];
    }
    ~Err() {
        delete[] data;
    }
private:
    int *data;
};

int main() {

    // メモリリーク:ポインタ b を解放するのを忘れました
    int *a = new int;
    int *b = new int[12];

    delete a;

    // メモリリーク:コンストラクタのパラメータとして 0 を指定すると例外がリークします
    try {
        Err* e = new Err(0);
        delete e;
    } catch (int &ex) {
        std::cout << "Exception: " << ex << std::endl;
    };

    return 0;
}

#include "LeakDetector.hpp" コードスニペットをコメントアウトすると、このコードは以下のように通常通り実行できます。ただし、もちろん、これが実際にメモリリークにつながることは知っています:

結果:

画像説明

メモリリーク検出器の設計

メモリリーク検出を実現するには、以下の点を考慮しましょう:

  1. new 演算子の後に delete が実行されていない場合、メモリリークが発生します。
  2. 最初に作成されたオブジェクトのデストラクタは常に最後に実行されます。

そのため、上記の 2 点に対応して以下の操作を行うことができます:

  1. new 演算子をオーバーロードする。
  2. 静的オブジェクトを作成し、元のプログラムの最後でデストラクタを呼び出す。

このような 2 つのステップの利点は、元のコードを変更することなくメモリリーク検出を行うことができることです。したがって、LeakDetector.hpp を次のように書くことができます:

//
//  /home/labex/Code/LeakDetector.hpp
//

#ifndef __LEAK_DETECTOR__
#define __LEAK_DETECTOR__

void* operator new(size_t _size, char *_file, unsigned int _line);
void* operator new[](size_t _size, char *_file, unsigned int _line);
// このマクロについては LeakDetector.cpp で説明します
#ifndef __NEW_OVERLOAD_IMPLEMENTATION__
#define new new(__FILE__, __LINE__)
#endif

class _leak_detector
{
public:
    static unsigned int callCount;
    _leak_detector() noexcept {
        ++callCount;
    }
    ~_leak_detector() noexcept {
        if (--callCount == 0)
            LeakDetector();
    }
private:
    static unsigned int LeakDetector() noexcept;
};
static _leak_detector _exit_counter;

#endif

なぜ callCount を設計するのか?callCount により、LeakDetector が一度だけ呼び出されることが保証されます。以下の簡略化されたコードスニペットを考えてみましょう:

// main.cpp
#include <iostream>
#include "test.h"
int main () {
    return 0;
}
// test.hpp
#include <iostream>
class Test {
public:
    static unsigned int count;
    Test () {
        ++count;
        std::cout << count << ",";
    }
};
static Test test;
// test.cpp
#include "test.hpp"
unsigned int Test::count = 0;

最終的な出力は 1, 2, です。 これは、ヘッダーファイル内の静的変数が複数回定義され、.cpp ファイルに含まれるたびに定義されるためです。(場合によっては、main.cpptest.cpp に含まれるでしょう。)ただし、本質的には同じオブジェクトです。

さて、残った問題は:メモリリーク検出器をどのように実装するか? です。

メモリリーク検出器を実装する

メモリリーク検出器を実装しましょう。

既に演算子 new をオーバーロードしているので、メモリ割り当てを手動で管理し、自然にメモリを解放することが容易に考えられます。delete が割り当てられたすべてのメモリを解放しない場合、メモリリークが発生します。さて、問題は:メモリを手動で管理するためにどの構造を使用するか?

双方向リンクリストは良い選択肢です。実際のコードでは、メモリを割り当てるタイミングがわからないため、単方向の線形リストでは不十分ですが、動的構造(たとえば、リンクリスト)の方がはるかに便利です。 また、メモリリーク検出器でオブジェクトを削除する際にも、単方向の線形リストは不便です。

したがって、双方向リンクリストが今のところ最適な選択肢です:

#include <iostream>
#include <cstring>

// __NEW_OVERLOAD_IMPLEMENTATION__ マクロを定義します。
// これにより、演算子 `new` の上書きを防止します。
#define __NEW_OVERLOAD_IMPLEMENTATION__
#include "LeakDetector.hpp"

typedef struct _MemoryList {
    struct  _MemoryList *next, *prev;
    size_t  size;       // 割り当てられたメモリのサイズ
    bool    isArray;    // 割り当てられた配列かどうか
    char    *file;      // リークするファイルの場所
    unsigned int line;  // リークする行
} _MemoryList;
static unsigned long _memory_allocated = 0;     // 解放されていないメモリサイズを保存する
static _MemoryList _root = {
    &_root, &_root,     // 最初の要素のポインタはすべて自身を指す
    0, false,           // 割り当てられたメモリサイズは 0、配列ではない
    NULL, 0             // ファイルなし、行番号は 0
};

unsigned int _leak_detector::callCount = 0;

次に、割り当てられたメモリを手動で管理しましょう:

// _MemoryList の先頭からメモリを割り当てます
void* AllocateMemory(size_t _size, bool _array, char *_file, unsigned _line) {
    // 新しいサイズを計算します
    size_t newSize = sizeof(_MemoryList) + _size;

    // new が既に上書きされているため、割り当てには malloc のみを使用できます。
    _MemoryList *newElem = (_MemoryList*)malloc(newSize);

    newElem->next = _root.next;
    newElem->prev = &_root;
    newElem->size = _size;
    newElem->isArray = _array;
    newElem->file = NULL;

    // ファイル情報がある場合は保存します
    if (_file) {
        newElem->file = (char *)malloc(strlen(_file)+1);
        strcpy(newElem->file, _file);
    }
    // 行番号を保存します
    newElem->line = _line;

    // リストを更新します
    _root.next->prev = newElem;
    _root.next = newElem;

    // 解放されていないメモリサイズに保存します
    _memory_allocated += _size;

    // 割り当てられたメモリを返します
    return (char*)newElem + sizeof(_MemoryList);
}

演算子 delete も同様に:

void  DeleteMemory(void* _ptr, bool _array) {
    // MemoryList の先頭に戻ります
    _MemoryList *currentElem = (_MemoryList *)((char *)_ptr - sizeof(_MemoryList));

    if (currentElem->isArray!= _array) return;

    // リストを更新します
    currentElem->prev->next = currentElem->next;
    currentElem->next->prev = currentElem->prev;
    _memory_allocated -= currentElem->size;

    // ファイル情報用に割り当てられたメモリを解放します
    if (currentElem->file) free(currentElem->file);
    free(currentElem);
}

newdelete の使い方には 2 通りあることを考えて:

void* operator new(size_t _size) {
    return AllocateMemory(_size, false, NULL, 0);
}
void* operator new[](size_t _size) {
    return AllocateMemory(_size, true, NULL, 0);
}
void* operator new(size_t _size, char *_file, unsigned int _line) {
    return AllocateMemory(_size, false, _file, _line);
}
void* operator new[](size_t _size, char *_file, unsigned int _line) {
    return AllocateMemory(_size, true, _file, _line);
}
// 演算子 delete を上書きします
void operator delete(void *_ptr) noexcept {
    DeleteMemory(_ptr, false);
}
void operator delete[](void *_ptr) noexcept {
    DeleteMemory(_ptr, true);
}

最後に、_leak_detector::LeakDetector を実装し、static _leak_detector _exit_counter のデストラクタで呼び出されます。そうすると、すべての割り当てられたオブジェクトが解放されます。メモリリークは、static _MemoryList _root が空でない場合に現れます。メモリのリーク箇所を見つけるには、_root を反復処理するだけです:

unsigned int _leak_detector::LeakDetector(void) noexcept {
    unsigned int count = 0;
    // リスト全体を反復処理します。メモリリークがある場合、
    // _LeakRoot.next は常に自身を指さない
    _MemoryList *ptr = _root.next;
    while (ptr && ptr!= &_root)
    {
        // すべてのリーク情報を出力します
        if(ptr->isArray)
            std::cout << "Leaking[] ";
        else
            std::cout << "Leaking   ";
        std::cout << ptr << ", size of " << ptr->size;
        if (ptr->file)
            std::cout << " (located in " << ptr->file << ", line " << ptr->line << ")";
        else
            std::cout << " (no file info)";
        std::cout << std::endl;

        ++count;
        ptr = ptr->next;
    }

    if (count)
        std::cout << "Existing " << count << " memory leaking behavior, "<< _memory_allocated << " byte in total." << std::endl;
    return count;
}

すべてをコンパイルしましょう:

g++ main.cpp LeakDetector.cpp -std=c++11 -Wno-write-strings

main.cpp では、include "LeakDetector.hpp" のコメントを外します。以下が期待される出力です:

画像説明

まとめ

この実験では、C++ の演算子 new を上書きして、メモリリーク検出器を実装しました。 実際には、このコードはスマートポインタの循環参照リークにも使用できます。main.cpp を次のように変更するだけです:

#include <memory> // スマートポインタを使用する
// 最初は main.cpp の前と同じで変更なし
// 2 つのテスト用のクラスを追加する
class A;
class B;
class A {
public:
    std::shared_ptr<B> p;
};
class B {
public:
    std::shared_ptr<A> p;
};

int main() {
    // ここは main.cpp の前と同じように変更する
    // 追加のケースとして、スマートポインタを使用する
    auto smartA = std::make_shared<A>();
    auto smartB = std::make_shared<B>();
    smartA->p = smartB;
    smartB->p = smartA;
    return 0;
}

最終的な出力は次の通りです:

画像説明

一部の方にとっては、結果が次のようになるかもしれません: 画像説明

あなたの結果は異なる場合があります。上記の 2 つの結果はどちらも正しいです。なぜなら、あなたの環境はマシンやコンパイラによって異なるからです。

まとめ

  1. メモリリーク - ウィキペディア