8.5 Allokációs hiba-injektálás és leak diagnosztika

A sikeres allokációkat lefedő teszt még nem bizonyítja, hogy a program erőforrás-kezelése helyes. A valódi kérdés az, hogy minden megszakított felépítési folyamat után helyreállnak-e az invariánsok: felszabadulnak-e a már megszerzett erőforrások, érvényes marad-e a korábbi állapot, és nem marad-e használatban érvénytelen pointer.

A realloc és dinamikusan növekvő buffer rész alapján sikeres átméretezés után a régi allokációból származó pointereket nem használhatjuk tovább. A tulajdonlási szerződés és cleanup ágak adja a hibautak szerkezetét; az élettartam- és memóriahibák vizsgálati laborja pedig a diagnosztikai szemléletet. Most ezekhez determinisztikus hibákat és ellenőrizhető erőforrásmérleget kapcsolunk.

8.5.1 Determinisztikus allokációs hiba

Az allokációs hiba-injektálás (allocation failure injection) szándékosan sikertelenre változtat egy kiválasztott foglalási kísérletet. Nem szükséges hozzá tényleges memóriahiány: egy tesztadapter az NN-edik hívásnál közvetlenül null pointert ad vissza.

A számlálás foglalási kísérleteket jelent, nem csak sikeres foglalásokat. A pozitív méretű realloc szintén kísérlet, akkor is, ha az implementáció egyébként helyben tudná végrehajtani.

Ha egy adott bemenet sikeres útján KK foglalási kísérlet történik, célszerű külön tesztesetben megvizsgálni:

Minden eset friss állapotból indul. Ez az adott út hibapontjait fedi le; más bemenetekhez tartozó elágazásokat, újrapróbálkozásokat és a hibakezelés közbeni további foglalásokat nem automatikusan.

Az alábbi adapter szerződése szűk:

Ez az utóbbi fontos különbség: itt ISO C17 szerinti, null pointerrel jelzett sikertelenséget tesztelünk, nem egy POSIX-specifikus hibakód-szerződést.

8.5.1.1 Az erőforrásmérleg

Legyen LL az élő, általunk nyilvántartott allokációk száma. Ekkor:

Lután=Lelőtt+A−F, L_{\mathrm{után}}=L_{\mathrm{előtt}}+A-F,

ahol AA az új, sikeres foglalások, FF pedig a nem null pointerrel végzett, érvényes felszabadítások száma.

A nem null pointeren végzett, pozitív méretű realloc ezt a logikai darabszámot nem változtatja meg. Sikertelenségkor megmarad a régi allokáció; siker esetén annak helyébe lép az új.

Bizonyításvázlat. Kezdetben L=0L=0. Minden sikeres malloc vagy calloc eggyel növeli, minden érvényes felszabadítás eggyel csökkenti az értéket. A fenti szerződésű átméretezés nettó változása nulla. A műveletsor hosszára végzett indukcióval az összefüggés minden prefixre fennáll.

A nulla végösszeg szükséges, de önmagában nem elégséges bizonyíték: egy puszta számláló nem azonosítja az allokációkat, és nem ellenőrzi a pointerek eredetét.

8.5.2 Kidolgozott labor: részleges felépítés visszagörgetése

A példában egy eredmény két erőforrást birtokol: egy egészérték-tömböt és egy címkét. A felépítés három foglalási kísérletből áll: malloc, calloc, majd realloc.

Szerződés: a build üres kimenetet kap. Siker esetén átadja a két allokáció tulajdonlását; hibánál a kimenet üres marad. A destroy üres objektumra is meghívható.

Szerep Tulajdonos Élettartamhatár és érvénytelenítés
Felépítés alatti tömb és címke A build cleanup-logikája Hiba esetén a közös takarító ág szabadítja fel
Sikeres eredmény két allokációja A hívó result objektuma A destroy felszabadítja őket
A tömbből képzett kölcsönzött pointer Nem tulajdonos Sikeres átméretezés vagy felszabadítás érvényteleníti
Az eredményt leíró result objektum A teszteset automatikus objektuma Hatókörének elhagyása önmagában nem szabadítja fel a dinamikus tárolót

Laborprofil: ISO C17, Linux hosted környezet, x86-64 System V ABI, LP64. Rögzíthető kiinduló verziók: GCC 14.2.0 vagy Clang 19.1.7, glibc 2.39; Clang esetén illeszkedő compiler-rt 19.1.7. A helyi elérhetőséget és párosítást ellenőrizni kell. A következő példákhoz nem tartozik dokumentált fordítási vagy futtatási eredmény.

A teljes fi.c:

#include <assert.h>
#include <stddef.h>
#include <stdlib.h>
#include <string.h>

struct fi {
    size_t calls;
    size_t fail_at;  /* 0: nincs injektalt hiba */
    size_t live;
};

struct result {
    int *data;
    char *label;
    size_t len;
};

static int reject(struct fi *f)
{
    ++f->calls;
    return f->fail_at != 0 && f->calls == f->fail_at;
}

static void *fi_malloc(struct fi *f, size_t bytes)
{
    assert(bytes > 0);
    if (reject(f))
        return NULL;

    void *p = malloc(bytes);
    if (p != NULL)
        ++f->live;
    return p;
}

static void *fi_calloc(struct fi *f, size_t count, size_t size)
{
    assert(count > 0 && size > 0);
    if (reject(f))
        return NULL;

    void *p = calloc(count, size);
    if (p != NULL)
        ++f->live;
    return p;
}

static void *fi_resize(struct fi *f, void *p, size_t bytes)
{
    assert(p != NULL && bytes > 0);
    if (reject(f))
        return NULL;       /* p allokacioja valtozatlanul el */

    return realloc(p, bytes);
}

static void fi_free(struct fi *f, void *p)
{
    if (p != NULL) {
        assert(f->live > 0);
        free(p);
        --f->live;
    }
}

static int build(struct fi *f, struct result *out)
{
    int *data = NULL;
    char *label = NULL;
    int status = -1;

    assert(out->data == NULL);
    assert(out->label == NULL && out->len == 0);

    data = fi_malloc(f, 4 * sizeof *data);
    if (data == NULL)
        goto done;

    for (size_t i = 0; i < 4; ++i)
        data[i] = (int)i;

    label = fi_calloc(f, 8, sizeof *label);
    if (label == NULL)
        goto done;
    memcpy(label, "probe", sizeof "probe");

    int *next = fi_resize(f, data, 8 * sizeof *data);
    if (next == NULL)
        goto done;
    data = next;

    for (size_t i = 4; i < 8; ++i)
        data[i] = (int)i;

    out->data = data;
    out->label = label;
    out->len = 8;
    data = NULL;          /* tulajdonlas atadva */
    label = NULL;
    status = 0;

done:
    fi_free(f, label);
    fi_free(f, data);
    return status;
}

static void destroy(struct fi *f, struct result *r)
{
    fi_free(f, r->label);
    fi_free(f, r->data);
    r->label = NULL;
    r->data = NULL;
    r->len = 0;
}

static size_t run_case(size_t fail_at)
{
    struct fi f = {0, fail_at, 0};
    struct result r = {NULL, NULL, 0};

    int status = build(&f, &r);

    if (fail_at == 0) {
        assert(status == 0);
        assert(f.calls == 3 && f.live == 2);
        assert(r.len == 8);
        assert(strcmp(r.label, "probe") == 0);
        for (size_t i = 0; i < r.len; ++i)
            assert(r.data[i] == (int)i);
    } else {
        assert(status == -1);
        assert(f.calls == fail_at);
        assert(r.data == NULL && r.label == NULL);
        assert(r.len == 0 && f.live == 0);
    }

    destroy(&f, &r);
    assert(f.live == 0);
    assert(r.data == NULL && r.label == NULL && r.len == 0);
    return f.calls;
}

int main(void)
{
    size_t count = run_case(0);
    for (size_t n = 1; n <= count; ++n)
        (void)run_case(n);
    return EXIT_SUCCESS;
}

A méretek itt kis, rögzített értékek, amelyek a megadott profilban reprezentálhatók. Változó elemszámnál a korábban tanult szorzási túlcsordulás-ellenőrzés továbbra is szükséges.

A várt állapotok:

Injektált hiba Elért kísérletek száma Élő allokáció a build visszatérésekor Élő allokáció a teszt végén
Nincs 3 2 0
Első kísérlet 1 0 0
Második kísérlet 2 0 0
Harmadik kísérlet 3 0 0

A sikeres ágon a teszt az eredeti négy elem megőrzését is ellenőrzi. A harmadik kísérlet kudarcakor pedig a régi tömb még él, ezért a cleanup jogosan felszabadítja.

A nem injektált foglalások ténylegesen is meghiúsulhatnak. Ilyenkor a teszt korábbi hibát észlelhet a tervezettnél: ez nem feltétlenül cleanup-hiba, hanem a tesztkörnyezet elégtelen erőforrása.

8.5.3 Elkülönített lifetime-hibák

A következő bad.c szándékosan hibás, negatív teszt. Mindig csak egy hibamódot válasszunk: az első hiba után a program további viselkedésére már nincs nyelvi garancia.

#include <stdlib.h>

int main(int argc, char **argv)
{
    if (argc != 2)
        return 2;

    int *p = malloc(2 * sizeof *p);
    if (p == NULL)
        return EXIT_FAILURE;
    p[0] = 42;
    p[1] = 7;

    switch (argv[1][0]) {
    case 'u':
        free(p);
        return p[0];       /* UB: use-after-free */

    case 'd':
        free(p);
        free(p);           /* UB: double-free */
        return EXIT_SUCCESS;

    case 'r': {
        int *slice = p + 1;
        int *next = realloc(p, 4 * sizeof *p);
        if (next == NULL) {
            free(p);
            return EXIT_FAILURE;
        }
        p = next;
        int value = *slice; /* UB: stale slice */
        free(p);
        return value;
    }

    default:
        free(p);
        return 2;
    }
}

Az allokáció tulajdonosa mindhárom ágban a main; a slice csak kölcsönzött hozzáférés. A kezdeti foglalás hibája szabályos hibakilépés. Az r ág sikertelen átméretezéskor szabályosan felszabadítja a régi tárolót.

A hibák azonban különböznek:

ISO C17 szabály: sikeres realloc után a régi allokáció élettartama véget ér. Ez akkor is igaz, ha a visszaadott pointer ugyanazt a numerikus címet reprezentálná. Ne a régi és az új pointer összehasonlításával próbáljuk eldönteni az érvényességet.

A helyes megoldás az index megtartása és a pointer újraképzése. Az alábbi részlet az r ág helyes változata, az előző inicializálással együtt értendő:

size_t index = 1; /* az eredeti ket inicializalt elem egyike */

int *next = realloc(p, 4 * sizeof *p);
if (next == NULL) {
    free(p);
    return EXIT_FAILURE;
}
p = next;

int *slice = p + index;
int value = *slice;
free(p);                  /* slice ettol kezdve ervenytelen */
return value;

Zsugorításnál az index és a nézet teljes hosszának érvényességét külön ellenőrizni kell. A régi kapacitás nem bizonyíték az új tartomány elérhetőségére.

8.5.4 Diagnosztikai eszközök: szerepek és korlátok

A fordítási parancsok külön laborhoz tartoznak. Az ellenőrzésekhez ne legyen definiálva az NDEBUG, mert az kikapcsolná az assert vizsgálatait.

gcc -std=c17 -Wall -Wextra -Wpedantic -g -O0 fi.c -o fi
gcc -std=c17 -Wall -Wextra -Wpedantic -g -O0 bad.c -o bad

clang -std=c17 -Wall -Wextra -Wpedantic -g -O1 \
  -fno-omit-frame-pointer -fsanitize=address fi.c -o fi-asan

clang -std=c17 -Wall -Wextra -Wpedantic -g -O0 \
  -fno-omit-frame-pointer -fsanitize=address bad.c -o bad-asan

A hibás fájlnál fordítói figyelmeztetés is megjelenhet; ez nem garantált diagnózis.

Eszköz Elsődleges szerep Fontos korlát
AddressSanitizer, ASan Többek között felszabadítás utáni hozzáférés és érvénytelen felszabadítás felismerése Csak a végrehajtott utakat látja; nem teljes nyelvi érvényességvizsgáló
LeakSanitizer, LSan Elérhetőségi vizsgálat alapján potenciális szivárgások keresése Az elérhető, de szükségtelenül megtartott memóriát rendszerint nem tekinti leaknek
Valgrind Memcheck Futás közbeni memóriahozzáférés- és leakdiagnosztika Jelentős futási többletköltség; platform- és verziófüggő támogatás

A megadott Linux-profilban az ASan runtime leakellenőrzést is támogathat; ezt az ASAN_OPTIONS változó detect_leaks=1 beállításával lehet kérni. Az integrációt a tényleges compiler/runtime pároson ellenőrizni kell. Az önálló -fsanitize=leak másik lehetséges profil, nem az ASan hozzáférés-ellenőrzésének helyettesítője.

Valgrindhoz például a 3.24.0 kiadás rögzíthető kiinduló verzióként. A szokásos összehasonlítást a sanitizer nélküli, debug információt tartalmazó binárison végezzük. A Memcheck --leak-check=full és --show-leak-kinds=all kapcsolója részletesebb jelentést kér; CI-ben külön döntés, mely leakkategóriák okozzanak hibakilépést.

Az u, d és r esetet külön folyamatban vizsgáljuk. Nincs garantált UB-kimenet vagy garantált szövegű jelentés.

Különösen fontos: ha a realloc helyben dolgozik, a stale pointer címe továbbra is címezhető memóriára mutathat. Az ASan hallgatása ilyenkor nem igazolja a C-kód helyességét. Mozgatásos diagnosztikai esetet úgy kényszeríthetünk ki, hogy még az élő régi tömb mellett külön új tömböt foglalunk, átmásoljuk a két inicializált elemet, majd felszabadítjuk a régit. Ez célzott tesztmodell, nem a valódi realloc implementációjának mérése.

8.5.5 Leak, élő cache és RSS

A memóriaszivárgás tulajdonlási probléma: a felszabadítási kötelezettség nem teljesül, például mert elveszítjük az utolsó használható tulajdonosi hivatkozást.

Egy élő cache más helyzet. Ha szándékosan birtokolja az objektumokat, és van ürítési vagy kilakoltatási szabálya, a memória megtartása lehet helyes. A korlátlanul növekvő cache ugyanakkor súlyos üzemeltetési hiba lehet, még ha a leakdetektor mindent elérhetőnek lát is.

Az RSS (resident set size) az operációs rendszer által elszámolt rezidens memória mérőszáma, nem az alkalmazás élő malloc-allokációinak összege. Linuxon többek között az allocator visszatartott területei, töredezettség, stackek és leképezések is befolyásolják. A free nem ígér azonnali RSS-csökkenést.

Megfigyelés Amit nem bizonyít
live == 0, de magas RSS Önmagában nem bizonyít leaket
„Still reachable” jelentés Nem bizonyítja a cache méretpolitikájának helyességét
Nincs leakjelentés Nem zárja ki az elérhető memória indokolatlan megtartását
Stabil RSS Nem zárja ki kis mennyiségű memória folyamatos elvesztését

Ezért együtt vizsgáljuk a tulajdonosi állapotot, az allokációk és kért C bájtok mérlegét, a cache elemszámát és az OS-mutatókat.

Gyakori hibás javítás, hogy free(p) után csak p = NULL kerül a kódba. Ez hasznos állapotfrissítés, de más aliasokat nem módosít, és korábbi hibás hozzáférést nem tesz helyessé.

8.5.6 Feladatok

8.5.6.1 A hibautak levezetése

Írd le a második és harmadik foglalás kudarcakor élő erőforrásokat, majd a cleanup hatását. Miért marad változatlan a live értéke sikertelen átméretezéskor?

8.5.6.2 Új foglalási pont beépítése

Egészítsd ki a build függvényt egy 16 C bájtos ideiglenes munkaterülettel. Foglalása az átméretezés után, a tulajdonlás átadása előtt történjen. A munkaterület siker esetén sem kerülhet a kimenetbe. Bővítsd az ellenőrzéseket.

8.5.6.3 Stale slice és félrevezető memóriaadat

Egy feltételezett vizsgálatban az r eset nem ad ASan-jelentést, a teljes cleanup után pedig az RSS nem csökken. Bizonyítja-e bármelyik megfigyelés a helyességet vagy a leaket? Adj célzott javítást és ellenőrzési tervet.

8.5.7 Megoldások

8.5.7.1 A hibautak levezetése

A második kísérlet előtt csak a tömb él: L=1L=1. A címke foglalása sikertelen, ezért a null címke felszabadítása nem csinál semmit; a tömb felszabadítása után L=0L=0.

A harmadik kísérlet előtt a tömb és a címke is él: L=2L=2. Sikertelen, pozitív méretű realloc esetén a régi tömb megmarad. A két cleanup-művelet sorrendben 2→1→02\to1\to0 változást eredményez.

Ellenőrzési feltétel: hibás visszatéréskor üres kimenet és nulla élő allokáció.

8.5.7.2 Új foglalási pont beépítése

A build elején, minden lehetséges ugrás előtt adjuk hozzá:

unsigned char *scratch = NULL;

A sikeres átméretezés és az elemek inicializálása után, de a kimenet kitöltése előtt:

scratch = fi_malloc(f, 16);
if (scratch == NULL)
    goto done;

A közös cleanup elején:

fi_free(f, scratch);

Ezek a korábbi C17-fájlba illesztendő részletek. A scratch tulajdonosa végig a build; minden visszatérési úton felszabadul, kölcsönzött nézet nem kerül ki belőle.

A sikeres ág elvárt hívásszáma négyre változik. A main a mért hívásszámból képezi a hibapontokat, ezért az új negyedik esetet is lefedi.

Ellenőrzési feltétel: sikeres visszatéréskor továbbra is csak két allokáció él; minden injektált hibánál nulla.

8.5.7.3 Stale slice és félrevezető memóriaadat

Egyik megfigyelés sem elegendő. Az ASan nem feltétlenül látja a helyben végzett átméretezéshez tartozó nyelvi élettartamsértést. A változatlan RSS pedig lehet allocator-retenció következménye.

A javítás: az elemindexet őrizzük meg, majd sikeres átméretezés után az új pointerből képezzük újra a nézetet. A vizsgálati terv külön tartalmazza a sikertelen átméretezést, a sikeres tartalommegőrzést és a mozgatást kényszerítő negatív tesztet.

Ellenőrzési feltétel: a javított út nem használ régi aliast, minden hibakimenet rendezett, és az erőforrásmérleg visszaáll. A sanitizer és a leakdetektor ezt kiegészítő bizonyíték, nem teljes helyességi bizonyítás.

8.5.8 Összefoglaló

Az allokációs hibák teszteléséhez nem tényleges memóriahiány, hanem pontosan kiválasztott hibapont kell. Az NN-edik foglalás elutasítása ellenőrizhetővé teszi a részleges felépítés cleanup-ját. Az erőforrásmérleg a tulajdonlási szerződést vizsgálja; az ASan, LSan és Memcheck végrehajtott hibákat keres. Egyik sem helyettesíti az élettartam-szabályokat: sikeres realloc után a régi nézeteket újra kell képezni, az RSS pedig nem tekinthető leakszámlálónak.