brk, sbrk, mmap : ce que malloc cache
Tu appelles malloc(256). Tu reçois un pointeur. Tu t’en sers. Tu appelles free(). Fini.
Sauf que non. Entre ton appel et la mémoire physique, il y a trois appels système, un allocateur de ~6 000 lignes de code (la glibc), et un sous-système de mémoire virtuelle dans le noyau. Comprendre cette pile, c’est comprendre pourquoi certains programmes consomment 3 fois plus de mémoire que prévu, pourquoi free ne rend pas toujours la mémoire au système, et pourquoi un allocateur sur mesure peut être 14 fois plus rapide.
Le tas et brk
Historiquement, le tas d’un processus Unix est une zone contiguë qui commence juste après le segment BSS (données non initialisées) et grandit vers les adresses hautes. Le sommet de cette zone s’appelle le “program break”.
L’appel système brk(adresse) déplace ce sommet. sbrk(incrément) le déplace d’un nombre d’octets relatif et retourne l’ancienne position. C’est le mécanisme le plus ancien d’allocation mémoire sous Unix.
void *bloc = sbrk(4096); // avance le program break de 4096 octets
Le noyau ne fait rien de spectaculaire : il met à jour une variable interne (mm->brk), crée ou étend une zone de mémoire virtuelle (VMA), et retourne. Aucune page physique n’est allouée à ce stade. Le noyau utilise l’allocation paresseuse : la page physique n’est attribuée qu’au premier accès, via un défaut de page.
brk est rapide (pas de recherche, pas de structure complexe) mais rigide : le tas ne peut que grandir ou rétrécir par le haut. Impossible de libérer un bloc au milieu.
mmap : l’alternative
Pour les allocations plus grandes, malloc utilise mmap au lieu de brk. L’appel mmap(NULL, taille, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) demande au noyau de créer une nouvelle zone de mémoire virtuelle, découplée du tas.
L’avantage : chaque allocation mmap est indépendante. Quand tu la libères avec munmap, la mémoire est rendue au système immédiatement. Pas de fragmentation du tas, pas de dépendance à la position du program break.
L’inconvénient : mmap est plus coûteux que brk. Chaque appel crée un nouveau VMA dans le noyau, met à jour l’arbre rouge-noir des zones mémoire, et potentiellement invalide des entrées TLB. Sur des milliers de petites allocations, ce coût fixe est prohibitif.
Le seuil M_MMAP_THRESHOLD
La glibc tranche entre les deux mécanismes avec un seuil : M_MMAP_THRESHOLD. Par défaut, il est à 128 Ko (131072 octets) et s’ajuste dynamiquement.
- En dessous du seuil :
mallocutilise le tas (géré viabrk/sbrk), avec ses structures internes (bins, arenas, tcache). - Au-dessus du seuil :
mallocappellemmapdirectement. Le bloc est isolé,freeappelleramunmap.
Tu peux forcer le seuil avec mallopt(M_MMAP_THRESHOLD, valeur), mais en pratique la glibc s’ajuste bien toute seule. L’important est de savoir que ce seuil existe, parce qu’il explique des comportements surprenants.
Pourquoi free ne rend pas la mémoire
Quand tu appelles free sur un bloc alloué via le tas (brk), la glibc le marque comme libre dans ses structures internes. Mais elle ne rétrécit pas le tas immédiatement. Le bloc reste réservé au cas où un futur malloc aurait besoin d’un bloc de taille similaire.
Le tas ne rétrécit que si le bloc libéré est au sommet ET qu’il dépasse un certain seuil (configurable via M_TRIM_THRESHOLD, défaut 128 Ko). Et même dans ce cas, brk ne peut rétrécir que par le haut : si un petit bloc occupe toujours le sommet du tas, tout ce qui est en dessous reste réservé, même si c’est 99 % de mémoire libre.
C’est le mécanisme fondamental de la “fuite apparente” : ton programme a libéré 90 % de sa mémoire, mais top montre une consommation constante. La mémoire est libre pour malloc mais pas rendue au système.
Les blocs alloués via mmap, en revanche, sont rendus immédiatement par munmap. C’est pourquoi les grosses allocations ne causent pas ce problème.
Les structures internes de la glibc
Entre brk/mmap et ton pointeur, la glibc maintient une machinerie complexe :
- Arenas : plusieurs zones de tas pour réduire la contention entre fils d’exécution. Le tas principal utilise
brk, les arenas secondaires utilisentmmap. - Bins : des listes de blocs libres classés par taille. Les fastbins (petits blocs, pas de fusion) servent les allocations les plus fréquentes sans verrou lourd.
- tcache : un cache par fil d’exécution (depuis la glibc 2.26). Chaque fil a ses propres listes de blocs libres, élimine le verrou d’arena pour les cas courants.
- Métadonnées : chaque bloc porte un en-tête (8 ou 16 octets selon l’architecture) avec sa taille et des drapeaux. C’est la mémoire que tu paies pour chaque
malloc, même pour un bloc de 1 octet.
Ce que ça change en pratique
Savoir que malloc utilise brk pour les petits blocs et mmap pour les grands te donne trois leviers :
1. Regrouper les petites allocations. Si tu alloues 10 000 blocs de 64 octets, c’est 10 000 en-têtes de 16 octets = 160 Ko de métadonnées pures. Un allocateur bump pré-alloue un seul bloc et avance un pointeur – zéro métadonnées.
2. Comprendre la consommation mémoire. Si top montre 500 Mo et que ton code n’utilise que 50 Mo, ce n’est probablement pas une fuite. C’est le tas de brk qui ne rétrécit pas. Utilise malloc_stats() ou malloc_info() pour voir la répartition réelle.
3. Choisir la bonne stratégie de libération. Si tu sais que tous tes objets seront libérés en même temps (fin de requête, fin de phase), un allocateur arena est plus rapide et ne laisse pas de fragmentation résiduelle.
Ces mécanismes sont visibles en pratique avec strace, malloc_stats() et la lecture de /proc/self/maps.