Informatika2-2015/Eloadas 12 C-5 Memoria kezeles
Tartalomjegyzék |
Memória kezelés
A programozási nyelveknek alapvetően két fajta hozzáállása lehet a memória kezeléséhez, automatikus vagy manuális. A ma is elterjedt programozási nyelvek közül csak a C és a C++ az ami még manuális memóriakezelést használ. A mai előadáson megnézzük hogy ez pontosan mit jelent. Ez azért is tanulságos lesz, hogy jobban megértsük hogy működnek azok a nyelvek amik automatikus memória kezelést használnak.
Mit is értünk memória kezelés alatt
Először nézzünk egy python példát:
# Ez egy lista objektum: lista1 = [1, 2, 3] # Ez az objektum valahol letezik a memoriaban, # es a "lista1" valtozo hivatkozik ra. # Ha ezt csinalom akkor mar ket valtozo hivatkozik ra: lista2 = lista1 # Ha ezt, akkor megint csak egy: lista1 = [] # Ha ezt csinalom akkor mar egy valtozo sem hivatkozik ra: lista2 = [] # Akkor vajon letezik meg az a lista? # Elerni mar nem tudom egyik valtozon keresztul se.
A válasz a kód végén feltett kérdésre az, hogy nem tudjuk. Mivel nem elérhető az a lista sehogyan sem, ezért a program helyes futása szempontjából mindegy hogy még létezik-e az a lista, hiszen már soha nem tudjuk használni semmire. Azonban abból a szempontból fontos hogy létezik-e, hogy ha még létezik, akkor a memóriának az a része ahol ez a lista található, nem használható másra.
Mivel a python egy olyan nyelv, ami automatikus memóriakezelést tartalmaz, ezért ott a felhasználónak nem kell törődnie ezzel a problémával, a python interpreter biztosít minket hogy minden amire már nincs szükség az idővel megsemmisül, és a soha nem fog a program túl sok fölösleges memóriát használni. Majd az előadás későbbi részében visszatérünk rá, hogy ezt pontosan hogyan csinálja.
Azonban a példa mutatja, hogy a program számára a memória is egy erőforrás, amit kezelni kell. Amikor létrehozok egy objektumot vagy bármilyen változót, akkor annak lefoglalok/allokálok valamennyi memóriát. Ezt a részt a memóriában fel kell szabadítani mielőtt lefoglalhatom valamilyen más célra. A pythonban ez az egész folyamat automatikus, nem kell törődni vele nagyon, azonban a C-ben ennél kicsit bonyolultabb a helyzet.
C memóriakezelés
C verem
A C memóriakezelésének egy részével már találkoztunk. Amikor két előadással ezelőtt arról beszéltem, hogy mi egy változó élettartalma, akkor az egyfajta memóriakezelés. Mindig amikor elkezdődik egy blokk a kódban, akkor a C program lefoglalja a szükséges memóriát az abban levő változóknak, és amikor vége a blokknak, akkor felszabadítja ezt a memóriát.
Mivel a blokkok szigorúan tartalmazzák egymást (nem lehet részleges átfedés két blokk között), ezért ha egy blokk éppen fut amig egy másik blokk kezdődik, akkor a másik blokknak lesz előbb vége. Ez azt jelenti, hogy ha lefoglaltam már az éppen futó X blokkhoz X memóriát, és most lefoglalok Y blokkhoz Y memóriát, akkor biztos az Y-t kell előbb felszabadítanom mint az X-et.
Emiatt ezeket a memóriablokkokat tárolhatom egymás "tetején", az X tetejére tehetem az Y-t, hisz az Y-t biztos előbb kell eldobnom mint az X-et. A memóriának azt a részét, ahol a C program a blokkok memóriáját így, egymás tetején, tárolja, úgy hívják hogy verem (angolul stack).
Itt látható egy ábra az angol wikipédiáról, hogy hogy néz ki ez a verem. Itt a DrawSquare függvény meghívta a DrawLine függvényt, és amíg a DrawLine függvény fut, addig a DrawLine függvény paraméterei és belső (lokális) változói le vannak tárolva a verem tetején. Mivel a DrawSquare biztosan nem tud véget érni amig a DrawLine még fut, ezért nem baj hogy lejjebb van a veremben és még nem felszabadítható.
Ahhoz hogy ez így megtehető legyen, az is kell egyébként, hogy a C-ben minden típusnak fix a mérete. Ezért, miután lefoglaltam egy memóriablokkot a DrawSquare-nek, a tetejére tehetek egy másik memóriablokkot, és nem kell aggódnom amiatt, hogy hirtelen nagyobb hely fog kelleni valamelyik változónak a DrawSquare-ben. (Ezért van az is, hogy a tömbök is fix méretűek ellenben a python-os listával, és ilyen kevés fajta típus van.)
És így az is látható, hogy mi történik akkor, ha használunk egy mutatót, ami olyan változóra mutat, ami már megsemmisült, aminek már vége a blokkjának: ha kezdődött egy másik blokk azóta, akkor az könnyen lehet hogy ugyanazt a helyet elfoglalja a memóriában amire mutat a mutató, és akkor valami olyasmit módosítunk a mutatón keresztül amit nem akarnánk.
C kupac
Azonban vannak olyan adatok, amiknek az élettartama nem ilyen szépen struktúrált. Egy korábbi példát nézve, lehet hogy egy függvény egy tömbben akar visszaadni adatokat, de a tömb méretéről csak azt tudjuk a program írásakor, hogy valahol 2 és 100000 között lesz. Az ilyen függvénynél nem jó mindig egy 100000-es tömböt használni, főleg ha a függvény sokszor lefuthat a program futása folyamán. Használható az ilyen esetekben a manuális memóriakezelés.
A C-ben a memóriának a másik fontos része, a veremen kívül, a kupac. A kupacból a programozó tud kérni memóriát egy speciális C függvénnyel, viszont az így elkért memóriát szintén a programozónak kell felszabadítania, egy másik függvénnyel. A memória kérésére használt speciális függvény a malloc(), a felszabadításra használt függvény a free(). Mielőtt jobban kifejtem a működésüket, nézzünk először egy egyszerű példát:
int i; int *tomb = malloc(10 * sizeof(int)); tomb[0] = 0; for(i = 1; i < 10; ++i) { tomb[i] = tomb[i-1] + i; } free(tomb); tomb = NULL;
Tehát amit itt látunk, hogy a malloc()-kal kérek egy 10 méretű tömbnyi memóriát, majd azt a memóriát használom 10 méretű tömbként, aztán felszabadítom a memóriát. A malloc() függvény paramétere az, hogy mennyi memóriát kérek, byte-ban mérve. Van egy spciális operátor a C-ben, a sizeof(), ami arra való, hogy megmondja, hogy egy alaptípus hány byte-ot foglal el. Tehát, a fenti példában, az int tipikusan modern rendszereken 4 byte méretű (ami 32 bit, ezért van hogy -2^31 és 2^31 közötti számokat tud tárolni), 40 byte memóriát foglalunk le a malloc()-kal. (De azért jó a sizeof()-ot használni, és nem csak odaírni hogy 40, mert ki tudja hogy valamikor használni akarják-e a programunkat nem tipikus rendszeren. Plusz, akkor nem kell megjegyeznünk hogy az int 4 byte-os.)
Aztán, miután tudom hogy már nem akarom többé használni ezt a tömböt, felszabadítom a számára lefoglalt memóriát a free()-vel. Jó szokás a memória felszabadítása után törölni (NULL-ra állítani) a mutatót, hogy nehogy próbáljuk a felszabadítás után is használni, hiszen addigra már lehet hogy valamilyen más célra van lefoglalva.
Tehát az eddigieket összefoglalva, a C-ben a malloc() és a free() használható manuális memóriakezelésre, ami azt jelenti hogy a memória lefoglalását és felszabadítását a programozó irányítja, nem hagyja a programnyelvre.
python fájlkezelés hasonlat
A python fájlkezelésnél tanultuk, hogy a legtöbb esetben ajánlott a fájl megnyitását a with kulcsszóval egybekötni, és akkor a fájl le lesz zárva a with blokk végén:
with open("adatok.txt", "w") as fajl: muszer = Muszer() fajl.write(muszer.azonosito + "\n")
De bizonyos esetekben a fájlt nem csak egy blokkon belül akarom használni, hanem pl. vissza akarok térni vele egy függvényből:
def iedik_fajl(n): fajlnev = "adatok_{}.txt" % n fajl = open(fajlnev, "w") return fajl
Ekkor ennek a függvény felhasználójának a felelőssége hogy bezárja a fájlt, mikor már nem használja többet, különben nem garantált a jó működés. Ehhez hasonló a verem és a kupac különbsége a C memóriakezelésnél:
- A legtöbb esetben megfelelő hogy egy blokkon belül érhető el csak a változó, ekkor egyszerűen definiálok egy változót, és az a veremben lesz tárolva.
- Bizonyos esetekben jobb ha nem vagyok a blokkokhoz kötve így, ekkor manuálisan is megoldhatom a memória kezelését a malloc()-kal és free()-vel.
A hasonlóságok nem véletlenek, a fájlok is úgy tekinthetőek mint egy erőforrás, amit a programoknak kezelniük kell, csak az még nehezebben automatizálható úgy hogy mindig jól működjön, és kevesebb problémát tud okozni a rossz kezelés, ezért annak a manuális kezelését a python-ban is engedik.
C memóriakezelés példák
Nézzünk néhány példát a memóriakezelésre.
Tetszőleges méretű tömbbel visszatérés
Korábban néztük, hogy amikor egy függvény egy tömbbel akar visszatérni, akkor egy lehetséges megoldás, hogy paraméterként megkapja a tömböt amibe beírja az adatokat. Egy másik lehetséges megoldás, hogy a függvény manuálisan lefoglal megfelelő mennyiségű memóriát a tömbnek. Ekkor a függvény visszatér a mutatóval erre a tömbre, és a függvény meghívójának felelőssége azt a memóriát felszabadítani:
// A visszateresi erteket fel kell szabaditani! int *beolvasott_range(int *n) { int i; scanf("%d", n); int *tomb = malloc(*n * sizeof(int)); for(i = 0; i < *n; ++i) { tomb[i] = i; } return tomb; } int main() { int N, i; int *mostani_range; mostani_range = beolvasott_range(&N); for(i = 0; i < N; ++i) { printf("%d ", mostani_range[i]); } free(mostani_range); return 0; }
Itt láthatjuk, hogy a függvény a tömb leendő méretét a parancssorról olvassa be, tehát a függvény hívója nem tudhatta előre, hogy mekkora tömböt adjon oda feltöltésre. Az ilyen függvényeknél a dokumentáció mindig tartalmazza, hogy fel kell szabadítani a visszatérési értéket.
Változó méretű tömb
Ha egy tömbnek akarjuk tudni változtatni a méretét, az lényegében elérhető, ha teszünk köré egy megfelelő struktúrát, megfelelő függvényekkel:
struct ValtozoTomb { int meret; int *adat; }; void ValtozoTomb_atmeretez(struct ValtozoTomb* tomb, int uj_meret) { if(uj_meret == 0) { (*tomb).meret = 0; free((*tomb).adat); (*tomb).adat = NULL; } else if((*tomb).meret != uj_meret){ int *uj_adat = malloc(uj_meret * sizeof(int)); int i; // Atmasoljuk amit kell if(uj_meret < (*tomb).meret) { for(i = 0; i < uj_meret; ++i) { uj_adat[i] = (*tomb).adat[i]; } } else { for(i = 0; i < (*tomb).meret; ++i) { uj_adat[i] = (*tomb).adat[i]; } for(i = (*tomb).meret; i < uj_meret; ++i) { uj_adat[i] = 0; } } //Felszabaditjuk a regi memoriat: free((*tomb).adat); //Beallitjuk az uj ertekeket (*tomb).meret = uj_meret; (*tomb).adat = uj_adat; } }
Ezt a változtatható méretű tömböt pl. így lehet használni:
int main() { struct ValtozoTomb teszt_tomb = {0, NULL}; ValtozoTomb_atmeretez(&teszt_tomb, 10); teszt_tomb.adat[0] = 20; ValtozoTomb_atmeretez(&teszt_tomb, 2); printf("%d", teszt_tomb.adat[0]); // Ez az adatok felszabaditasa: ValtozoTomb_atmeretez(&teszt_tomb, 0); return 0; }
Figyeltem a kód megírása közben, hogy amikor átméretezem, akkor a régi adatot mindig felszabadítsam. Ennél a tömbnél a helyes használat átméretezni 0-ra amikor befejeztük a használatát, mert az felszabadítja a belső adattárolót. Az, hogy a ValtozoTomb struktúra megsemmisül a blokk végén, az nem jelenti alapból azt hogy a benne levő adattároló is felszabadul, csak ha direkt felszabadítjuk így.
Láncolt lista?
A láncolt lista egy népszerű módszer adatok tárolására. Valahogy így néz ki:
struct ListaElem { int data; struct ListaElem *next; } int main() { }
[Fájl:C lancolt lista.png]
Automatikus vs. manuális memóriakezelés
Hogy működik az automatikus memóriakezelés?
Most, hogy részletesen tárgyaltuk, hogy hogy működik a memóriakezelés a C-ben, teszek egy rövid kitérőt, hogy a python, és más hasonló nyelvek működéséről adjak egy rövid összefoglalót. Továbbra is azt a problémát kell megoldani, hogy a programozó lefoglalhat a memóriából memóriablokkokat, és azokat fel kell szabadítani, ha már nincsenek használatban. Bár itt a memória foglalása közvetett, és nem látszik első ránézésre, hogy pontosan mikor történik új memória foglalása, de attól még erről van szó.
A legtöbb automatikus memóriakezelő rendszer az úgynevezett szemétgyűjtés (garbage collection) módszert használja. A megoldás fontos eleme, hogy nem kell azonnal felszabadítani a memóriablokkot az utolsó használat után, hanem ráér később is. A szemétgyűjtő program (a python esetében ez az interpreter egy része) számontart minden memóriablokkot ami le lett foglalva. Ezen kívül, a szemétgyűjtő program tudja ellenőrizni, hogy milyen változók léteznek a programban, és azok mely memóriablokkokra mutatnak.
Bizonyos rendszerességgel, a program elvégzi a szemét gyűjtését, és kiválogatja, hogy melyik memóriablokkokra van szükség, melyekre nincs. Ez nem olyan egyszerű, hogy csak azt ellenőrzi, hogy melyikekre mutat még változó, mert az egyik memóriablokk is mutathat a másikra, mint láthattuk amikor egy listán belül tároltunk egy másik listát.
Itt látható hogy a belsőbb listák a fa nevű listán kereszül érhetőek el, és azokat is meg kell tartani szemétgyűjtéskor, hiába nincs olyan változó, ami közvetlenül rájuk mutat.
Miután elvégezte a válogatást, felszabadítja a nem szükséges memóriablokkokat, és bizonyos esetekben a megtartott, szükséges blokkokat átrendezi úgy, hogy azok hatékonyabban foglalják a helyet.
Összehasonlítás
Mint láthatjuk a manuális memóriakezelés nem egyszerű, mindjárt nézünk néhány tipikus hibát is majd, amit könnyű vele elkövetni. Sokkal nehezebb használni, de mit nyerünk vele, miért használják mégis még mindig a C-t mint programozási nyelv? A válasz, mint a C-nél általában, az, hogy hatékonyságot.
A python szemétgyűjtője ugyan felszabadítja idővel a fölösleges memóriát, de tényleg csak idővel, így a program folyamatosan több memóriát használ mint feltétlenül szükséges. (Plusz, eleve az automatikus memóriakezelés megvalósításához plusz memóriára van szükség az interpreterben, mielőtt a mi programunk akár elindult volna.) Aztán, amikor lefut a szemét gyűjtés, az bizonytalan hogy mennyi ideig tart, és kb. bármikor lefuthat. Így ha a programunknak azonnal kell tudnia reagálni, akkor megengedhetetlen hogy közben lefusson egy szemétgyűjtés, python-ban viszont ezt nem lehet megakadályozni.
Ezek mind olyan problémák, amik a legtöbb programozó számára nem számítanak. Ellenben a C problémáival, amivel mindenki szembesül aki használni próbálja. Ezért is mondtam az első C előadáson, hogy valószínűleg nem fog nektek a C kelleni a jövőben, csak a nagyon erőforrásigényes és/vagy sebesség igényes alkalmazásokhoz használják. De azért néhány tanulság levonható a most tanultakból, ami automatikus memóriakezelés esetén is érvényes.
Memóriakezelési hibák
Most megvizsgálunk néhány tipikus problémát, amit a memóriakezelés folyamán el lehet követni.
Memóriaszívárgás (memory leak) felejtéssel
Memóriaszívárgásról általánosságban akkor beszélünk, ha a program nem szabadítja fel a memóriát amire már nincsen szüksége, főleg ha közben egyre több memóriát foglal le fölöslegesen.
A legegyszerűbb ezt úgy elkövetni ha "elfelejtjük" a lefoglalt memória címét, olyan változóban tároltuk el, ami megsemmisül:
void szivargo() { int *tomb = malloc(10 * sizeof(int)); // .... if(valami_furcsa_tortent) { // Elobb kilepunk a fuggvenybol return; } // .... free(tomb); }
Ebben az esetben például hiába szabadítjuk fel a függvény végén a lefoglalt memóriát, elófordulhat olyan eset, amikor a függvény előbb kilép. Ilyenkor a tomb számára lefoglalt memória nem csak hogy nem lesz felszabadítva, de el is veszítettük a rá mutató mutatót, úgyhogy nem is tudjuk többé felszabadítani. Tehát mindig amikor ez a függvény meg van hívva és valami furcsa történik, lefoglalunk 40 byte memóriát amit soha nem szabadítunk fel. Ha ez a függvény egy órákon át futó szerver része, és a hosszú órák alatt 400000-szer megtörténik a furcsa dolog, akkor már ez az egy függvény 40 MB memóriát foglalt amit nem lehet felszabadítani, 40 MB memória elszívárgott.
Rövid ideig futó programoknál ez nem olyan nagy tragédia: amikor a program futása végetér, az operációs rendszer minden memóriát amit az a program foglalt le, felszabadít, tehát ha a programon belül nem is volt már lehetőség felszabadítani, ennek nincs hatása a számítógép további futására.
Memóriszívárgás megtartással
A memóriaszivárgás másik változata olyan, hogy akár python-ban, és bármelyik szemét gyűjtéssel működő nyelvben is el lehet követni. Arról van szó, ha valamit eltárolunk egy változóban, pedig már nem lesz rá szükségünk. Nézzük a következő python példát:
Van egy függvényünk, beolvas(fajlnev), ami adatokat olvas be egy fájlból, azonban a fájl bonyolult szisztémája miatt ez sokáig tart. Valahogy így kell használni a függvényt:
while true: fajlnev = raw_input("Melyik fajlt dolgozzam fel?") adatok = beolvas(fajlnev) feldolgoz(adatok)
Ez a program nagyon sokáig futhat, és újabb és újabb fájlokat dolgozhat fel. Mivel a fájl beolvasása már maga lassú, és időnként előfordul hogy ugyanazt a fájlt kétszer fel kell dolgozni (és a feldolgozandó fájlok tartalma nem változik), az egyik programozó úgy dönt, hogy gyorsít a dolgon azzal, hogy letárolja a már beolvasott fájloknál a bennük levő adatokat.
tarolt_adat = {} while true: fajlnev = raw_input("Melyik fajlt dolgozzam fel?") if fajlnev in tarolt_adat: feldolgoz(tarolt_adat[fajlnev]) else: adatok = beolvas(fajlnev) tarolt_adat[fajlnev] = adatok feldolgoz(adatok)
Ezzel a megoldással nincsen technikai probléma, működik, és eleinte valószínűleg gyorsabb is mint az eredeti megoldás. Azonban, azzal hogy ezeket a beolvasott adatokat a végtelenségig eltárolja, egyre több memóriát foglal, annak ellenére, hogy a fájlok többségét nem kell többször feldolgozni. Ha gyorsítani akarunk, akkor ahhoz érdemesebb lenne valami bonyolultabb megoldást alkotni, ami mondjuk egy óra után elfelejti a beolvasott fájlokat, vagy maximum 10 fájl adatait tárolja le, mert ez így nem fenntartható viselkedés.