Zwalczanie wycieków i błędów pamięci (C++11)

Programowanie

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. Zaużymy, ż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. O co więc tyle szumu, skoro i tak pamięć zostanie w końcu zwolniona? W przypadku programów codzinnego użytku drobne wycieki w zasadzie są niezauważalne. Po godzinie pracy niewielki przyrost zużycia pamięci jest w zasadzie niezauważalny. Sytuacja ma się gorzej w przypadku programów, które działają bez przerwy po 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, która być może spełaniała bardzo ważną funkcję.

Sytuacja wygląda jeszcze gorzej w przypadku aplikacji zużywających duże ilości zasobów. Przykładem może być program renderujący animacje trójwymiarowe. Samo renderowanie pochłania ogramną ilość pamięci RAM. Jeżeli program pozwoli sobie na wycieki pamięci dynamicznie alokowanej (wycieki takie będą w jakimś stopniu proporcjonalne do zużywanej ilości pamięci), to może to skończyć się jego zawieszeniem i utratą efektów wielu godzin pracy sprzętu.

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ć?

Po pierwsze trzeba wiedzieć, że 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. Jeżeli program, który piszemy jest prosty (tak jak poniższe przykłady), to możemy po prostu uruchomić po kolei wszystkie funkcje. Jeżeli jednak składa się on z wielu modułów, to warto napisać dodatkową aplikację, która po kolei uruchomi wszystkie funkcje danego modułu. Wtedy możemy osobno zająć się debugowaniem każdego z nich, co jest o realnym zadaniem w przeciwieństwie do testowania całego programu od razu.

Jeżeli piszesz testy jednostkowe i wykorzystujesz do tego celu dobrą bibliotekę, która sama w sobie nie generuje wycieków, to możesz do testowania valgrind'em wykorzystać te testy.

Uruchomienie aplikacji w valgrind powinno wyglądać tak:

1 valgrind [opcje valgrinda] /sciezka/do/programu parametry startowe programu

Opcje programu valgrind możemy sprawdzić korzystąc z polecenia:

1 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

 1 #include <iostream>
 2 
 3 int main(int argc, char** argv)
 4 {
 5   int *x = new int[1000];
 6   {
 7     int y;
 8     int *z = new int[1000];
 9   } // wskaźnik z i zmienna y przestają istnieć
10 
11   delete [] x; // zwalniamy pamięć zaalokowaną w 5 wierszu
12   return 0;
13 }

W programie tworzymy trzy zmienne. Dwie z nich (x i z) to zmienne wskaźnikowe, czyli po prostu wskaźniki. Tworzymy też jeden zakres przy pomocy nawiasów klamrowych. Na samym początku alokujemy 1000 obiektów int w pamięci. Adres pierwszego z nich zapsiujemy we wskaźniku x. Następnie wchodzimy do zakresu, w którym definiujemy zmienną y i wskaźnik z. Do wskaźnika z przypisujemy adres pierewszego z kolejnych 1000 elementów zaalokowanych w pamięci dynamicznie. Tak jak wcześniej wspomniałem, po wyjściu z zakresu, zmienne są usuwane. Dotyczy to również zmiennych wskaźnikowych, ale nie tego na co one pokazują. W 9 wierszu powyższego kodu powstał pierwszy wyciek pamięci. 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. Zakładamy, że program jest napisany w miarę poprawnie i wyjątek przechwyci w bloku catch. Program najprawdopodobniej będzie wtedy działał dalej, a pamięć pozostanie zajęta, ponieważ program nie dotrze do miejsca, w którym użyty był operator delete. Przykład takiego kodu:

 1 #include <iostream>
 2 #include <stdexcept>
 3 
 4 int main(int argc, char** argv)
 5 {
 6   try {
 7     int *x = new int[1000];
 8     throw std::logic_error("logic_error");
 9     delete [] x;
10   } catch (std::logic_error &e)
11   {
12     std::cerr << "złapano wyjątek: " << e.what() << "\n";
13     return 1;
14   }
15 }

Tutaj oczywiście sytuacja została zamierzona. Może być jednak tak, że w wierszu 8 będzie wywołanie jakiejś funkcji, która taki wyjątek może sama rzucić lub wyołuje inne funkcje mogące to zrobić. 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

Ten kod jest bardzo podobny do poprzedniego. Lepiej obrazowuje jednak sytuację, w której nie jesteśmy świadomi, że w danym momencie może wystąpić wyjątek. 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 zwraca zadany element tablicy. Jeżeli wybrany indeks elementu będzie mniejszy od zera lub większy lub równy liczbie elementów w tablicy, funkcja wyrzuci wyjątek std::out_of_range. Oto kod klasy wraz z jej wykorzystaniem:

 1 #include <iostream>
 2 #include <stdexcept>
 3 
 4 class BazaDanych
 5 {
 6 private:
 7   int rozmiar;
 8   int* dane;
 9 
10 public:
11   BazaDanych(int n) :
12     rozmiar(n),
13     dane(new int[n])
14   {}
15 
16   int get(int i)
17   {
18     if (i < 0 || i >= rozmiar)
19     {
20       throw std::out_of_range("BazaDanych");
21     }
22 
23     return dane[i];
24   }
25 
26   ~BazaDanych()
27   {
28     delete [] dane;
29   }
30 };
31 
32 int main(int argc, char** argv)
33 {
34   try {
35     BazaDanych *db = new BazaDanych(10);
36     db->get(10);
37     delete db;
38   } catch (std::out_of_range &e)
39   {
40     std::cerr << "out_of_range in " << e.what() << "\n";
41     return 1;
42   }
43   return 0;
44 }

Pominiemy fakt, że tworzenie w tym miejscu wskaźnika do obiektu klasy BazaDanych było bez sensu. Można to zrobić przy pomocy zwykłego obiektu umieszczanego na stosie. Wyobraźmy sobie jednak, że z jakiegoś powodu musieliśmy użyć wskaźnika i dynamicznej alokacji. W momencie gdy mamy bazę danych z 10 elementami, najwyższy indeks, który możemy pobrać to 9. Przy próbie pobrania wartości dla indeksu 10, funkcja rzuci wyjątek. Program valgrind w swoim raporcie potwierdzi wystąpienie wycieku.

W powyższych trzech przypadkach mogliśmy zapobiec powstawaniu wycieków na kilka sposobów.

  1. Pierwszy z nich, to stworzenie wskaźnika z poza zakresem, w którym aktualnie

    się znajduje. Mogliśmy stworzyć go dokładnie w zakresie funkcji main, a nawet globalnym. Umożliwia nam to zwolnienie pamięci na obszarze innego zakresu niż ten, w którym pamięć alokowaliśmy. Mimo wszystko musimy nadal pamiętać o zwolnieniu pamięci. Przeszkodą w drodze do momentu, w którym pamięć zwalniamy może być wyjątek.

  2. Drugim sposobem na zapobiegnięcie takiej sytuacji było skorzystanie z tzw.

    uchwytu do zasobów. Uchwyt do zasobów to nic innego jak pojemnik standardowej biblioteki szablonów lub inna klasa zarządzająca zasobami. Możemy wtedy stworzyć standardowy obiekt (zmienną) tego typu. Kiedy jego ważność się skończy, zostanie on usunięty, a jego destruktor zwolni zajmowaną pamięć zaalokowaną dynamicznie. Takie rozwiązanie jest nie tylko wygodniejsze i szybsze, ale także daje stuprocentową gwarancję braku wycieku pamięci (pod warunkiem, że w takim kontenerze nie przechowujemy zwykłych wskaźników do pamięci zaalokowanej w innym miejscu).

  3. Jeżeli już musimy stworzyć wskaźnik do zasobów, to warto rozważyć

    skorzystanie z inteligentnych wskaźników oferowanych przez standard C++11. Wskaźniki te same zwalniają pamięć na którą wskazują, kiedy same przestają istnieć. Więcej przeczytasz o nich tutaj. Wspomnę o nich także w dalszej części artykułu.

Najlepszym sposobem jest jednak unikanie korzystania jawnej dynamicznej alokacji pamięci. Nie unikniemy tego całkowicie, ale możemy zminimalizować liczbę wystąpień tej potencjalnie niebezpiecznej operacji. Wielu niezbyt doświadczonych programistów interpretuje obecność wskaźników i dynamicznej alokacji pamięci w C++ jako konieczność korzystania z tych zdobyczy na każdym kroku. Efektem jest używanie ich tam, gdzie nie są potrzebne. Przykład 3 jest świetnym przypadkiem kodu, w którym wystarczyłoby skorzystać ze zwykłego obiektu umieszczanego na stosie. Nie byłoby wtedy żadnego problemu z pamięcią. Po wyjściu z zakresu bloku try { /* ... */ } catch () {} obiekt zostałby usunięty.

Wycieki pamięci to jednak nie jedyny problem z jakim możemy się spotkać.

Naruszenie pamięci

Kolejnym błędem popełnianym przez nas jest korzystanie z pamięci, która do nas nie należy. Błąd ten jest bardzo często popełniany przez programistów, którzy dopiero zaczynają przygodę z programowaniem i nie są przyzwyczajeni, że maksymalny indeks w tablicy 100-elementowej to 99, a nie 100. Problem może zostać wykryty na etapie korzystania z programu, ale wcale nie musi! 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. Czas na przykłady, które naświetlą temat.

Przykład 1

Na początek zaprezentuję standardowy błąd „początkującego programisty“.

1 int main(int argc, char** argv)
2 {
3   int tab[100];
4   tab[100] = 10;
5   return 0;
6 }

Tutaj najprawdopodobniej przestrzeni innego programu nie naruszymy. Valgrind zakomunikuje jednak problem w ten sposób:

1 ==7933== Invalid write of size 4
2 ==7933==    at 0x400623: main (example5.cpp:4)
3 ==7933==  Address 0x595a1d0 is 0 bytes after a block of size 400 alloc'd
4 ==7933==    at 0x4C28147: operator new[](unsigned long) (vg_replace_malloc.c:348)
5 ==7933==    by 0x400614: main (example5.cpp:3)

Tutaj żadna wielka rzecz się nie stała (a przynajmniej tego nie zauważyliśmy). Jeżeli jednak wychodzimy poza zakres tablicy i wpisujemy tam dane, to najczęściej coś nadpiszemy. Kolejny przykład zilustruje właśnie taki przypadek.

Przykład 2

Poprzez wyjście poza zakres tablicy wejdziemy następny element na stosie. Aby to udowodnić skompilujmy i wykonajmy następujący program:

 1 #include <iostream>
 2 
 3 int main(int argc, char** argv)
 4 {
 5   int a[100];
 6   int b[100];
 7   b[0] = 1;
 8   std::cout << "b[0] = " << b[0] << "\n";
 9   a[100] = 2;
10   std::cout << "b[0] = " << b[0] << "\n";
11   std::cout << "Porównajmy adresy elementów a[100] i b[0]:\n";
12   int* p1 = &a[100];
13   int* p2 = &b[0];
14   if (p1 == p2)
15   {
16     std::cout << "Adresy są takie same! (" << reinterpret_cast<void*>(p1) << ")\n";
17   } else
18   {
19     std::cout << "Adresy różnią się!\n";
20   }
21   return 0;
22 }

Jak widać, element b[0] został nadpisany poprzez błędne indeksowanie w tablicy a. Gdybyśmy wpisali wartość do a[101], trafiłaby ona do b[1]. W całym tym przypadku gorsze jest to, że valgrind nie sygnalizuje tutaj błędów! Ponieważ pamięć należy do programu, została zainicjalizowana (wpisaliśmy do b[0] wartość 1), nie zostaje wykryty żaden błąd.

Jeżeli jednak korzystamy z dynamicznej alokacji pamięci, to wyjście poza zakres tablicy zaalokowanej w ten sposób na pewno spowoduje reakcję programu valgrind.

Przykład 3

1 int main(int argc, char** argv)
2 {
3   int* p = new int[10];
4   p[10] = 10;
5   int a = p[10];
6   return 0;
7 }

W tym programie błąd popełniamy dwukrotnie. Pierwszy raz próbując zapisać liczbę 10 pod adresem p[10] (zaraz za końcem zaalokowanej pamięci). Drugi raz, gdy próbujemy ją stamtąd odczytać. Obydwie operacje powiodą się pod warunkiem, że następne bajty za zaalokowaną tablicą nadal będą należały do sterty programu. Jeżeli rozważymy powyższy przykład to stwierdzimy, że próba na pewno się powiedzie, ponieważ sterta w nowoczesnych komputerach działających z nowoczesnymi systemami operacyjnymi zawsze będzie wielokrotnie większa niż te 40 bajtów, które zajmuje 10 „intów”.

Tak jak wspomniałem, takie błędy powodują reakcję programu valgrind. Oto fragment zwróconego komunikatu:

 1 ==8840== Invalid write of size 4
 2 ==8840==    at 0x400621: main (example6.1.cpp:4)
 3 ==8840==  Address 0x595a068 is 0 bytes after a block of size 40 alloc'd
 4 ==8840==    at 0x4C28147: operator new[](unsigned long) (vg_replace_malloc.c:348)
 5 ==8840==    by 0x400614: main (example6.1.cpp:3)
 6 ==8840==
 7 ==8840== Invalid read of size 4
 8 ==8840==    at 0x40062B: main (example6.1.cpp:5)
 9 ==8840==  Address 0x595a068 is 0 bytes after a block of size 40 alloc'd
10 ==8840==    at 0x4C28147: operator new[](unsigned long) (vg_replace_malloc.c:348)
11 ==8840==    by 0x400614: main (example6.1.cpp:3)

Z podobnym problemem spotkamy się gdy będziemy próbowali zapisać coś do pamięci, która została już zwolniona.

Przykład 4

1 int main(int argc, char** argv)
2 {
3   int* p = new int[10];
4   delete [] p;
5   p[2] = 10;
6   return 0;
7 }

Komunikat programu valgrind delikatnie różni się od poprzedniego:

1 ==9086== Invalid write of size 4
2 ==9086==    at 0x400684: main (example9.cpp:5)
3 ==9086==  Address 0x595a048 is 8 bytes inside a block of size 40 free'd
4 ==9086==    at 0x4C275BC: operator delete[](void*) (vg_replace_malloc.c:490)
5 ==9086==    by 0x40067B: main (example9.cpp:4)

Niezainicjalizowana pamieć

Ile razy słyszeliśmy: „ustawiaj wartość zmiennej zaraz po jej utworzeniu!“? Chyba każdy podręcznik do programowania o tym wspomina. Pół biedy jeżeli oblejesz kolokwium z algorytmów, bo przed zliczaniem sumy elementów tablicy napisałeś int suma;, a nie int suma = 0;. Gorzej będzie jeżeli przez tego typu błąd stracisz kilka godzin na poszukiwanie powodu dla którego coś nie działa tak jak powinno właśnie z powodu niezainicjalizowanej zmiennej. 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 i informuje o nich. Jako przykład może posłużyć nam taki kod:

1 int main(int argc, char** argv)
2 {
3   int x;
4   if (x == 10)
5   {
6     x = 20;
7   }
8   return 0;
9 }

Valgrind poinformuje nas przy uruchamianiu tego programu, że korzystamy z niezainicjalizowanej zmiennej przy sprawdzaniu warunku. Ponieważ jest ona niezainicjalizowana, znajdują się tam losowe bity. Komunikat ten będzie wyglądał następująco:

1 ==8777== Conditional jump or move depends on uninitialised value(s)
2 ==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. Sama obecność często niedocenianych destruktorów jest już bardzo pomocna. Jeżeli stworzymy obiekt, który sam zajmuje się rezerwacją pamięci, to jego destruktor powinien zajmować się zwalnianiem tej pamięci. Oto dobre praktyki, które pozwolą uniknąć wycieków:

  1. Jeżeli w konstruktorze klasy korzystasz z operatora new, to w jej

    destruktorze skorzystaj z delete.

  2. Jeżeli w funkcji składowej korzystasz z lokalnego wskaźnika (takiego, który

    nie jest składnikiem klasy) i operatora new, to najprawdopodobniej wystarczy tam lokalna zmienna.

  3. Jeżeli jesteś przekonany, że w sytuacji z pkt. 2 nie wystarczy zmienna

    lokalna, a korzystasz z operatora delete na tym wskaźniku przed wyjściem z funkcji, to jesteś w błędzie (wystarczy lokalna zmienna).

  4. 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 nam 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ą. Więcej o tych wskaźnikach już pisałem. Ich działanie w skrócie można przedstawić tak:

  • Wskaźnik std::shared_ptr może być kopiowany. Jeżeli „ginie” ostatnia kopia,

    pamięć na którą wskaźnik wskazywał jest zwalniana.

  • Wskaźnik std::unique_ptr nie 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 zasad z tego i poprzedniego akapitu 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. Zachęcam do dokładnego zapoznawania się z nimi, bo umiejętne ich wykorzystanie pomaga, ale nieumiejętne już bardzo szkodzi.