Valgrind, muistinhallintaanalysaattori

Ohjelmointi2: Valgrind materiaaliosiossa

Muistityypit ja niiden elinajat

Sidonta voi tarkoittaa muuttujan liittämistä tiettyyn muistipaikkaan. Sidonnan alussa muistitila varataan muuttujalle, ja kun sidonta on valmis, muistitilaa vapautetaan. Jaksoa, jonka aikana muuttuja on sidottu tiettyyn muistipaikkaan, kutsutaan muuttujan elinkaareksi. C++:ssa ohjelmoija voi määritellä muuttujia, joilla on eri elinikä, mukaan lukien:

Staattiset muuttujat

static int globalVariable = 10; // Staattinen globaali muuttuja
void foo() {
      static int staticLocalVariable = 5; // Staattinen paikallinen muuttuja
}

Muuttujat, jotka esitellään “static” avainsanalla, ovat esimerkkejä staattisista muuttujista. C++:ssa staattinen muuttuja sidotaan muistipaikkaansa ennen ohjelman suorittamista, ja sidos säilyy koko suorituksen ajan. Aiemmissa C++:n versioissa kaikkia muuttujia pidettiin staattisina.

Pinodynaamiset muuttujat funktion sisällä

void bar() {
   int pinoMuuttuja = 7; // Pinodynaaminen paikallinen muuttuja
}

bar() suorittamisen jälkeen pinoMuuttuja tuhotaan pinosta, eikä sitä enää ole.

Eksplisiittiset kekodynaamiset muuttujat

int* dynaaminenMuuttuja = new int(42); // Eksplisiittinen keon dynaaminen muuttuja
delete dynaaminenMuuttuja; // Eksplisiittinen purku ennen ohjelman päättymistä

Eksplisiittinen muistinvaraus komennolla new tai malloc.

void bar() {
   int* dynaaminenMuuttuja = new int(42); // Eksplisiittinen keon dynaaminen muuttuja
}

Mikään eksplisiittinen poistaminen tai keon muistin poistaminen ei johda muistivuotoon. Mielellään poisto tehdään samassa skoopissa, missä muuttuja määriteltiin.

Epäsuorat keon dynaamiset muuttujat

C++:ssa implisiittisiä dynaamisia muuttujia voidaan luoda esimerkiksi seuraavalla tavalla:

std::vector<int>v = {0};

Koodinpätkä varaa muistia heapissa sijaitsevalle int-taulukolle. Vektori käsittelee muistin varaamisen ja vapauttamisen sisäisesti, mikä helpottaa käyttäjän työskentelyä taulukoiden kanssa ilman nimenomaista muistinhallintaa.

Kuinka voit itse tarkastella pinon ja heapin käyttöä:

#include <iostream>
#include <vector>

using namespace std;

int main()
{

   // memory usage examples
   // stack
   int i = 42;
   int* stack_i = &i;
   // heap
   int* heap_i = new int(69);

   cout << "stack_i: " << stack_i << " heap_i: " << heap_i << std::endl<< std::endl;


   // heap or stack?: an array with one value
   int arr[1] = {0};
   int* arr_ptr = &arr[0];
   std::vector<int>v = {0};

   // stack
   cout << "arr_ptr: " << arr_ptr << std::end;
   // stack
   cout << "v_ptr: " << &v << std::endl;
   // heap!
   cout << "  The content of vector: " << &v.front() << endl;

   delete heap_i;
   return 0;
}

Ohjelma tulostaa:

stack_i: 0x7ffe7ac4a90c heap_i: 0x56076a6dceb0

arr_ptr: 0x7ffe7ac4a934
v_ptr: 0x7ffe7ac4a910
The content of vector: 0x56076a6dd2e0

Pino-muistiosoitteet alkavat 0x7ff*-tavuilla, kun taas heap-muistiosoitteet alkavat 0x56*-tavuilla. Taulukon arr sisältö on staattista, joten se tallentaan pinoon.

Itse vektoriobjekti (+ metatiedot) sijoitetaan pinoon, mutta vaikka vektori on pinossa, sen dynaaminen sisältö (kokonaislukutaulukko) on keossa.

Ulkoiset muuttujat

C++ tukee myös ulkoisia viittauksia (esim. “extern”-avainsana) ja tiedostoissa olevia pysyviä tietoelementtejä, jotka säilyvät ohjelman suorituksen päätyttyäkin. Näin ollen muuttujan elinikä voi olla pidempi kuin ohjelman ajonaika.

Muistinhallinnan parhaat käytännöt

  1. Vältä iteraattorin mitätöintiä:
    • ole varovainen iteraattorin mitätöimisen suhteen, kun muokkaat säilöjä (esim. lisäät tai poistat)
  2. Käytä älykkäitä osoittimia:
    • “std::unique_ptr” ja “std::shared_ptr” hallitsevat muistia automaattisesti
#sisällytä <muisti>
std::ainutlaatuinen_ptr<int> ainutlaatuinenPtr = std::make_unique<int>(42);
  1. suosi STL-säilöjä ja algoritmeja:
    • kuten “std::vector”, “std::map” ja STL-algoritmit aina kun mahdollista.
  2. Vältä keon muistin varaamista kokonaan, jos mahdollista:
    • minimoi “new”, “delete”, “malloc” ja “free” käyttö.
    • Muista poistaa ja antaa selkeät menetelmät resurssien vapauttamiseksi
  3. Profiloi ja testaa:
    • Käytä Valgrindia! (valgrind –quiet –leak-check=full ./a.out )

Muistinhallinnan analysaattori valgrind

Ohjelmointi2: Valgrind-materiaali

Muutaman rivin ohjelmasta muistinkäsittelyvirheet löytyvät silmäilemällä koodia ja miettimällä, mitä tuli tehtyä. Kun ohjelman koko kasvaa, ongelmien löytäminen muuttuu aina vaikeammaksi.

Muistinkäsittelyyn liittyviä ongelmia voi jäljittää ohjelmalla nimeltä valgrind, joka pitää suorittaa erikseen. Se analysoi ohjelmakoodin ja tulostaa virheilmoituksen, jos esimerkiksi

  • ohjelmasi varaa dynaamista muistia komennolla new, mutta ei vapauta sitä komennolla delete
  • ohjelmassa käytetään muuttujaa (dynaamisesti tai automaattisesti varattua), jolle ei ole asetettu arvoa
  • yrität käsitellä dynaamisesti varattua muistia, joka on jo vapautettu.

Huomautus

Valgrind toimii Linux-etätyöpöydällä, mutta todennäköisesti ei Mac- eikä Windows-koneilla (tai valgrindin asentaminen näille koneille ei onnistu).

Työkalu valgrind on asennettuna linux-desktopilla (Oikeudet haetaan palvelussa https://id.tuni.fi/idm/entitlement) Jos haluat, voit myös asentaa sen omalle koneellesi. Voit suorittaa valgrind-komennon joko Qt Creatorissa tai komentorivillä. Seuraavassa tehtävässä valgrind suoritetaan harjoituksen vuoksi molemmilla tavoilla, jotta opimme käyttämään sitä monipuolisemmin.

Valgrindin virheilmoitukset

Joissakin virheilmoituksissa esiintyy numeroita 1, 4, 8. Luvut tarkoittavat tyypin tarvitseman muistialueen kokoa tavuina. Merkkityypille (char) riittää yksi tavu, kokonaisluku (int) tarvitsee neljä tavua ja osoittimet kahdeksan.

Valgrind kertoo sarakkeessa Location tiedoston ja rivinumeron. Kyseinen kohta on se, missä virhe ilmenee tai missä se havaitaan. Korjattava koodikohta voi olla jossakin muualla (jossakin kohdassa, joka suoritettiin ennen virheen havaitsemiskohtaa).

Seuraavaksi käydään läpi joitakin yleisimpiä virheilmoituksia.

Use of unitialized value of size 8

Jos muuttujalle ei varata muistia (komennolla new):

int* new_int;

mutta alustamatonta muuttujaa yritetään lukea seuraamalla osoitinta, seuraa ongelmia:

std::cout << *new_int;

Valgrind valgrind --quiet --leak-check=full ./a.out tulostaa seuraavat virheet:

  • Use of uninitialised value of size 8
  • Invalid read of size 4

Jos alustat osoittimen arvoksi nullptr:

int* new_item = nullptr;

virhe “Use of uninitialised value of size 8” katoaa, mutta koodi on edelleen virheellinen, toinen virhe, Invalid read, säilyy, ja ohjelma kaatuu segmentointivirheeseen.

Conditional jump or move depends on uninitialised value(s)

Tietueen kenttä tai jokin muu muuttuja on jäänyt alustamatta. Esimerkiksi linkitetyssä listassa jollekin tietueen kentistä ei ole annettu arvoa. Jos alustamaton kenttä on jokin muu kuin next-kenttä, virhe voi ilmetä, kun kyseisen kentän arvoa yritetään tulostaa. Jos next-kenttään ei sijoiteta mitään arvoa (ei edes nullptr), ohjelma voi näyttää toimivan normaalisti. Toisaalta tästä voi aiheutua myös ikuinen silmukka, kun listaa käydään läpi niin kauan, kun next-kentän arvo ei ole nullptr.

Invalid read of size N

Yritetään käyttää (lukea) jo vapautettua muistialuetta. Esimerkiksi:

delete item_to_be_removed;
...
cout << item_to_be_removed->task << endl;

Tässä task-kenttä on merkkijono, jolloin virheilmoituksessa N on 8. Jos tulostettava kenttä olisi int-tyyppinen, N olisi 4.

Invalid free() / delete / delete[]

Yritetään vapauttaa jo vapautettua muistia. Esimerkiksi:

delete item_to_be_removed;
...
delete item_to_be_removed;

Ohjelma voi silti toimia oikein, eli virhettä on vaikea havaita ilman valgrindia.

Jos välillä sijoitetaan arvo nullptr esimerkiksi:

delete item_to_be_removed;
item_to_be_removed = nullptr;
...
delete item_to_be_removed;

valgrind ei anna mitään ilmoitusta, ja ohjelma voi näyttää toimivan oikein. Tämä johtuu siitä, että nullptr ei osoita mitään muistipaikkaa, joten mitään ei voida vapauttaakaan.

N (…) bytes in M blocks are definitely lost in loss record X of Y

Muistia jää vapauttamatta, eli delete-komento unohtui.

Ohjelma voi silti toimia oikein, eli virhettä on vaikea havaita ilman valgrindia.

TIRAKA-fall2023 tavallisimmat virheet

Vuoden 2023 Valgrind-tulokset vahvistavat aiemmat tulokset siitä, että muistivuodot ovat merkittävin virhelähde, kuten alla olevasta kuvasta ilmenee:

../../../_images/valgrind2023.png

Pääasialliset syyt muistivuodoille ovat new-operatorin (muisti) virheellinen käyttö.

Jossain määrin meillä oli myös uusi virheluokka, nimeltään irreflexive requirements.

Irreflexive requirements

Note

Virhe näyttää tältä: Error: comparison doesn’t meet irreflexive requirements, assert(!(a < a)).

Irrefleksiiviset virheet johtuvat virheellisistä vertailijoista (comparator). C++ STL:ssä vertailijat ovat usein yhteydessä tietorakenteisiin kuten std::map, std::set tai std::priority_queue, ja algoritmeihin kuten std::sort. Kun käytetään omia rakenteita tai luokkia esimerkiksi mapissä, vertailija on olennainen määrittämään elementtien lajittelukriteerit.

Vertailijat voivat olla perinteisiä funktioita, lambdoja tai funktio-objekteija (functors). Tutkitaan esimerkkiä kustakin.

Meillä on Person-strukti-esimerkki, ja seuraavaksi luomme joukon Person-strukteja, jotka lajittelemme iän perusteella:

struct Person {
   std::string name="";
   int age=0;
};

Perinteinen funktio:

bool ageComparator(const Person& p1, const Person& p2) {
   return p1.age < p2.age;
}

std::set<Person, decltype(&ageComparator)> peopleSet(&ageComparator);

Lambda:

std::set<Person, [](const Person& p1, const Person& p2) {
   return p1.age < p2.age;
}> peopleSet;

Funktori:

/* Functor */
struct AgeComparator {
   bool operator()(const Person& p1, const Person& p2) const {
      return p1.age < p2.age;
   }
};

std::set<Person, AgeComparator> peopleSet;

Lambdat ja funktorit lisäävät vertailijoiden monimutkaisuutta. Kuitenkin irrefleksiivisten vaatimusten virhe liittyy yleensä itse vertailuprosessiin eikä vertailijan muotoon.

Kuten yllä olevissa tapauksissa näytetään, vertailijan on suoritettava “on pienempi kuin” -vertailu.

  • Jos ensimmäinen parametri on pienempi kuin toinen, palautetaan true
  • Muussa tapauksessa palautetaan false

Minkä tahansa muun vertailun (kuten <=) käyttö vertailulogiikassa johtaa irrefleksiivisiin virheisiin. Lisäksi objekteja voidaan yrittää verrata väärällä tavalla, kuten osoittimien vertailu ilman asianmukaista purkamista. Virheen vianmäärityksessä tarkista ja vahvista logiikka comparator-funktiossa, erityisesti mukautettujen vertailijoiden tapauksessa.