Unul din cele mai mari avantaje pe care le au C# și ecosistemul .NET este gestionarea automată a memoriei. Este și una din caracteristicile cheie care face dezvoltarea cu C# atât de răspândită în prezent, comparativ cu limbaje care nu beneficiază de asta, precum C sau C++.
E important să înțelegem exact cum funcționează, care sunt principiile din spate, pentru a putea optimiza tot timpul codul din aplicațiile noastre, în felul ăsta obținând mereu maximul de performanță posibil, dacă asta ne dorim.
Performanța nu e mereu pe primul loc, dar o să lasăm subiectul ăsta pentru o altă discuție.
Ce înseamnă management automat de memorie pentru tine ca developer? Înseamnă că nu trebuie să-ți faci griji în legătură cu memoria folosită de programele sau aplicațiile tale, decât în anumite situații, o să vedem imediat care sunt ele.
Spuneam aici că un obiect este tot timpul creat în zona de memorie denumită “heap”, sau mai bine spus, i se alocă memorie în acea zonă, iar noi primim o referință către adresa respectivă. Acela este locul unde sunt create și păstrate datele tuturor obiectelor de tip referință din aplicația noastră.
Asta înseamnă că de câte ori folosim cuvântul cheie new
pentru a crea un obiect, o bucățică de memorie este ocupată cu datele respectivului obiect. Dar ce facem atunci când nu mai avem nevoie de obiectul pe care l-am creat?
Aici intervine mecanismul despre care vorbeam și face “curățenie” după noi, eliberând memoria de obiectele care nu mai sunt folosite.
Există totuși niște limitări ale acestui mecanism.
În .NET există 2 tipuri de cod
- Cod gestionat (sau managed code)
- Code negestionat (sau unmanaged code)
Mecanismul de curățare al memoriei se ocupă doar de cod din prima categorie, codul “gestionat”.
Mediul de execuție - Common Language Runtime
Codul de C# pe care noi îl scriem în aplicațiile noastre, nu reprezintă în mod direct instrucțiuni pentru procesor. Da, procesorul este componenta hardware care se ocupă cu execuția instrucțiunilor date de programele software.
Dar în cazul codului de C#, instrucțiunile nu se transmit direct la procesor, pentru că nu va ști ce să facă cu codul de C#, pentru că el nu “vorbește” C#, ci limbaje mult, mult mai abstracte de atât.
Aici intervine CLR, sau Common Language Runtime, care este mediul în care aplicațiile se execută. Mediul ăsta joacă un fel de rol de “translator” dacă vrei, pentru că face exact ceea ce povesteam mai devreme, traduce codul nostru de C# într-un alt limbaj, denumit Intermediate Language, sau limbaj intermediar.
De ce îi spune “intermediar”, poate te întrebi.
Poartă numele ăsta, pentru că nici acest limbaj nu este cel final, executat de procesorul sistemului hardware. El este exact așa cum îi spune și numele “intermediar”, adică un limbaj de tranziție.
Asta se întâmplă la pasul la care codul nostru este compilat, adică se face acțiunea de build și o colecție de clase de C# sunt convertite în ceea ce poartă numele de assembly (ansamblu), adică un fișier cu extensia .dll, care conține tot codul scris de noi, dar tradus în acel limbaj intermediar.
În momentul în care execuția programului nostru începe, se începe execuția acestui cod intermediar, care în timp real este transformat în limbaj pe care procesorul “îl vorbește”, denumit cod mașină, sau cod binar, adică instrucțiuni formate doar din 0 și 1. De-asta spuneam că limbajul pe care procesorul îl vorbește e unul mult mai abstract, pentru că e extrem de diferit de codul pe care îl generăm și manipulăm noi ca și programatori.
Revenind, spuneam că acest cod e transformat în cod binar în timp real, în timpul execuției. Cum se întâmplă asta?
Cu foarte puțin înaintea execuției codului merge ceea ce se numește Just In Time Compiler sau compilator în timp real, care are grijă ca înainte să dea drumul instrucțiunilor spre procesor, să le transforme în limbaj binar.
Imaginează-ți ca o bară de progres care se derulează în momentul în care dăm play unei melodii și avem acel punct care se mișcă de la stânga sprea dreapta, dând de înțeles că rularea fișierului muzical face progres. Așa îți poți imagina întermeni simpliști și execuția codului, iar puțin inaintea acelui punct de progres, se află compilatorul “în timp real” care traduce codul fix înainte ca el să fie trimis pentru execuție la procesor.

Cod gestionat & negestionat
Revenind la codul gestionat, el este reprezentat de totalitatea claselor al căror obiect poate fi generat și distrus în totalitate de către mediul de execuție (sau CLR - Common Language Runtime).
Acum, în cea de-a 2-a categorie, codul negestionat, se află toate obiectele care oferă posibilitatea de interacțiune cu alte componente externe, cum ar fi sistemul de fișiere al sistemului de operare, bazele de date sau o conexiune prin rețea.
Aceste resurse, cum ar fi bazele de date sau componente ale sistemului de operare, nu se află sub tutela mediului de execuție și atunci crearea și distrugerea obiectelor generate în procesul de interacțiune al codului nostru cu aceste componente nu poate fi posibilă în mod automat.
Se spune despre aceste componente că mediul de execuție oferă “interoperabilitate” cu ele, dar doar atât. Nu se ocupă de gestiunea lor.
Dacă ar fi să fac o analogie pentru a te ajuta să înțelegi mai bine, gândește-te că ai la tine acasă o persoană care îți face curățenie regulat, dar doar la tine acasă. Dacă tu alegi să faci mizerie la birou, în mașină sau oriunde în altă partea în afara casei tale, persoana respectivă nu va fi responsabilă să curețe după tine.

Cum se face eliberarea memoriei în cazul ăsta?
Eliberarea memoriei ocupate de obiecte ale codului negestionat nu se face atât de complicat pe cât ai crede, în cele mai multe cazuri. În esență stă în implementarea unei interfețe în toate clasele care comunică într-un fel sau altul cu orice fel de resurse externe, cum ar fi cele din exemplele menționate mai devreme.
Interfața despre care vorbesc, se numește IDisposable
, care declară o singură metodă denumită Dispose
.
Implementarea acestei metode va conține instrucțiuni despre cum să se facă eliberarea resurselor folosite în procesul respectiv. Instrucțiuni care pot fi mai mult sau mai puțin complicate, în funcție de caz.
În momentul în care implementăm această interfață IDisposable
, mecanismul de curățare al memoriei va prelua clasa noastră și va executa metoda Dispose
în momentul în care se face o curățare, împreună cu toate celelalte obiecte pentru care curățarea se face automat.
Deci va trata și aceste obiecte, trebuie doar să-i spunem ce să “facă efectiv pentru a le curăța”, iar de-acolo se ocupă fără ca noi să intervenim în vre-un fel.
Acum poate te întrebi, păi și cum trebuie implementată metoda asta, ce anume trebuie să fac eu pentru a șterge resursele din memorie?
De cele mai multe ori implementarea ta de IDisposable
nu trebuie să facă altceva decât să delege apelul le metoda Dispose
a resurselor pe care le-a folosit. Făcând asta, se declanșează un lanț de execuții care va ajunge până la punctul unde există instrucțiunile necesare pentru a elibera aceste resurse din memorie.
static void Main(string[] args)
{
MetodaMea(); // cod gestionat
}
static void Main(string[] args)
{
// cod negestionat
// varianta 1
using(var db = new DatabaseConnection())
{
var users = db.GetUsers();
}
// varianta 2
var db = new DatabaseConnection();
var users = db.GetUsers();
db.Dispose();
}
Ce se întâmplă dacă nu curățăm resursele negestionate
În cazul în care ignorăm tratarea acestor resurse, ele vor rămâne in memoria sistemului pe termen nedefinit și aplicațiile noastre vor deveni neperformante, folosind mult mai multă memorie decât ar avea nevoie în mod normal.
Fenomenul ăsta poartă numele de memory leak sau “scurgere de memorie”, pentru că e ca și cum pierdem memorie care nu mai poate fi recuperată în niciun fel. Acest lucru ducând nu numai la aplicații mai puțin performante, dar și la sisteme de operare îngreunate de astfel de aplicații ale căror resurse rămân pierdute pentru că nimeni nu mai poate ajunge ele pentru a le elibera.
Ține minte totuși că am simplificat un pic procesele pentru a te ajuta să înțelegi cum funcționează lucrurile în ansamblu.
Atât pentru azi, ne auzim data viitoare.