Cum funcționează memoria virtuală pe Linux
Când deschizi un terminal și rulezi free -h, cifrele afișate par simple - atât RAM folosit, atât liber. În realitate, în spatele acestor numere se află un sistem complex pe care kernelul Linux îl gestionează în permanență: memoria virtuală. Înțelegerea acestui sistem explică de ce un proces poate „aloca“ mai multă memorie decât există fizic, de ce swap-ul funcționează cum funcționează, și de ce uneori un program consumă aparent mult RAM fără să facă nimic util cu el.
Problema pe care o rezolvă memoria virtuală
Pe un calculator timpuriu, programele accesau direct memoria fizică. Dacă aveai 64KB de RAM, fiecare program trebuia să știe exact unde în cei 64KB poate să scrie fără să suprascrie alt program. Această abordare era fragilă, nesigură și imposibil de scalat - un program cu un bug putea corupe memoria oricărui alt program sau chiar a kernelului.
Memoria virtuală rezolvă această problemă prin introducerea unui nivel de abstractizare: fiecare proces vede propriul spațiu de adrese, izolat de celelalte, iar kernelul (cu ajutorul hardware-ului) traduce adresele virtuale în adrese fizice reale. Un proces nu știe și nu poate ști unde anume în RAM fizic sunt datele sale - vede doar propriile adrese virtuale, care încep de obicei de la 0.
Pagini de memorie
Memoria nu este gestionată byte cu byte, ci în blocuri numite pagini (pages). Pe arhitecturile x86 și ARM moderne, o pagină are implicit 4KB. Aceasta este unitatea minimă de alocare și transfer între RAM și disc (swap).
Împărțirea în pagini simplifică enorm managementul memoriei - în loc să urmărească miliarde de bytes individuali, kernelul urmărește milioane de pagini, fiecare cu propriile atribute: dacă e în RAM sau în swap, dacă e modificată față de versiunea de pe disc, dacă e accesibilă la citire, scriere sau execuție.
Când un proces încearcă să acceseze o adresă de memorie, hardware-ul calculează automat în care pagină se află acea adresă și verifică dacă pagina e prezentă în RAM. Dacă nu e - fie pentru că nu a fost niciodată alocată fizic, fie pentru că a fost mutată în swap - apare un page fault, iar kernelul intervine pentru a rezolva situația.
Page tables și TLB
Traducerea dintre adresele virtuale și cele fizice este realizată printr-o structură de date numită page table. Fiecare proces are propria page table, menținută de kernel în RAM. Când un proces accesează o adresă virtuală, hardware-ul (Memory Management Unit - MMU) consultă page table pentru a găsi adresa fizică corespunzătoare.
Pe sistemele moderne cu spații de adrese de 64 de biți, page table-urile sunt organizate pe mai multe niveluri (de obicei 4 sau 5 pe x86_64) pentru a economisi memorie - un page table plat pentru un spațiu de adrese de 64 de biți ar ocupa terabytes.
Consultarea page table-ului la fiecare acces de memorie ar fi extrem de lentă. De aceea există TLB (Translation Lookaside Buffer) - un cache hardware mic și extrem de rapid care memorează traducerile recente. Când o traducere e în TLB, accesul la memorie este aproape instantaneu. Când nu e (TLB miss), hardware-ul trebuie să parcurgă page table-ul, ceea ce durează câteva cicluri de procesor în plus.
Aceasta este una dintre rațiunile pentru care Huge Pages (pagini de 2MB sau 1GB) îmbunătățesc performanța pentru anumite aplicații - o singură intrare în TLB acoperă mult mai multă memorie, reducând numărul de TLB miss-uri.
Spațiul de adrese al unui proces
Fiecare proces pe Linux are un spațiu virtual de adrese împărțit în mai multe regiuni cu roluri bine definite. Pe un sistem de 64 de biți, spațiul de adrese teoretic este imens (128TB pe x86_64), dar în practică e organizat astfel:
Text segment - codul executabil al programului, marcat ca read-only și executabil. Mai multe instanțe ale aceluiași program (de exemplu mai multe terminale deschise) partajează același text segment fizic în RAM - kernelul mapează același cod fizic în spațiile virtuale ale tuturor instanțelor.
Data segment - variabilele globale și statice inițializate ale programului.
BSS segment - variabilele globale neinițializate. Nu ocupă spațiu în fișierul executabil - kernelul le alocă ca pagini zero la pornire.
Heap - memoria alocată dinamic de program (prin malloc, new, etc.), crește în sus spre adrese mai mari.
Stack - stiva de apeluri a programului, crește în jos spre adrese mai mici. Fiecare thread are propriul stack.
Memory-mapped files - fișiere sau dispozitive mapate direct în spațiul de adrese prin mmap(). Bibliotecile partajate (.so) sunt încărcate astfel.
Poți vedea harta memoriei unui proces cu:
cat /proc/PID/maps
sau mai lizibil:
pmap -x PID
Page faults
Un page fault apare când un proces încearcă să acceseze o adresă virtuală pentru care nu există o mapare fizică validă în page table. Există mai multe tipuri:
Minor page fault - pagina există în memorie (de exemplu a fost alocată de un alt proces sau e în cache), dar nu e mapată în page table-ul procesului curent. Kernelul rezolvă situația actualizând page table-ul, fără acces la disc. Este rapid și absolut normal.
Major page fault - pagina nu e în RAM și trebuie adusă de pe disc (din fișier sau din swap). Implică I/O și e semnificativ mai lent. Un număr mare de major page faults indică un sistem sub presiune de memorie.
Invalid page fault - procesul încearcă să acceseze o adresă care nu aparține spațiului său de adrese. Kernelul trimite un semnal SIGSEGV procesului, care de obicei se termină cu eroarea „Segmentation fault“.
Poți urmări page fault-urile pentru un proces cu:
/usr/bin/time -v comanda_ta
Sau pentru un proces care rulează deja:
cat /proc/PID/stat | awk '{print "Minor:", $10, "Major:", $12}'
Alocare leneșă (lazy allocation)
Când un program apelează malloc(1GB), kernelul nu alocă imediat 1GB de RAM fizic. Returnează o adresă virtuală și marchează paginile ca alocate în page table, dar fără corespondent fizic. RAM-ul fizic este alocat abia când programul scrie efectiv în acele pagini, la primul acces - moment în care apare un minor page fault și kernelul alocă o pagină fizică.
Această strategie se numește lazy allocation sau demand paging și stă la baza mecanismului de overcommit - kernelul poate promite mai multă memorie decât există fizic, bazându-se pe faptul că nu toți alocanții vor folosi simultan toată memoria promisă. Funcționează bine în practică, dar în scenarii extreme duce la situații OOM descrise în articolul despre OOM Killer.
Copy-on-Write
Copy-on-Write (CoW) este o optimizare fundamentală care face ca operația fork() - crearea unui proces copil - să fie extrem de rapidă pe Linux.
Când un proces apelează fork(), în loc să copieze tot spațiul de adrese al părintelui (care ar putea fi gigabytes), kernelul mapează aceleași pagini fizice în spațiul de adrese al copilului, marcând toate paginile partajate ca read-only. Atât părintele cât și copilul văd aceleași date, partajând același RAM fizic.
Când unul dintre procese încearcă să scrie într-o pagină partajată, apare un page fault. Kernelul creează atunci o copie privată a paginii respective pentru procesul care scrie, actualizează page table-ul, și permite scrierea. Paginile care nu sunt niciodată modificate rămân partajate pe toată durata de viață a proceselor.
Aceasta explică de ce un server web care face fork() pentru fiecare cerere (modelul prefork al Apache, de exemplu) nu consumă de N ori mai mult RAM decât un singur proces - paginile de cod și datele read-only sunt partajate între toți copiii.
Zone de memorie
RAM-ul fizic nu este uniform din perspectiva kernelului. Pe arhitecturile x86, memoria este împărțită în zone cu caracteristici diferite:
ZONE_DMA - primii 16MB de RAM, rezervați pentru dispozitive hardware mai vechi care pot face DMA (Direct Memory Access) doar în această zonă. Pe hardware modern această restricție e mai puțin relevantă.
ZONE_DMA32 - primii 4GB de RAM, pentru dispozitive care suportă DMA pe 32 de biți.
ZONE_NORMAL - memoria obișnuită, mapată direct în spațiul de adrese al kernelului. Majoritatea alocărilor kernelului și proceselor vin din această zonă.
ZONE_HIGHMEM - pe sistemele pe 32 de biți, memoria peste 896MB care nu poate fi mapată direct în spațiul de adrese al kernelului (limitat la 4GB). Pe sistemele de 64 de biți această zonă nu mai există practic, deoarece kernelul poate mapa tot RAM-ul disponibil.
Poți vedea distribuția memoriei pe zone:
cat /proc/zoneinfo
Memoria partajată între procese
Pe lângă CoW, Linux oferă mai multe mecanisme explicite de partajare a memoriei între procese:
Biblioteci partajate - când mai multe procese încarcă aceeași bibliotecă (.so), codul acesteia există o singură dată în RAM fizic și e mapat în spațiile virtuale ale tuturor proceselor care o folosesc.
POSIX shared memory - procese diferite pot crea și accesa zone de memorie partajată explicit prin shm_open() și mmap(). Folosit frecvent pentru comunicare inter-proces de înaltă performanță.
tmpfs - sistemul de fișiere în RAM al Linux-ului, montat de obicei la /dev/shm și /run. Fișierele create în tmpfs există doar în RAM (și eventual swap), fără scriere pe disc.
# Vezi dimensiunea și utilizarea tmpfs df -h /dev/shm /run
Memoria unui proces: RSS, VSZ și PSS
Când monitorizezi consumul de memorie al unui proces, întâlnești mai mulți termeni care măsoară lucruri diferite:
VSZ (Virtual Size) - dimensiunea totală a spațiului virtual de adrese al procesului, incluzând tot ce e alocat dar poate nu e folosit efectiv. Poate fi mult mai mare decât RAM-ul fizic disponibil și nu reflectă consumul real.
RSS (Resident Set Size) - paginile fizice din RAM ocupate de proces în momentul măsurătorii, incluzând paginile partajate cu alte procese (biblioteci, CoW). Supraestimează consumul real dacă procesul partajează multe pagini.
PSS (Proportional Set Size) - ca RSS, dar paginile partajate sunt împărțite proporțional între procesele care le folosesc. Dacă trei procese partajează o pagină de 4KB, fiecare contribuie cu 1.33KB la PSS. Este cea mai precisă măsurătoare a consumului real de memorie per proces.
# Afișează RSS și PSS pentru toate procesele smem -r | head -20 # Sau pentru un singur proces cat /proc/PID/smaps_rollup | grep -E "Rss|Pss"
Legătura cu swap-ul
Tot ce am descris - pagini, page tables, lazy allocation, CoW - se leagă direct de swap. Când kernelul decide să elibereze RAM, alege pagini care nu au fost accesate recent (algoritmul LRU - Least Recently Used) și le mută în swap. Actualizează page table-ul pentru a marca paginile ca absente din RAM.
Data viitoare când procesul accesează o pagină din swap, apare un major page fault - kernelul trebuie să citească pagina de pe disc, să găsească RAM liber pentru ea, să o încarce și să actualizeze page table-ul. Toată această operație durează câteva milisecunde pe SSD și mult mai mult pe HDD, ceea ce explică de ce un sistem care swap-ează intensiv devine lent.
Soluțiile moderne precum zram și zswap reduc această latență prin compresie în RAM, eliminând sau reducând accesele la disc.
Resurse suplimentare
- Swap pe Linux - gestionarea memoriei virtuale pe disc
- zram și zswap - compresie în RAM ca alternativă la swap pe disc
- OOM Killer - ce se întâmplă când memoria se epuizează complet
- Huge Pages - pagini mari pentru performanță îmbunătățită
- Monitorizarea memoriei pe Linux - unelte practice de diagnosticare