Ten artykuł pochodzi z mojego starego bloga na kacperkolodziej.pl.
Nowoczesne narzędzia dostarczone w bibliotece standardowej języka C++11 ułatwiają i czynią bardziej efektywną walkę z wyciekami i błędami pamięci. Z pozoru niegroźne problemy mogą całkowicie położyć naszą aplikację. Nauczmy się jak je rozpoznawać i unikać.
Czym jest wyciek pamięci?
Zacznijmy od tego, że programując w C++ sami możemy zarządzać rezerwowaniem i
zwalnianiem pamięci. Kiedy tworzymy zwykłą zmienną, ma ona przydzielone swoje
miejsce w pamięci. Miejsce to jest zwalniane kiedy wychodzimy poza zakres
ważności zmiennej, czyli za blok ograniczony nawiasami klamrowymi {}. Tutaj
nie ma ryzyka powstawania wycieków pamięci, ponieważ jest ona na bieżąco
zwalniana. Język C++ daje nam jednak dużą swobodę i możemy także rezerwować
pamięć dynamicznie, czyli wtedy kiedy potrzebujemy, tyle ile jej potrzebujemy i
na ile ją potrzebujemy. Ostatnia cecha pamięci alokowanej dynamicznie oznacza,
że sami musimy zadbać o to, żeby zwolnić pamięć kiedy nie będzie już potrzebna.
Jeżeli tego nie zrobimy w odpowiednim momencie, powstaje wyciek pamięci.
Skutki wycieków pamięci
Skutkiem każdego wycieku jest nadmierne zużycie pamięci operacyjnej przez aplikację. W zależności od tego, ile jej tracimy to zjawisko jest mniej lub bardziej groźne. Zauważmy, że jeżeli jakaś funkcja, która nie zwalnia pamięci alokowanej dynamicznie jest wywoływana setki lub tysiące razy w ciągu każdej godziny działania aplikacji, to po pewnym czasie program może zużywać kilkukrotnie więcej pamięci niż faktycznie jest mu to potrzebne. Oczywiście po wyłączeniu programu do pracy ruszy systemowy garbage collector (tzw. śmieciarz), czyli program, który zwalnia pamięć, którą zajmował program.
W przypadku programów codziennego użytku drobne wycieki są w zasadzie niezauważalne. Sytuacja ma się gorzej w przypadku programów, które działają bez przerwy przez kilka dni, tygodni czy nawet miesięcy. Przykładami takich aplikacji są serwery www, dns, plików, pocztowe, baz danych itp. Jeżeli taki program nie zwalnia na bieżąco niepotrzebnej pamięci, to po kilku tygodniach jej zużycie będzie znacznie większe. W efekcie może to prowadzić do spowolnienia działania komputera, a w końcu siłowego „zabicia procesu" aplikacji.
Wykrywanie
Wykrywaniem wycieków pamięci zajmują się wyspecjalizowane aplikacje. Jedną z nich jest valgrind. Program ten jest przeznaczony na systemy oparte na jądrze linuxa (lub inne zgodne z nim). Aby przetestować nasz program, musimy posiadać jego binarną (uruchamialną) wersję.
Jak korzystać?
Valgrind testuje program podczas jego działania. Nie interesuje go kod źródłowy, a nawet sam kod binarny. Musimy po prostu z aplikacji skorzystać. Problemem może być to, że jeżeli podczas testowego uruchomienia nie skorzystamy z jakiejś funkcji, w której jest wyciek, to valgrind tego wycieku nie wykryje.
Uruchomienie aplikacji w valgrind powinno wyglądać tak:
valgrind [opcje valgrinda] /sciezka/do/programu parametry startowe programu
Opcje programu valgrind możemy sprawdzić korzystając z polecenia:
valgrind --help
Do uzyskania pełnej informacji o miejscach wycieków należy wykorzystać opcję
--leak-check=full.
Przykłady wycieków pamięci
Przykład 1
#include <iostream>
int main(int argc, char** argv)
{
int *x = new int[1000];
{
int y;
int *z = new int[1000];
} // wskaźnik z i zmienna y przestają istnieć
delete [] x; // zwalniamy pamięć zaalokowaną w 5 wierszu
return 0;
}
W programie tworzymy trzy zmienne. Dwie z nich (x i z) to zmienne wskaźnikowe. Tworzymy też jeden zakres przy pomocy nawiasów klamrowych. Na samym początku alokujemy 1000 obiektów int w pamięci. Następnie wchodzimy do zakresu, w którym definiujemy zmienną y i wskaźnik z. Po wyjściu z zakresu, zmienne są usuwane. Dotyczy to również zmiennych wskaźnikowych, ale nie tego na co one pokazują. Straciliśmy adres 1000 elementów typu int — nie możemy teraz zwolnić zajmowanej przez nie pamięci.
Przykład 2
Nawet jeżeli zadbamy o zwolnienie pamięci operatorem delete (lub delete[]
dla tablic), może zdarzyć się, że pomiędzy alokacją a dealokacją pamięci
zostanie rzucony wyjątek:
#include <iostream>
#include <stdexcept>
int main(int argc, char** argv)
{
try {
int *x = new int[1000];
throw std::logic_error("logic_error");
delete [] x;
} catch (std::logic_error &e)
{
std::cerr << "złapano wyjątek: " << e.what() << "\n";
return 1;
}
}
Jeżeli program będzie działał dalej, a takie przypadki będą się powtarzały, to po jakimś czasie będziemy mieli ogromną ilość nieużywanej i nie zwolnionej pamięci.
Przykład 3
Tworzymy klasę alokującą w konstruktorze określoną przestrzeń w pamięci i
zwalniającą ją w destruktorze. Klasa ma również funkcję get, która rzuci wyjątek
std::out_of_range dla nieprawidłowego indeksu:
#include <iostream>
#include <stdexcept>
class BazaDanych
{
private:
int rozmiar;
int* dane;
public:
BazaDanych(int n) :
rozmiar(n),
dane(new int[n])
{}
int get(int i)
{
if (i < 0 || i >= rozmiar)
{
throw std::out_of_range("BazaDanych");
}
return dane[i];
}
~BazaDanych()
{
delete [] dane;
}
};
int main(int argc, char** argv)
{
try {
BazaDanych *db = new BazaDanych(10);
db->get(10);
delete db;
} catch (std::out_of_range &e)
{
std::cerr << "out_of_range in " << e.what() << "\n";
return 1;
}
return 0;
}
Program valgrind w swoim raporcie potwierdzi wystąpienie wycieku.
W powyższych trzech przypadkach mogliśmy zapobiec powstawaniu wycieków na kilka sposobów:
- Stworzenie wskaźnika poza zakresem, w którym aktualnie się znajduje, aby umożliwić zwolnienie pamięci na obszarze innego zakresu.
- Skorzystanie z uchwytu do zasobów — pojemnika standardowej biblioteki szablonów lub innej klasy zarządzającej zasobami.
- Jeżeli już musimy stworzyć wskaźnik do zasobów, warto rozważyć skorzystanie z inteligentnych wskaźników oferowanych przez standard C++11.
Najlepszym sposobem jest jednak unikanie jawnej dynamicznej alokacji pamięci tam, gdzie nie jest ona potrzebna.
Naruszenie pamięci
Kolejnym błędem popełnianym przez nas jest korzystanie z pamięci, która do nas nie należy. Komunikat segmentation fault wystąpi tylko gdy program naruszy przestrzeń w pamięci przydzieloną jakiejś innej aplikacji. Nie oznacza to jednak, że kiedy naruszamy swoją przestrzeń w pamięci, to wszystko jest w porządku.
Przykład 1
Na początek standardowy błąd „początkującego programisty":
int main(int argc, char** argv)
{
int tab[100];
tab[100] = 10;
return 0;
}
Valgrind zakomunikuje problem w ten sposób:
==7933== Invalid write of size 4
==7933== at 0x400623: main (example5.cpp:4)
==7933== Address 0x595a1d0 is 0 bytes after a block of size 400 alloc'd
==7933== at 0x4C28147: operator new[](unsigned long) (vg_replace_malloc.c:348)
==7933== by 0x400614: main (example5.cpp:3)
Przykład 2
Poprzez wyjście poza zakres tablicy możemy wejść w następny element na stosie:
#include <iostream>
int main(int argc, char** argv)
{
int a[100];
int b[100];
b[0] = 1;
std::cout << "b[0] = " << b[0] << "\n";
a[100] = 2;
std::cout << "b[0] = " << b[0] << "\n";
std::cout << "Porównajmy adresy elementów a[100] i b[0]:\n";
int* p1 = &a[100];
int* p2 = &b[0];
if (p1 == p2)
{
std::cout << "Adresy są takie same! (" << reinterpret_cast<void*>(p1) << ")\n";
} else
{
std::cout << "Adresy różnią się!\n";
}
return 0;
}
Element b[0] został nadpisany poprzez błędne indeksowanie w tablicy a.
W całym tym przypadku gorsze jest to, że valgrind nie sygnalizuje tutaj błędów!
Ponieważ pamięć należy do programu i została zainicjalizowana, nie zostaje
wykryty żaden błąd.
Przykład 3
Jeżeli korzystamy z dynamicznej alokacji pamięci, to wyjście poza zakres tablicy spowoduje reakcję programu valgrind:
int main(int argc, char** argv)
{
int* p = new int[10];
p[10] = 10;
int a = p[10];
return 0;
}
Fragment zwróconego komunikatu:
==8840== Invalid write of size 4
==8840== at 0x400621: main (example6.1.cpp:4)
==8840== Address 0x595a068 is 0 bytes after a block of size 40 alloc'd
==8840== at 0x4C28147: operator new[](unsigned long) (vg_replace_malloc.c:348)
==8840== by 0x400614: main (example6.1.cpp:3)
==8840==
==8840== Invalid read of size 4
==8840== at 0x40062B: main (example6.1.cpp:5)
==8840== Address 0x595a068 is 0 bytes after a block of size 40 alloc'd
==8840== at 0x4C28147: operator new[](unsigned long) (vg_replace_malloc.c:348)
==8840== by 0x400614: main (example6.1.cpp:3)
Przykład 4
Z podobnym problemem spotkamy się gdy będziemy próbowali zapisać coś do pamięci, która została już zwolniona:
int main(int argc, char** argv)
{
int* p = new int[10];
delete [] p;
p[2] = 10;
return 0;
}
Komunikat programu valgrind:
==9086== Invalid write of size 4
==9086== at 0x400684: main (example9.cpp:5)
==9086== Address 0x595a048 is 8 bytes inside a block of size 40 free'd
==9086== at 0x4C275BC: operator delete[](void*) (vg_replace_malloc.c:490)
==9086== by 0x40067B: main (example9.cpp:4)
Niezainicjalizowana pamięć
Niezainicjalizowana zmienna jest nawet gorsza niż niezainicjalizowany wskaźnik. Jeżeli skorzystamy ze wskaźnika, który pokazuje „byle gdzie", najprawdopodobniej podczas uruchomienia programu spotkamy się z naruszeniem pamięci. Zwykła zmienna na pewno znajduje się na naszym stosie, więc przestrzeni pamięci programu nie naruszymy. Na szczęście valgrind takie błędy wykrywa:
int main(int argc, char** argv)
{
int x;
if (x == 10)
{
x = 20;
}
return 0;
}
Valgrind poinformuje nas, że korzystamy z niezainicjalizowanej zmiennej przy sprawdzaniu warunku:
==8777== Conditional jump or move depends on uninitialised value(s)
==8777== at 0x4005AD: main (example7.cpp:4)
Zapobieganie błędom pamięci
Zapobieganie błędom pamięci jest możliwe w C++ nawet bez wyspecjalizowanych narzędzi. Oto dobre praktyki, które pozwolą uniknąć wycieków:
- Jeżeli w konstruktorze klasy korzystasz z operatora
new, to w jej destruktorze skorzystaj zdelete. - Jeżeli w funkcji składowej korzystasz z lokalnego wskaźnika i operatora
new, to najprawdopodobniej wystarczy tam lokalna zmienna. - Jeżeli jesteś przekonany, że w sytuacji z pkt. 2 nie wystarczy zmienna
lokalna, a korzystasz z operatora
deletena tym wskaźniku przed wyjściem z funkcji, to jesteś w błędzie (wystarczy lokalna zmienna). - Jeżeli w funkcji składowej musisz dynamicznie zaalokować pamięć i nie możesz jej zwolnić przed wyjściem z funkcji, to zapisz adres we wskaźniku dostępnym poza tą funkcją.
Udogodnienia z C++11
Od dawna w bibliotece standardowej mamy dostęp do pojemników automatycznie
zwalniających pamięć (vector, list, deque, ...). Umiejętne korzystanie z
nich zapobiegnie powstawaniu opisanych wyżej sytuacji. Dobrym nawykiem może być
korzystanie z funkcji std::vector::at zamiast operatora []. Ta pierwsza
rzuca wyjątek w przypadku naruszenia zakresu wektora.
Począwszy od standardu C++11 mamy także dostęp do wskaźników automatycznie zwalniających pamięć, na którą wskazują:
- Wskaźnik
std::shared_ptrmoże być kopiowany. Jeżeli „ginie" ostatnia kopia, pamięć na którą wskaźnik wskazywał jest zwalniana. - Wskaźnik
std::unique_ptrnie może być kopiowany. Jeżeli jest usuwany z pamięci, zwalniana jest także pamięć, na którą pokazywał.
Ważne w ich używaniu jest to, żeby być konsekwentnym i nie korzystać ze wskazywanej przez nie pamięci za pomocą zwykłych wskaźników. Nie należy też tworzyć tych specjalnych wskaźników dla pamięci zarezerwowanej gdzieś poza nimi czy obiektów, które nie są dynamicznie alokowane. Doprowadzi to do katastrofy!
Stosowanie się do powyższych zasad pozwala całkowicie wyeliminować problemy z pamięcią operacyjną. Udogodnienia dostępne w C++11 i uzupełnione w standardzie C++14 powodują, że nie ma już żadnego usprawiedliwienia dla wycieków pamięci.