Menu

Wycieki pamięci w C: skąd się biorą i jak je znaleźć

Czym naprawdę jest wyciek, trzy sposoby, w jakie powstaje w programach w C, dyscyplina własności, która im zapobiega, i jak znaleźć resztę przez valgrind i -fsanitize=address. Na koniec program z wyciekami naprawiony krok po kroku.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

Wyciek pamięci to nie pamięć, która zniknęła. To pamięć, która wciąż jest twoja, wciąż zarezerwowana, ale której nie możesz już oddać, bo zniknął ostatni wskaźnik na nią. Nic się nie wysypuje. Program działa dalej, za każdym razem trochę cięższy, aż w końcu coś zawodzi w zupełnie niezwiązanym miejscu.

Ta strona opisuje, jak powstają wycieki, dyscyplinę, która zapobiega większości z nich, i dwa narzędzia, które znajdują resztę.

Jak wygląda wyciek

Każda iteracja nadpisuje block nowym wskaźnikiem. Poprzedni blok jest wciąż przydzielony, żadna zmienna nie przechowuje jego adresu i nie da się go już zwolnić. Trzy iteracje gubią dwanaście kilobajtów. Serwer, który robi to raz na żądanie, traci tę pamięć na zawsze, w tempie, w jakim przychodzą żądania.

Poprawka to jedna linia, free(block); na końcu ciała pętli, ale prawdziwą umiejętnością jest rozpoznanie, gdzie ma się znaleźć.

Skąd biorą się wycieki

1. Zgubiony wskaźnik

Każde przypisanie do wskaźnika, który wciąż przechowuje jedyną referencję do żywego bloku, powoduje wyciek tego bloku.

char *name = malloc(32);
name = malloc(64);     /* pierwsze 32 bajty są teraz nieosiągalne */

Pętla powyżej to ten sam błąd w przebraniu pętli. Tak samo jest z ponownym przypisaniem pola struktury i ze skrótem z realloc opisanym na stronie o calloc i realloc:

p = realloc(p, n);     /* przy niepowodzeniu: p staje się NULL, stary blok zostaje osierocony */

2. Wczesny powrót

Każda ścieżka wyjścia z funkcji musi zwolnić to, co funkcja już pobrała. Zapomina się zawsze o ścieżce błędu.

Szczęśliwa ścieżka jest poprawna, a ścieżka błędu cieknie i dlatego ten błąd przeżywa testy: gałąź niepowodzenia prawie nigdy nie wykonuje się podczas programowania. Poprawką jest jedna sekcja sprzątania, do której skacze każda ścieżka:

To jedyne użycie goto, które doświadczeni programiści C aktywnie polecają. Działa, bo każdy wskaźnik zaczyna od NULL, a free(NULL) nic nie robi, więc jeden blok wyjścia jest poprawny niezależnie od tego, jak daleko funkcja doszła.

3. Niejasna własność

Najbardziej podstępne wycieki to wcale nie błędy w kodzie, tylko dwie funkcje, które nie zgadzają się, czyje to było zadanie.

char *build_message(void);   /* czy wywołujący to zwalnia? */
void  store(char *text);     /* czy store przejmuje własność? */

Jeśli build_message zwraca przydzieloną pamięć, a store ją kopiuje, zwolnić musi wywołujący. Jeśli store zachowuje wskaźnik, wywołującemu nie wolno. Nic w kodzie nie mówi, który przypadek zachodzi, więc jedno z dwóch założeń zostaje przyjęte dwa razy, a wynikiem jest wyciek albo podwójne zwolnienie.

Lekarstwem jest konwencja zapisana w komentarzu przy każdej funkcji, która przydziela pamięć:

/* Zwraca nowo przydzielony string; wywołujący musi go zwolnić. */
char *build_message(void);

/* Przejmuje własność 'text'; zostanie zwolniony przez store_free(). */
void store(char *text);

Zapisz regułę przy funkcji, a nie w dokumencie projektowym. To najcenniejszy pojedynczy nawyk w zarządzaniu pamięcią w C.

Dyscyplina własności

Cztery zasady obejmują prawie wszystko:

  1. Każdy przydział ma dokładnie jednego właściciela, czyli jeden fragment kodu odpowiedzialny za zwolnienie.
  2. Łącz w parę każdą funkcję przydzielającą z funkcją zwalniającą. vec_init / vec_free, config_load / config_free. Symetria sprawia, że brakujące wywołanie jest widoczne.
  3. Zwalniaj w tej samej warstwie, która przydzieliła, chyba że komentarz funkcji wyraźnie przekazuje własność.
  4. Po zwolnieniu ustaw wskaźnik na NULL, żeby późniejsze przypadkowe użycie wysypało się w miejscu błędu, zamiast po cichu uszkodzić stertę.

Szukanie wycieków: valgrind

Na Linuksie valgrind nie wymaga ponownej kompilacji, choć symbole debugowania sprawiają, że raport da się przeczytać:

gcc -g -O0 program.c -o program
valgrind --leak-check=full --show-leak-kinds=all ./program

Dla cieknącej pętli z początku tej strony raport kończy się mniej więcej tak:

==12345== HEAP SUMMARY:
==12345==     in use at exit: 12,000 bytes in 3 blocks
==12345==   total heap usage: 3 allocs, 0 frees, 12,000 bytes allocated
==12345==
==12345== 12,000 bytes in 3 blocks are definitely lost in loss record 1 of 1
==12345==    at 0x4C2FB0F: malloc (vg_replace_malloc.c:299)
==12345==    by 0x108671: main (program.c:6)
==12345==
==12345== LEAK SUMMARY:
==12345==    definitely lost: 12,000 bytes in 3 blocks

Czytaj od dołu. "definitely lost" oznacza, że przy wyjściu nie istniał żaden wskaźnik na blok: to prawdziwy wyciek. Ślad stosu wskazuje linię malloc, które go utworzyło, a nie linię, w której został zgubiony, ale zwykle to wystarczy, żeby znaleźć brakujące free.

Pojawiają się jeszcze dwie kategorie:

  • indirectly lost: bloki osiągalne tylko przez blok, który sam został zgubiony, na przykład elementy wyciekłej listy jednokierunkowej. Napraw blok "definitely lost", a te znikną.
  • still reachable: przydzielone przy wyjściu, ale z żywym wskaźnikiem, zwykle globalna pamięć podręczna. To nie wyciek w groźnym sensie, ale warto go zwolnić, żeby raport pozostał pusty.

Valgrind wyłapuje też odczyty niezainicjalizowanej pamięci i zapisy za końcem bloku, i często właśnie tak odkrywa się błąd stojący za wyciekiem.

Szukanie wycieków: AddressSanitizer

AddressSanitizer jest wbudowany w GCC i Clang, działa znacznie szybciej niż valgrind i działa tam, gdzie valgrind nie działa (w tym na obecnym macOS):

gcc -g -fsanitize=address -fno-omit-frame-pointer program.c -o program
./program

Raport o wyciekach wypisuje się automatycznie przy wyjściu:

=================================================================
==12345==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 12000 byte(s) in 3 object(s) allocated from:
    #0 0x7f... in malloc
    #1 0x1086... in main program.c:6

SUMMARY: AddressSanitizer: 12000 byte(s) leaked in 3 allocation(s).

ASan zamienia też użycie po zwolnieniu i przepełnienia bufora na stercie w natychmiastowe, wyraźnie opisane przerwanie programu, a nie tajemnicze uszkodzenie danych później. Uruchamiaj z nim testy, a wydania buduj bez niego, bo kosztuje pamięć i szybkość.

Jeśli wykrywanie wycieków nie zadziała na twojej platformie, przed uruchomieniem ustaw w środowisku ASAN_OPTIONS=detect_leaks=1.

Naprawa programu z wyciekami krok po kroku

Oto mały program z trzema osobnymi wyciekami:

Valgrind zgłasza trzy wpisy "definitely lost" z trzema różnymi numerami linii. Naprawione po kolei:

Poprawka 1 to komentarz o własności wcielony w życie: shout przydziela, main zwalnia. Poprawka 2 całkiem usuwa zdublowany przydział, zamiast zwalniać pierwszy blok: prostszy kod jest też kodem poprawnym. Poprawka 3 dodaje brakujące free przy wczesnym powrocie; przy większej liczbie przydziałów pojedyncza etykieta cleanup: pokazana wcześniej skaluje się lepiej niż powtarzanie zwolnień.

Nawyki, które zapobiegają wyciekom

  • Pisz free od razu po napisaniu malloc, a dopiero potem uzupełniaj kod pomiędzy.
  • Daj każdej funkcji przydzielającej pasującą funkcję zwalniającą.
  • Zapisuj zasady własności w komentarzu przy każdej funkcji, która zwraca albo przyjmuje przydzielony przez siebie wskaźnik.
  • W funkcjach z kilkoma przydziałami używaj jednego bloku wyjścia cleanup:.
  • Uruchamiaj testy z -fsanitize=address rutynowo, a nie tylko wtedy, gdy coś wygląda podejrzanie.
  • Traktuj "definitely lost: 0 bytes" jako część zaliczonego przebiegu testów.

Najczęściej zadawane pytania

Czym jest wyciek pamięci w C?

Pamięcią przydzieloną przez malloc, której nie możesz już zwolnić, bo nic w programie na nią nie wskazuje. Blok pozostaje zarezerwowany do końca życia procesu. To nie jest awaria: program działa dalej, tylko przy każdym przebiegu zużywa więcej pamięci, aż w końcu jej zabraknie.

Jak znaleźć wycieki pamięci w C?

Uruchom program pod valgrindem: valgrind --leak-check=full ./program. Zgłasza każdy blok, który przy wyjściu jest wciąż przydzielony, ze śladem stosu wywołania malloc, które go utworzyło. Na macOS albo tam, gdzie valgrind nie jest dostępny, skompiluj z -fsanitize=address, a ten sam raport pojawi się przy wyjściu.

Co powoduje wycieki pamięci w C?

Prawie wszystkie wynikają z trzech wzorców: nadpisania jedynego wskaźnika na blok (w tym p = realloc(p, n) przy niepowodzeniu), wczesnego powrotu z funkcji, która już coś przydzieliła, i niejasnej własności, gdy każda z dwóch funkcji zakłada, że zwolni to druga, więc nie zwalnia żadna.

Czy wycieki pamięci mają znaczenie, jeśli program i tak się kończy?

W programie, który uruchamia się raz i kończy, system operacyjny odzyskuje wszystko, więc praktyczny wpływ jest zerowy. Znaczenie mają we wszystkim, co działa długo: serwerze, pętli gry, demonie, gdzie wyciek na każde żądanie rośnie bez ograniczeń. I tak zwalniaj pamięć konsekwentnie: raport o wyciekach pełen szumu ukrywa te, które naprawdę mają znaczenie.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ