Dlaczego RSS twojego procesu rośnie i nie spada
Klasyczny moment zdziwienia: program alokuje 2 GB, przetwarza dane, zwalnia wszystko przez free() — a htop wciąż pokazuje 2 GB rezydentnej pamięci (RSS). Wyciek? Nie. To zaprojektowane zachowanie allocatora, które większość deweloperów bierze za błąd, bo nie rozumie co malloc() i free() faktycznie robią pod spodem.
free() w zdecydowanej większości przypadków nie oddaje pamięci systemowi operacyjnemu. Oddaje ją z powrotem do allocatora — warstwy pośredniej między twoim kodem a kernelem, która zarządza pulami pamięci, by uniknąć kosztownych syscalli przy każdej alokacji. Zrozumienie tej warstwy to różnica między „mamy memory leak” a „to fragmentacja areny, RSS się nie zmniejszy, ale to nie wyciek”.
Ten artykuł rozkłada wnętrzności malloc w glibc: areny, biny, chunki, granicę brk vs mmap, mechanikę fragmentacji oraz — kluczowe dla decyzji produkcyjnych — kiedy jemalloc lub tcmalloc bije domyślny allocator glibc.
Warstwa pośrednia: dlaczego nie wołamy kernela bezpośrednio
Kernel zarządza pamięcią w granularności stron (zwykle 4 KB). Gdyby każde malloc(16) wymagało syscalla do kernela, koszt byłby absurdalny — syscall to ~100–500 ns, a typowy program robi miliony alokacji. Allocator rozwiązuje to, prosząc kernel o pamięć hurtowo i rozdzielając ją na drobne kawałki w userspace.
Dwa syscalle dostarczają pamięć od kernela:
| Mechanizm | Działanie | Użycie w glibc malloc |
|---|---|---|
brk / sbrk | Przesuwa granicę segmentu danych (program break) | Małe alokacje (< 128 KB), główna arena |
mmap | Mapuje anonimowe strony w dowolnym miejscu przestrzeni adresowej | Duże alokacje (≥ 128 KB) i areny wątków |
# Zobacz oba mechanizmy w akcji — strace na prostym programie
$ strace -e trace=brk,mmap ./program 2>&1 | head
brk(NULL) = 0x55a3c2a00000 # bieżący program break
brk(0x55a3c2a21000) = 0x55a3c2a21000 # rozszerzenie areny (132 KB)
mmap(NULL, 2101248, PROT_READ|PROT_WRITE, ...) # duża alokacja (2 MB)
= 0x7f3a8c000000Kluczowy próg to M_MMAP_THRESHOLD (domyślnie 128 KB). Alokacje poniżej idą z areny zarządzanej przez brk; powyżej — bezpośrednio przez mapowanie pamięci (mmap). To rozróżnienie ma fundamentalne konsekwencje dla tego, czy pamięć kiedykolwiek wróci do systemu.
Anatomia chunka — jak malloc pamięta rozmiary
Gdy wołasz malloc(100), dostajesz wskaźnik do 100 bajtów — ale allocator zarezerwował więcej. Każdy blok pamięci to chunk z nagłówkiem metadanych tuż przed zwróconym wskaźnikiem:
/* Uproszczona struktura chunka w glibc (ptmalloc2) */
struct malloc_chunk {
size_t prev_size; /* rozmiar poprzedniego chunka, JEŚLI wolny */
size_t size; /* rozmiar tego chunka + 3 bity flag */
/* Poniższe pola używane TYLKO gdy chunk jest wolny: */
struct malloc_chunk *fd; /* forward pointer w liście wolnych */
struct malloc_chunk *bk; /* backward pointer w liście wolnych */
};
/* Trzy najniższe bity pola 'size' to flagi (chunki wyrównane do 8B): */
/* PREV_INUSE (0x1) — czy poprzedni chunk jest w użyciu */
/* IS_MMAPPED (0x2) — czy chunk pochodzi z mmap (nie z areny) */
/* NON_MAIN_ARENA (0x4) — czy chunk należy do areny wątku */Sprytna sztuczka: gdy chunk jest w użyciu, pola fd/bk nie są potrzebne, więc ten obszar zawiera dane użytkownika. Gdy chunk jest wolny, te same bajty przechowują wskaźniki list. To dlatego use-after-free nadpisujący zwolniony chunk korumpuje listy allocatora — pisze po fd/bk, co przy następnej alokacji prowadzi do zapisu pod dowolny adres (klasyczny wektor eksploitacji fastbin dup).
Biny — struktura zarządzania wolnymi chunkami
Zwolnione chunki nie są od razu oddawane systemowi. Trafiają do binów — list wolnych chunków pogrupowanych według rozmiaru, by kolejne malloc() mogło szybko znaleźć pasujący blok. glibc utrzymuje kilka kategorii binów o różnej charakterystyce:
| Typ binu | Rozmiar chunków | Charakterystyka |
|---|---|---|
| Fast bins | 16–160 B (10 binów) | LIFO, brak scalania — najszybsze, single-linked |
| Tcache | per-thread, 24–1032 B | Per-wątek cache (glibc 2.26+), bez locka |
| Small bins | < 512 B (62 biny) | FIFO, dokładny rozmiar, double-linked |
| Large bins | ≥ 512 B | Posortowane, zakresy rozmiarów, best-fit |
| Unsorted bin | dowolny | Bufor pośredni przed klasyfikacją |
Tcache (thread-local caching) to najważniejsza optymalizacja ostatnich lat: każdy wątek ma własną pulę ostatnio zwolnionych chunków, dostępną bez zakładania locka areny. To dramatycznie przyspiesza wielowątkowe alokacje, ale wprowadziło też nowe wektory ataku (tcache poisoning).
Dlaczego free() nie oddaje pamięci
Gdy zwalniasz chunk, allocator wykonuje sekwencję:
- Sprawdza czy chunk pasuje do tcache → jeśli tak, wrzuca tam (najszybsza ścieżka)
- Jeśli mały → fast bin lub small bin
- Próbuje scalić (coalesce) z sąsiednimi wolnymi chunkami, by przeciwdziałać fragmentacji
- Tylko jeśli na szczycie areny (top chunk) urośnie duży, ciągły wolny obszar — allocator może wywołać
sbrkz ujemnym argumentem lubmalloc_trim(), by oddać pamięć
Krytyczny warunek: pamięć wraca do systemu tylko gdy wolny obszar jest na samym końcu areny i przekracza próg (M_TRIM_THRESHOLD, domyślnie 128 KB). Jeśli żywy chunk siedzi za zwolnionym obszarem, brk nie może się cofnąć — pamięć pozostaje zarezerwowana, choć wolna. To jest fragmentacja, nie wyciek.
#include <stdlib.h>
#include <malloc.h>
int main(void) {
/* Alokuj 1000 bloków po 1 KB */
void *blocks[1000];
for (int i = 0; i < 1000; i++)
blocks[i] = malloc(1024);
/* Zwolnij wszystkie PARZYSTE — szachownica fragmentacji */
for (int i = 0; i < 1000; i += 2)
free(blocks[i]);
/* RSS pozostaje wysoki: wolne chunki przeplatają się z żywymi,
brk nie może się cofnąć, bo żywe chunki blokują koniec areny */
/* Jawne wymuszenie zwrotu pamięci do systemu */
malloc_trim(0); /* oddaj wszystko co można z końca areny */
return 0;
}mmap vs brk — dlaczego duże alokacje są inne
Alokacje powyżej M_MMAP_THRESHOLD idą bezpośrednio przez mmap i mają fundamentalnie inną charakterystykę zwrotu. Każda taka alokacja to osobne mapowanie, które free() natychmiast oddaje systemowi przez munmap — bo to niezależny region, nie część współdzielonej areny.
| Cecha | brk arena (małe) | mmap (duże ≥128 KB) |
|---|---|---|
| Zwrot do OS przy free() | Rzadko (tylko z końca areny) | Natychmiast (munmap) |
| Koszt alokacji | Niski (zwykle bez syscalla) | Wysoki (syscall + page fault) |
| Fragmentacja | Możliwa (współdzielona arena) | Brak (izolowane mapowania) |
| Inicjalizacja | Pamięć może zawierać śmieci | Zawsze wyzerowana (kernel gwarantuje) |
Stąd kontrintuicyjny efekt: program robiący wiele dużych alokacji może mieć stabilniejszy RSS niż program z milionem małych, bo duże wracają do systemu natychmiast, a małe zostają uwięzione w arenie. Threshold jest też dynamiczny — glibc go zwiększa, gdy wykryje, że program zwalnia duże bloki, by uniknąć kosztownych mmap/munmap cykli.
Areny — skalowanie wielowątkowe
W programie wielowątkowym, gdyby wszystkie wątki konkurowały o jedną arenę, lock na niej byłby wąskim gardłem. glibc rozwiązuje to przez multiple arenas: główna arena (na brk) plus dodatkowe areny wątków (na mmap), każda z własnym lockiem.
# Liczba aren jest ograniczona — domyślnie 8 × liczba rdzeni (64-bit)
$ echo $((8 * $(nproc)))
64
# Kontrola przez zmienną środowiskową
$ MALLOC_ARENA_MAX=2 ./program
# Ograniczenie aren zmniejsza zużycie pamięci (mniej osobnych pul)
# kosztem potencjalnej kontencji locków przy wielu wątkach
# Diagnostyka stanu wszystkich aren
$ MALLOC_STATS=1 ./program 2>&1 | tail -20Trade-off jest bezpośredni: więcej aren = mniej kontencji locków, ale więcej zafragmentowanej, nieoddanej pamięci (każda arena trzyma własne wolne chunki). Dla aplikacji z wieloma wątkami i wrażliwych na RSS, MALLOC_ARENA_MAX=2 to częsty pierwszy strzał optymalizacyjny. To wyjaśnia też, dlaczego ten sam program zużywa więcej pamięci na maszynie 64-rdzeniowej niż 4-rdzeniowej.
Alternatywne allocatory — pomiar, nie ideologia
Domyślny ptmalloc2 w glibc to kompromis ogólnego przeznaczenia. Dla konkretnych workloadów wyspecjalizowane allocatory dają mierzalną przewagę.
| Allocator | Mocna strona | Typowe zastosowanie |
|---|---|---|
| ptmalloc2 (glibc) | Uniwersalny, zawsze dostępny | Domyślny, większość aplikacji |
| jemalloc | Niska fragmentacja, przewidywalny RSS, profiling | Bazy danych, długo żyjące serwery (Redis, FB) |
| tcmalloc (Google) | Bardzo szybkie małe alokacje, świetne wielowątkowo | Aplikacje Google, intensywne alokacje |
| mimalloc (Microsoft) | Najnowszy, świetny balans szybkość/fragmentacja | Nowe projekty, .NET runtime |
# Podmiana allocatora BEZ rekompilacji — przez LD_PRELOAD
$ LD_PRELOAD=/usr/lib/libjemalloc.so.2 ./serwer
# Ten sam binarka, inny allocator — natychmiastowy benchmark
# Porównanie RSS pod obciążeniem (ten sam workload, różne allocatory)
$ /usr/bin/time -v ./serwer 2>&1 | grep "Maximum resident"
Maximum resident set size (kbytes): 524288 # glibc
Maximum resident set size (kbytes): 312456 # jemalloc — ~40% mniej
# Permanentna podmiana: link przy kompilacji
$ gcc program.c -ljemalloc -o programMechanizm podmiany to LD_PRELOAD nadpisujący symbole malloc/free z libc — ten sam mechanizm interpozycji, który napędza AddressSanitizer. Reguła praktyczna: jemalloc gdy walczysz z fragmentacją i RSS na długo żyjącym serwerze, tcmalloc gdy bottleneckiem jest throughput małych alokacji w wielu wątkach. Zawsze jednak najpierw zmierz — dla wielu aplikacji domyślny glibc jest wystarczający, a podmiana to przedwczesna optymalizacja.
Diagnostyka — narzędzia
| Narzędzie | Zastosowanie |
|---|---|
strace -e brk,mmap | Które syscalle pamięci program faktycznie wykonuje |
MALLOC_STATS=1 | Statystyki aren i binów glibc na wyjściu |
malloc_info() | Programmatyczny zrzut stanu allocatora (XML) |
valgrind --tool=massif | Profil zużycia sterty w czasie (heap profiler) |
jemalloc + jeprof | Profiling alokacji z atrybucją do funkcji |
/proc/<pid>/smaps | Szczegółowy podział RSS na mapowania (heap, mmap, anon) |
cat /proc/<pid>/status | VmRSS vs VmData — rezydentna vs zarezerwowana |
Podsumowanie: allocator jako warstwa, którą trzeba rozumieć
malloc i free to nie cienkie wrappery na kernel — to wyrafinowana warstwa zarządzania pamięcią z arenami, binami, chunkami i strategiami przeciwdziałania fragmentacji. Inżynier rozumiejący tę warstwę nie panikuje na widok wysokiego RSS po free(), wie kiedy to fragmentacja a kiedy realny wyciek, i potrafi świadomie dobrać allocator do charakterystyki workloadu.
Kluczowe zrozumienia: free() oddaje pamięć allocatorowi, nie systemowi; granica brk vs mmap na 128 KB determinuje czy pamięć kiedykolwiek wróci; tcache i wiele aren przyspieszają wielowątkowość kosztem RSS; a metadane chunków przeplatają się z danymi, co czyni use-after-free wektorem korupcji. Ta wiedza zamienia „tajemniczy wzrost pamięci” w przewidywalny, mierzalny i kontrolowalny aspekt systemu.
RSS, który nie spada po free(), to najczęściej nie bug w twoim kodzie — to allocator robiący dokładnie to, do czego został zaprojektowany. Pytanie nie brzmi „dlaczego nie zwolniło”, tylko „czy to fragmentacja, czy faktycznie trzymam żywe referencje”.
Powiązane artykuły
- Anatomia segfaulta — od MMU przez kernel po core dump w gdb — use-after-free i korupcja chunków od strony page faultów
- ELF i dynamic linking — co dzieje się zanim odpali main() — LD_PRELOAD jako mechanizm podmiany allocatora
- mmap — kiedy memory-mapped I/O bije read() i write() — mmap dla dużych alokacji anonimowych
- fork() Linux — co naprawdę robi pod spodem — copy-on-write i współdzielenie stron sterty


