Informatika2-2015/Eloadas 12 C-5 Memoria kezeles

A MathWikiből
(Változatok közti eltérés)
198. sor: 198. sor:
 
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.
 
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? ===
+
=== "->" (nyíl) operátor ===
  
A láncolt lista egy népszerű módszer adatok tárolására. Valahogy így néz ki:
+
Itt az előző példában sokszor láthattuk azt, hogy "<code>(*tomb).meret</code>" és "<code>(*tomb).adat</code>", amik kicsit csúnyán és bonyolultan néznek ki. Általában sokszor előfordul a C-ben, hogy valamilyen struktúra elemeit egy, a struktúrára mutató mutatón keresztül szeretnénk elérni. Ezért erre van speciális szintaxis, és ahelyett hogy "<code>(*tomb).meret</code>", írhatom azt hogy "<code>tomb->meret</code>".
 
+
<c>
+
struct ListaElem {
+
    int data;
+
    struct ListaElem *next;
+
}
+
 
+
int main() {
+
 
+
}
+
</c>
+
 
+
[Fájl:C lancolt lista.png]
+
  
 
== Automatikus vs. manuális memóriakezelés ==
 
== Automatikus vs. manuális memóriakezelés ==

A lap 2015. április 29., 12:15-kori változata

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ó.

Call stack layout.png

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.

"->" (nyíl) operátor

Itt az előző példában sokszor láthattuk azt, hogy "(*tomb).meret" és "(*tomb).adat", amik kicsit csúnyán és bonyolultan néznek ki. Általában sokszor előfordul a C-ben, hogy valamilyen struktúra elemeit egy, a struktúrára mutató mutatón keresztül szeretnénk elérni. Ezért erre van speciális szintaxis, és ahelyett hogy "(*tomb).meret", írhatom azt hogy "tomb->meret".

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.

link

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.

Felszabadított memória használata

Személyes eszközök