C++ 를 이용한 메모리 누수 감지기

C++Beginner
지금 연습하기

소개

메모리 누수는 숙련된 프로그래머에게도 항상 가장 어려운 문제 중 하나였습니다. 시간이 지나도 메모리 누수는 여전히 발생합니다. 할당된 메모리의 기본적인 누수 현상 외에도, 예외 분기 누수 등 다양한 유형의 메모리 누수가 있습니다. 이 프로젝트는 메모리 누수 감지기를 구현하도록 안내합니다.

학습 내용

  • 연산자 new 오버라이드 방법
  • 미리 정의된 매크로 __FILE____LINE__
  • 헤더 파일의 정적 변수
  • 스마트 포인터 std::shared_ptr

코드 테스트

메모리 누수는 일반적으로 의도치 않게 할당되어 해제되지 않은 메모리와 관련이 있습니다. 현대 운영 체제에서, 이러한 해제되지 않은 메모리는 종료된 하나의 애플리케이션이 사용한 일반적인 메모리 이후에도 운영 체제에 의해 회수되므로, 일시적인 애플리케이션으로 인한 메모리 누수는 심각한 결과를 초래하지 않습니다.

그러나 서버와 같은 애플리케이션을 작성하는 경우, 항상 실행 중입니다. 메모리 누수를 유발하는 로직이 있다면, 메모리 누수가 계속 증가할 가능성이 높습니다. 결국 시스템의 성능이 저하되고 작동 실패를 초래할 수도 있습니다.

메모리를 지속적으로 할당하는 실행 중인 프로그램은 매우 간단합니다.

int main() {
    // allocates memory forever, never release
    while(1) int *a = new int;
    return 0;
}

실행 중인 프로그램에서 메모리 누수를 감지하기 위해, 일반적으로 애플리케이션에 검사 지점 (checkpoint) 을 설정하여 다른 검사 지점과 비교하여 메모리가 달라지는지 분석합니다.

본질적으로, 이는 단기 프로그램 메모리 누수 검사와 유사합니다. 따라서 이 프로젝트의 메모리 누수 감지기는 단기 메모리 누수를 위해 구현될 것입니다.

다음 테스트 코드가 메모리 누수를 감지하기를 바랍니다. 이 코드에서는 할당된 메모리를 해제하지 않고 예외 분기로 인해 메모리 누수를 생성합니다.

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

// Implements memory leak inspection here
// #include "LeakDetector.hpp"

// testing exception branch leaking behavior
class Err {
public:
    Err(int n) {
        if(n == 0) throw 1000;
        data = new int[n];
    }
    ~Err() {
        delete[] data;
    }
private:
    int *data;
};

int main() {

    // Memory Leak: forget release pointer b
    int *a = new int;
    int *b = new int[12];

    delete a;

    // Memory Leak: 0 as constructor parameter causes exception leaking
    try {
        Err* e = new Err(0);
        delete e;
    } catch (int &ex) {
        std::cout << "Exception: " << ex << std::endl;
    };

    return 0;
}

#include "LeakDetector.hpp" 코드 조각을 주석 처리하면, 이 코드는 아래와 같이 정상적으로 실행될 수 있습니다. 물론, 실제로는 메모리 누수를 유발한다는 것을 알고 있습니다.

결과:

image desc

메모리 누수 감지기 설계

메모리 누수 감지를 달성하기 위해, 다음 사항을 고려해 보겠습니다.

  1. new 연산 후 delete가 수행되지 않으면 메모리 누수가 발생합니다.
  2. 처음 생성된 객체의 소멸자는 항상 마지막에 실행됩니다.

그런 다음, 위의 두 가지 사항에 해당하는 다음 작업을 수행할 수 있습니다.

  1. new 연산자 오버로딩 (overload)
  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);
// This macro will be described in 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를 설계하는 이유는 무엇일까요? callCountLeakDetector가 한 번만 호출되도록 보장합니다. 다음의 단순화된 코드 조각을 고려해 보겠습니다.

// 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가 할당된 모든 메모리를 해제하지 않으면 메모리 누수가 발생합니다. 이제 문제는 다음과 같습니다. 메모리를 수동으로 관리하기 위해 어떤 구조를 사용해야 할까요?

양방향 연결 리스트 (two-way linked list) 가 좋은 선택입니다. 실제 코드에서는 메모리를 할당해야 할 때를 알 수 없으므로, 단방향 선형 리스트 (one-way linear list) 는 충분하지 않지만, 동적 구조 (예: 연결 리스트) 가 훨씬 더 편리할 것입니다.
또한, 단방향 선형 리스트는 메모리 누수 감지기에서 객체를 삭제할 때도 편리하지 않습니다.

따라서, 현재로서는 양방향 연결 리스트가 최선의 선택입니다.

#include <iostream>
#include <cstring>

// Defining __NEW_OVERLOAD_IMPLEMENTATION__ macros,
// it prevents overwrite operator `new`
#define __NEW_OVERLOAD_IMPLEMENTATION__
#include "LeakDetector.hpp"

typedef struct _MemoryList {
    struct  _MemoryList *next, *prev;
    size_t  size;       // size of allocated memory
    bool    isArray;    // either allocated array or not
    char    *file;      // locating leaking file
    unsigned int line;  // locating leaking line
} _MemoryList;
static unsigned long _memory_allocated = 0;     // saving unreleased memory size
static _MemoryList _root = {
    &_root, &_root,     // pointers of first element all pointing to itself
    0, false,           // 0 allocated memory size, not array
    NULL, 0             // no file, line 0
};

unsigned int _leak_detector::callCount = 0;

이제 할당된 메모리를 수동으로 관리해 보겠습니다.

// allocating memory starts from the head of _MemoryList
void* AllocateMemory(size_t _size, bool _array, char *_file, unsigned _line) {
    // calculate new size
    size_t newSize = sizeof(_MemoryList) + _size;

    // we can only use malloc for allocation since new has already been overwited.
    _MemoryList *newElem = (_MemoryList*)malloc(newSize);

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

    // save file info if exitsting
    if (_file) {
        newElem->file = (char *)malloc(strlen(_file)+1);
        strcpy(newElem->file, _file);
    }
    // save line number
    newElem->line = _line;

    // update list
    _root.next->prev = newElem;
    _root.next = newElem;

    // save to unreleased memory size
    _memory_allocated += _size;

    // return allocated memory
    return (char*)newElem + sizeof(_MemoryList);
}

그리고 delete 연산자도 마찬가지입니다.

void  DeleteMemory(void* _ptr, bool _array) {
    // back to the begining of MemoryList
    _MemoryList *currentElem = (_MemoryList *)((char *)_ptr - sizeof(_MemoryList));

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

    // update list
    currentElem->prev->next = currentElem->next;
    currentElem->next->prev = currentElem->prev;
    _memory_allocated -= currentElem->size;

    // release allocated memory for file information
    if (currentElem->file) free(currentElem->file);
    free(currentElem);
}

newdelete를 사용하는 두 가지 다른 방법을 고려해 보겠습니다.

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);
}
// overwrite operator 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;
    // iterates the whole list, if there is a memory leak,
    // then _LeakRoot.next alwasy doesn't pointing to itself
    _MemoryList *ptr = _root.next;
    while (ptr && ptr != &_root)
    {
        // output all leaking information
        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"의 주석 처리를 해제합니다. 예상되는 출력은 다음과 같습니다.

image desc

요약

이 랩에서는 C++ 에서 new 연산자를 오버라이드하고 메모리 누수 감지기를 구현했습니다.
사실, 이 코드는 스마트 포인터 (smart pointer) 순환 참조 누수에도 사용할 수 있습니다. main.cpp를 다음과 같이 변경하기만 하면 됩니다.

#include <memory> // use smart pointer

// no changes at the begining as previous in main.cpp

// add two testing class
class A;
class B;
class A {
public:
    std::shared_ptr<B> p;
};
class B {
public:
    std::shared_ptr<A> p;
};

int main() {

    // changes here as previous in main.cpp

    // additional cases, use smart pointer
    auto smartA = std::make_shared<A>();
    auto smartB = std::make_shared<B>();
    smartA->p = smartB;
    smartB->p = smartA;

    return 0;

}

최종 출력은 다음과 같습니다.

image desc

일부 사용자의 경우 결과는 다음과 같을 수 있습니다.

image desc

결과는 다를 수 있습니다. 위의 두 결과 모두 올바르며, 이는 사용자의 환경 (machine 및 compiler) 에 차이가 있을 수 있기 때문입니다.