Detector de Vazamento de Memória com C++

C++Beginner
Pratique Agora

Introdução

O vazamento de memória (memory leak) sempre foi um dos problemas mais difíceis, mesmo para programadores experientes. O vazamento de memória ainda persiste mesmo após um certo tempo. Além do fenômeno básico de vazamento de memória alocada, existem muitos tipos diferentes de vazamento de memória, como vazamento em ramificações de exceção, etc. Este projeto guia você na implementação de um detector de vazamento de memória.

Tópicos a Aprender

  • Como sobrecarregar o operador new
  • Macros predefinidas __FILE__ e __LINE__
  • Variáveis estáticas em arquivos de cabeçalho (headfile)
  • Ponteiro inteligente std::shared_ptr

Teste de Código

Vazamentos de memória geralmente estão relacionados à memória alocada inadvertidamente que permanece não liberada. Em sistemas operacionais modernos, essa memória não liberada ainda será recuperada pelo sistema operacional após a memória regular ter sido usada por uma aplicação que é terminada, portanto, um vazamento de memória causado por uma aplicação transitória não causará consequências sérias.

No entanto, se estivermos escrevendo uma aplicação como um servidor, ela estará sempre em execução. Se houver alguma lógica que leve a um vazamento de memória, há uma alta possibilidade de que ela continue aumentando o vazamento de memória. Eventualmente, o desempenho do sistema será reduzido e pode até causar falha na operação.

Um programa em execução que continuamente continua alocando memória é bastante simples:

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

Para detectar vazamentos de memória em um programa em execução, geralmente definimos pontos de verificação (checkpoints) na aplicação para analisar se a memória se torna diferente em relação a outros pontos de verificação. Essencialmente, é semelhante a uma inspeção de vazamento de memória de curto prazo do programa. Portanto, o detector de vazamento de memória neste projeto será implementado para um vazamento de memória de curto prazo.

Esperamos que o seguinte código de teste possa detectar um vazamento de memória. Neste código, não liberamos a memória alocada e criamos um vazamento de memória causado por uma ramificação de exceção:

//
//  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;
}

Se comentarmos o trecho de código #include "LeakDetector.hpp", então este código pode ser executado normalmente, como mostrado abaixo. No entanto, é claro que sabemos que ele realmente leva a vazamentos de memória:

O Resultado:

image desc

Design do Detector de Vazamento de Memória

Para alcançar a detecção de vazamento de memória, vamos considerar os seguintes pontos:

  1. Vazamentos de memória são gerados se delete não foi executado após a operação new.
  2. O destrutor do primeiro objeto criado sempre executa no final.

Então, podemos realizar as seguintes ações correspondentes aos dois pontos acima:

  1. Sobrecarga do operador new.
  2. Criar um objeto estático e chamar o destrutor no final do programa original.

O benefício dessas duas etapas é que a detecção de vazamento de memória pode ser feita sem modificar o código original. Assim, podemos escrever 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

Por que projetar um callCount? O callCount garante que nosso LeakDetector só possa ser invocado uma vez. Considere o seguinte trecho de código simplificado:

// 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;

A saída final é 1, 2,.
Isso ocorre porque as variáveis estáticas no arquivo de cabeçalho são definidas várias vezes e serão definidas toda vez que forem incluídas em um arquivo .cpp. (Neste caso, será incluído em main.cpp e test.cpp.) No entanto, essencialmente, eles são o mesmo objeto.

Agora, a questão restante é: Como implementar um detector de vazamento de memória?

Implementar um Detector de Vazamento de Memória

Vamos implementar nosso detector de vazamento de memória.

Como já sobrecarregamos o operador new, é fácil pensar em gerenciar a alocação de memória manualmente e liberar a memória naturalmente. Se nosso delete não liberar toda a memória alocada, então ocorrerá um vazamento de memória. Agora, a pergunta é: Que estrutura devemos usar para gerenciar a memória manualmente?

Lista duplamente encadeada é uma boa opção. Para o código real, não sabemos quando precisamos alocar memória, portanto, uma lista linear unidirecional não é boa o suficiente, mas uma estrutura dinâmica (por exemplo, uma lista encadeada) será muito mais conveniente. Além disso, uma lista linear unidirecional também não é conveniente ao excluir o objeto no detector de vazamento de memória.

Assim, a lista duplamente encadeada é nossa melhor opção no momento:

#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;

Agora, vamos gerenciar a memória alocada manualmente:

// 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);
}

e o operador delete também:

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);
}

Considerando que temos duas maneiras diferentes de usar new e delete:

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);
}

Finalmente, precisamos implementar _leak_detector::LeakDetector e ele será chamado no destrutor de static _leak_detector _exit_counter. Então, todos os objetos alocados serão liberados. O vazamento de memória aparece no caso em que static _MemoryList _root não está vazio, e só precisamos iterar _root para encontrar onde está o vazamento de memória:

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;
}

Vamos compilar tudo:

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

Em main.cpp, descomente o include "LeakDetector.hpp". Aqui está a saída esperada:

image desc

Resumo

Neste laboratório, sobrecarregamos o operador new em C++ e implementamos um detector de vazamento de memória.
Na verdade, o código também pode ser usado para vazamento de referência circular de ponteiro inteligente. Só precisamos mudar main.cpp da seguinte forma:

#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;

}

Nossas saídas finais são:

image desc

Para alguns de vocês, talvez o resultado seja:
image desc

Seu resultado pode ser diferente. Cada um dos dois acima está correto, porque seu ambiente pode ter diferenças na máquina e no compilador.