Máte dost paměti?

31. 3. 2005
Doba čtení: 6 minut

Sdílet

Přestože poučka o tom, že každá alokovaná paměť se musí uvolnit, je zmíněna ve všech učebnicích programování, patrně není programátora, kterému by neuvoněná paměť alespoň jednou nezpůsobila bolení hlavy. Naštěstí nám jazyk C++ dává do rukou jednoduché, ale účinné prostředky k boji s neuvolněnou pamětí, která se v cizojazyčné literatuře označuje jako memory leaks.

Klíčem k úspěchu je okolnost, že C++ umožňuje předefinovat globální operátor new (a pochopitelně též delete). Je třeba si totiž uvědomit, že vytváření instancí pomocí operátoru new vykonává činnosti dvě – alokuje potřebnou paměť a poté volá příslušný konstruktor. Část, která se stará o alokaci paměti, můžeme snadno přetížit; o zavolání konstruktoru se postará překladač, ovšem pouze tehdy, pokud alokace paměti proběhne úspěšně.

Stačí tedy maličkost: při každé alokaci paměti si ukazatel na alokovanou pamět uložíme do seznamu alokované paměti a při každém uvolnění paměti ukazatel ze seznamu odebereme. Při ukončení programu pak stačí zkontrolovat seznam, zda neobsahuje odkazy na neuvolněnou paměť.

Existuje mnoho volně dostupných knihoven, které tuto práci obstarají (namátkou: Valgrind, memprof, mpatrol, eletric fence) a rovněž řada komerčních produktů, přesto ale existují i důvody, abychom se alespoň na princip podívali sami. Zmíněné knihovny totiž často přinášejí nežádoucí závislost na dalších knihovnách nebo je kontrola až příliš „dokonalá“, tj. knihovna umí mnohem více, než ve skutečnosti potřebujeme. V následujících řádcích proto popíšu činnost nejjednodušší kontroly správného uvolňování paměti, která hlídá nejčastější alokace pomocí new/malloc. Rožšíření o kontrolu alokací realloc/calloc/strdup nebo i třeba exotičtější _expand přenechávám laskavému čtenáři.

Hodit se budou struktura popisující alokovanou paměť

struct MemoryPosition{
          //adresa pameti
          void *ptr;
          //popis alokovane pameti (velikost, misto alokace atd.)
          char position[256];
};

a třída, která obstará práci se seznamem alokované paměti.

class MemoryPositionList{
private:
      bool MallocatedMemoryChecked;
      std::list<MemoryPosition*> mlist;
public:
      MemoryPositionList(bool=false);
      ~MemoryPositionList();
      void Add(MemoryPosition*);
      void Remove(void*);
private:
      void Dump();
};

Jako seznam je zde použit seznam z STL, což nemusí být nejšťastnější řešení, s ohledem na proklamovanou míru jednoduchosti (rozuměj: lenost autora) je však alespoň zpočátku akceptovatelné. Vzhledem k tomu, že i v C++ bývá občas výhodné nezapomínat na klasickou alokaci paměti pomocí malloc, vytvoříme dvě statické instance třídy MemoryPositionList. Paměť alokovanou použitím operátoru new budeme zapisovat do jedné instance, paměť alokovanou pomocí malloc budeme zapisovat do druhé. Výpis neuvolněné paměti (metoda Dump) můžeme volat v destruktoru třídy MemoryPositionList. Díky tomu, že destruktor statických objektů volá překladač na konci programu sám, budeme mít zajištěno vypsání neuvolněné paměti na konci programu.

Implementace metod třídy MemoryPositionList je snadná: Jediný parametr konstruktoru určuje, zda sledujeme pamět alokovovanou pomocí new, nebo pomocí malloc:

MemoryPositionList::MemoryPositionList(bool mem){
      MallocatedMemoryChecked=mem;
}

Přidání nově alokované paměti zařadí příslušný popis pozice paměti na konec seznamu (push_back je metoda std::list):

void MemoryPositionList::Add(MemoryPosition *item){
      mlist.push_back(item);
}

Metoda Remove odstraní ze seznamu odkaz na alokovanou paměť zadanou její adresou (ihned je patrné, že držení informací o alokované paměti v seznamu je nevýhodné, protože nalezení požadované položky při odstraňování může být zdlouhavé, a to dokonce tak, že zpomalení bude i pro debugovací účely neúnosné):

void MemoryPositionList::Remove(void *ptr){
    if(mlist.size()==0) return;
    std::list<MemoryPosition*>::iterator i=mlist.begin();
    while (i!=mlist.end()){
        if((*i)->ptr==ptr){
             mlist.remove(*i);
             return;
        }
        i++;
    }
}

Metoda Dump se postará o vypsání neuvolněné paměti, pokud taková existuje, na standardní chybový výstup:

void MemoryPositionList::Dump(){
    std::list<MemoryPosition*>::iterator i=mlist.begin();
    if(mlist.size()>0){
         if(MallocatedMemoryChecked)
              fprintf(stderr,"Memory leaks caused by malloc detected:\n");
         else
              fprintf(stderr, "Memory leaks caused by new detected:\n");
         while (i!=mlist.end()){
              fprintf(stderr,"%X %s\n",(*i)->ptr, (*i)->position);
              i++;
        }
    }
}

A konečně destructor, který výpis neuvolněné paměti při ukončování programu zavolá.

MemoryPositionList::~MemoryPositionList(){
    Dump();
    std::list<MemoryPosition*>::iterator i=mlist.begin();
    while(i!=mlist.end()){
         if(MallocatedMemoryChecked) free(*i);
         else delete(*i);
         i++;
     }
     mlist.clear();
}

V dalším předpokládejme, že máme dvě statické instance třídy MemoryPositionList:

static MemoryPositionList MallocMemory(true);
static MemoryPositionList NewMemory(false);

Stačí tedy již jen předefinovat operátor new. K tomu potřebujeme vědět, že kromě standardního new(size_t) mohou existovat i přetížené operátory s větším počtem parametrů. V našem případě tedy např.

void* operator new (size_t size, char const * file, int line){
    void *ptr=malloc(size);
    MemoryPosition *m=(MemoryPosition*) malloc(sizeof(MemoryPosition));
    m->ptr=ptr;
    sprintf(m->position,"%s:%d (%d bytes)", file, line, size);
    NewMemory.Add(m);
    return ptr;
}

Zbývá doplnit, že operátor new vrací ukazatel na void, paměť alokovanou pomocí malloc tak není třeba nijak přetypovávat. Při volání operátoru new se první parametr size_t vynechává, neboť velikost objektu doplní překladač za nás. K tomu, abychom věděli, kde paměť alokujeme, pak využijeme standardní makra __FILE__ a __LINE__, za která překladač dosadí jméno zdrojového souboru a číslo řádky. Výše uvedený operátor new pak můžeme zavolat třeba tako:

int *i= new(__FILE__, __LINE__) int;

Nejnovější norma jazyka C, označovaná jako C99, pak navíc zavádí identifikátor __func__, které obsahuje název funkce. Překladač gcc jde ještě dále, když zavádí identifikátory __FUNCTION__ a __PRETTY_FUNCTI­ON__. První je shodný s  __func__, druhý se v C++ rozvíjí v „košatější“ název funkce. Pro úplnost je třeba dodat, že zmíněné identifikátory jsou implicitně kompilátorem deklarovány tak, jako by ve funkci bylo deklarováno

static const char __func__[] = "jmeno_funkce";

a z toho pramení i rozdílné použití v porovnání s makry __FILE__LINE__. Používáme-li např. překladač gcc, můžeme definovat nadstandardní new třeba takto:

void* operator new (size_t size, char const * file, int line, char const *func){
     void *ptr=malloc(size);
     MemoryPosition *m=(MemoryPosition*) malloc(sizeof(MemoryPosition));
     m->ptr=ptr;
     sprintf(m->position,"%s:%d (%d bytes), %s", file, line, size, func);
     NewMemory.Add(m);
     return ptr;
}

Odpovídající operátor delete musí kromě uvolnění paměti ještě odebrat paměť ze seznamu alokované paměti:

void operator delete (void * ptr){
    NewMemory.Remove(ptr);
    free (ptr);
}

Úplně stejně, jako jsme definovali vlastní operátor new, je třeba definovat i operátor new[] a odpovídající delete[]. Pro sledování alokace pomocí malloc pak ještě přidejme debugovací verze dmalloc a dfree takto:

void *dmalloc(const char *file, int line, size_t size){
    void *ptr=malloc(size);
    MemoryPosition *m=(MemoryPosition*)malloc(sizeof(MemoryPosition));
    m->ptr=ptr;
    sprintf(m->position,"%s:%d (%d bytes)", file, line, size);
    MallocMemory.Add(m);
    return ptr;
}

void dfree(const char *file, int line, void *ptr){
    MallocMemory.Remove(ptr);
    free(ptr);
}

Nyní jsme prakticky hotovi, máme k dispozici soubory: memory_check.cpp, memory_check.h a use_memory_chec­k.h a zbývá je použít v praxi. Krátký testovací prográmek

#include <cstdlib>
#include <iostream>

#include "use_memory_check.h"

class testClass{
public:
    testClass();
private:
    int m;
};

testClass::testClass(){m=0;}

int main(){
    testClass *m;
    m=new testClass;
    return 0;
}

nám vypíše kupříkladu:

Memory leaks caused by new detected:
440030 c:\martin\root\memory\main.cpp:16 (4 bytes)

K úplné spokojenosti ale stále chybí několik detailů. Předně náš nový operátor new neošetřuje případ, kdy nedojde k alokaci požadované paměti, třeba z důvodu, že jí není dostatek. Standardní operátor new by v takové situaci měl vyhodit výjimku bad_alloc. Některé překladače (např. Visual C++) nás proto varují:

ict ve školství 24

warning C4291: ‚void *__cdecl operator new(unsigned int,const char *,int)‘: no matching operator delete found; memory will not be freed if initialization throws an exception

Rovněž držení informací o alokované paměti v seznamu (natož v std::list) není příliš efektivní a spíše by se hodilo použití hašovacích tabulek. A konečně, známou chybu, kdy pole je akolováno pomocí new[], ale „smazáno“ použitím delete (což je pochopitelně memory leak) takto jednoduše napsaná kontrola neodhalí. A to jsme se ještě nezmínili o těžkostech ve vícevláknových aplikacích… Opravdu nechcete použít Valgrind?