17.4 Záróreview: helyesség, diagnosztika és mérés

A záróreview nem egyszerűen annak ellenőrzése, hogy a szolgáltatás néhány helyes kérésre válaszol-e. Azt kell megmutatnia, hogy a könyvtári mag, a CLI és a TCP-adapter ugyanazt a dokumentált szerződést valósítja meg, erőforrásaikat hibautakon is rendezik, és viselkedésük reprodukálhatóan vizsgálható.

A Nonblocking TCP adapter és leállítás állapotgépét most nem tervezzük újra: az állapotátmenetekhez ellenőrizhető invariánsokat és teszteket rendelünk. A C minőségi kapuk és hibajavítási jegyzőkönyv módszeréből pedig kiadási bizonyítékcsomagot készítünk. Ez lesz a későbbi C++-port összehasonlítási alapja.

Az alábbi kód és parancsok laborreceptek; végrehajtott fordítást, sikeres tesztet vagy mért teljesítményt nem dokumentálnak.

17.4.1 Az audit tárgya és bizonyítékai

Az auditálható szerződés előfeltételek, megfigyelhető eredmények és megőrzendő invariánsok együttese. Például az „allokációs hibát kezelünk” önmagában nem szerződés. Pontosabb állítás:

Ha a konstrukció bármely allokációja meghiúsul, a függvény hibát jelez, nem ad át részlegesen inicializált objektumot, a kimeneti paraméter változatlan marad, és minden addig megszerzett erőforrást felszabadít.

Az audit három eltérő bizonyítékot kapcsol össze:

Egyik sem helyettesíti a másikat. Egy sikeres sanitizer-futás nem bizonyítja az összes lehetséges végrehajtás helyességét; egy helyesnek tűnő cleanup-gráf pedig nem igazolja, hogy a kiadott bináris valóban abból a forrásból készült.

A példák választott, itt nem ellenőrzött referenciaprofilja: Linux hosted környezet, x86-64 System V ABI, LP64 adatmodell, nyolcbites C bájt; GCC 14.2.0 és Clang 19.1.7, glibc 2.40. A tényleges laborban rögzítsük a kernel, a linker és az elemzőeszközök verzióját is. Más verzió használata nem önmagában hiba, hanem másik dokumentált profil.

A megadott anyag nem tartalmaz ellenőrzött külső forrásjegyzetet. A verziófüggő eszközviselkedést ezért a tényleges labor eszközdokumentációjával kell megerősíteni; az alábbi kapcsolók nem helyettesítik ezt az ellenőrzést.

17.4.2 Ownership és cleanup: minden erőforrásnak legyen története

Az ownership, vagyis tulajdonlás itt a lezárási felelősség egyértelmű hozzárendelése. A kölcsönzött hozzáférés nem hoz létre új tulajdonost, és nem hosszabbítja meg az erőforrás élettartamát.

Az audit első kézzelfogható eredménye egy kitöltött ownership-térkép:

Erőforrás Tulajdonos Átadás vagy élettartamhatár Invalidálás Hibautas kötelezettség
Figyelő socket Szolgáltatásvezérlő Sikeres létrehozástól lezárásig Lezárás Inicializálási hiba után is lezárni
Elfogadott socket Kezdetben az elfogadó ág, majd a kapcsolatobjektum Csak sikeres kapcsolat-inicializáláskor kerül át Kapcsolat lezárása Sikertelen átadáskor az elfogadó ág zárja
Bemeneti és kimeneti tároló Kapcsolatobjektum Sikeres foglalástól felszabadításig Felszabadítás, esetleges újrafoglalás Részleges inicializálásnál is rendezni
Parser bemeneti nézete Nincs saját tulajdonlása Legfeljebb az API-ban engedett időtartamig Tömörítés, felülírás, újrafoglalás Nem tárolható el engedély nélkül
Válaszhoz tartozó adatok A konkrét válaszszerződés szerint A küldés befejezéséig szükségesek Korai felszabadítás vagy felülírás Hiba esetén is pontosan egyszer rendezni

Különösen veszélyes a feldolgozott bemenetre mutató válasz. Ha a parser kölcsönzött szeletet ad vissza, a TCP-adapter pedig később küldi el, akkor a bemeneti buffer tömörítése már a küldés előtt invalidálhatja a szeletet. Javításként vagy saját válaszpéldányt készítünk, vagy az eredeti tároló élettartamát és változatlanságát biztosítjuk a küldés végéig.

A kapcsolatállapot egyik alapinvariánsa:

0≤sent≤out_len≤out_cap. 0 \leq \text{sent} \leq \text{out\_len} \leq \text{out\_cap}.

Az elküldendő tartomány [sent,out_len)[\text{sent},\text{out\_len}). Ez nem a teljes kapacitás, és nem nullterminált sztringszerződés.

Bizonyításvázlat. Kezdetben sent == out_len == 0. A válasz előállítása csak elegendő kapacitás mellett növelheti az out_len értékét. Pozitív, kk hosszúságú írás után kizárólag akkor növeljük sent értékét kk-val, ha k≤out_len−sentk \leq \text{out\_len}-\text{sent}. Átmeneti írhatatlanság nem módosítja a számlálókat. A buffer újrafelhasználása csak a küldés befejezésekor vagy a kapcsolat megszüntetésekor engedett.

A cleanup-ágakat megszerzési sorrendben járjuk végig: mi történik a socket létrehozása, a kapcsolatobjektum foglalása, majd valamelyik buffer foglalása után bekövetkező hibánál? A már lezárt descriptor tulajdonosi mezője kapjon érvénytelen őrértéket, például -1-et.

Linux-specifikus megjegyzés: a close() hibájára nem szabad automatikus újrapróbálkozó ciklust építeni. A numerikus descriptor időközben új erőforráshoz tartozhat; a tényleges célrendszer lezárási szabályait külön kell ellenőrizni.

17.4.3 Dinamikus diagnosztika és hibainjektálás

A sanitizer, a hibainjektálás és a fuzzolás különböző kérdésekre válaszol.

GCC/Clang eszközprofil: az AddressSanitizer, röviden ASan, számos tiltott memória-hozzáférést felismerhet. Az UndefinedBehaviorSanitizer, röviden UBSan, többek között bizonyos túlcsordulási és igazítási hibákat jelezhet. Nem teljes UB-detektorok. A szabályos tartományon belüli, de szemantikailag hibás válasz egyiknek sem feltétlenül feltűnő. A leak-ellenőrzés támogatását és bekapcsolását külön rögzítsük.

A hibainjektálás egy hibaforrás irányított helyettesítése. Allokációnál például a kijelölt sorszámú foglalás tér vissza sikertelenül. A számlálás minden teszteset elején újraindul, és csak a vizsgált komponens foglalásaira vonatkozik.

17.4.3.1 Kidolgozott cleanup-próba

Az alábbi önálló audit_cleanup.c nem a projekt parserét helyettesíti: a kétlépcsős konstrukció auditmintáját mutatja. Minimum ISO C17; a fenti hosted GCC/Clang-profilhoz használható, platform-API nélkül.

Az allocator tesztkontextusnak az utolsó felszabadításig élnie kell. A result tulajdonolja a data tárolót; a hívó tulajdonolja a sikeresen visszaadott result objektumot. A create_result() előfeltétele érvényes a és out, továbbá *out == NULL. A hibakimenet -1, változatlan kimeneti pointerrel.

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

struct allocator {
    size_t calls;
    size_t fail_at;
    size_t live;
};

struct result {
    unsigned char *data;
    size_t size;
};

static void check(int condition)
{
    if (!condition)
        abort();
}

static void *take(struct allocator *a, size_t n)
{
    ++a->calls;
    if (a->calls == a->fail_at)
        return NULL;

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

static void release(struct allocator *a, void *p)
{
    if (p != NULL) {
        check(a->live != 0);
        --a->live;
        free(p);
    }
}

static int create_result(struct allocator *a, struct result **out)
{
    struct result *r = take(a, sizeof *r);
    if (r == NULL)
        return -1;

    r->data = take(a, 32);
    if (r->data == NULL) {
        release(a, r);
        return -1;
    }

    r->size = 32;
    memset(r->data, 0, r->size);
    *out = r;
    return 0;
}

static void destroy_result(struct allocator *a, struct result **out)
{
    struct result *r = *out;
    if (r != NULL) {
        release(a, r->data);
        release(a, r);
        *out = NULL;
    }
}

int main(void)
{
    for (size_t fail = 1; fail <= 3; ++fail) {
        struct allocator a = {0, fail, 0};
        struct result *r = NULL;
        int rc = create_result(&a, &r);

        if (fail <= 2) {
            check(rc == -1 && r == NULL);
        } else {
            check(rc == 0 && r != NULL);
            check(r->size == 32);
        }

        destroy_result(&a, &r);
        check(r == NULL && a.live == 0);
    }
    return 0;
}

A data elemeire mutató bármely kölcsönzött pointer a destroy_result() során invalidálódik. A tesztkontextus nem általános allokátorkönyvtár: egyszálú, rövid, korlátos tesztre készült.

Fordítás külön diagnosztikai binárisokba:

gcc -std=c17 -O1 -g -Wall -Wextra -Wpedantic \
  -fsanitize=address,undefined -fno-omit-frame-pointer \
  -fno-sanitize-recover=undefined \
  audit_cleanup.c -o audit-cleanup-gcc

clang -std=c17 -O1 -g -Wall -Wextra -Wpedantic \
  -fsanitize=address,undefined -fno-omit-frame-pointer \
  -fno-sanitize-recover=undefined \
  audit_cleanup.c -o audit-cleanup-clang

A várt eredmény az első két esetben konstrukciós hiba, a harmadikban siker; minden eset végén nulla élő foglalás. A sikeres eset feltétele, hogy a valódi malloc() hívások sikerüljenek. A tesztellenőrzések szándékosan nem assert alapúak: NDEBUG mellett sem tűnnek el.

A projektben ugyanígy járjuk be a konstrukció, a buffernövelés és a válaszelőállítás foglalási pontjait. A sikeres út foglalásszáma csak kiindulópont: a hibakezelés maga is tartalmazhat új foglalást.

17.4.3.2 Parser-fuzzolás értelmes orákulummal

A fuzz-orákulum az a szabály, amely eldönti, hogy egy végrehajtás hibás-e. A „nem omlott össze” szükséges, de gyenge orákulum.

A parserhez célszerű azonos bemenetet kétféleképpen beadni: egyetlen darabban és több részletben. Előfeltétel, hogy ugyanaz a parserkonfiguráció, ugyanaz az EOF-esemény és elegendő kimeneti kapacitás álljon rendelkezésre. Az összehasonlítás a végső üzenetsorozatra és hibakategóriára vonatkozik, nem az egyes feed hívások köztes státuszára.

A harness ellenőrizze továbbá, hogy:

Clang/LibFuzzer eszközprofilban az adapter belépési pontja LLVMFuzzerTestOneInput; a -fsanitize=fuzzer,address,undefined kapcsolóval készített célprogram nem tartalmaz saját main() függvényt. A fuzzer bemenete kölcsönzött: a projekt nem szabadíthatja fel és nem őrizheti meg a híváson túl.

17.4.4 Részleges I/O és leállítás integrációs tesztje

POSIX socket-szerződés: a TCP bájtfolyam, ezért a küldési hívások határai nem képeznek üzenethatárokat. Az olvashatósági vagy írhatósági jelzés sem ígéri a teljes protokollüzenet feldolgozhatóságát.

A determinisztikus tesztben ezért helyettesíthető I/O-réteg adja elő a részleges eredményeket; a valódi socketes teszt külön ellenőrzi az integrációt. A socketbufferek csökkentése önmagában nem garantál egy meghatározott rövid írást.

Teszthelyzet Elvárt ellenőrzés
Egy keret több bemeneti részletben Pontosan egy feldolgozott kérés
Több keret egy olvasásban Minden teljes keret feldolgozódik, helyes sorrendben
Rövid írás, majd EAGAIN A küldési pozíció megmarad, nincs duplikáció
EINTR adatátvitel nélkül A számlálók nem változnak
Peer féllezárása A teljes kérések és a csonka maradék külön kezelése
Nem olvasó kliens Korlátos kimeneti memória és érvényes leállítási határidő
Peer eltűnése küldés közben Kapcsolati hiba, nem kontrollálatlan folyamatleállás

Kidolgozott példa: egy hét C bájtos válasznál a helyettesített író előbb 3, majd EAGAIN, végül 4 eredményt ad. A sent sorozata:

0→3→3→7. 0 \rightarrow 3 \rightarrow 3 \rightarrow 7.

A második pozitív írásnak a negyedik bájttól kell indulnia. Az első címtől történő újraküldés tartalmilag hibás, még akkor is, ha egyetlen memóriaellenőrző sem jelez.

A leállítás tesztelése előtt rögzítsük, mit jelent a befogadott kérés. Egy használható auditpolicy szerint ez olyan teljes, érvényes kérés, amelyhez az adapter már biztosította a válaszhoz szükséges helyet. A stop esemény észlelése után nincs új befogadás; a már sorban álló válaszok a határidőig ürülhetnek, majd minden kapcsolat lezárul. A félkész bemenet nem válik utólag befogadott kéréssé.

A teszt egy befogadott és egy csonka kéréssel indít leállítást. Nem rögzített idejű alvásokkal találgat: megfigyelhető állapotokra és határidős várakozásokra támaszkodik. Külön eset vizsgálja a válaszokat nem olvasó klienst. A határidő számítása monoton órát használjon; a leállítás észlelési késleltetését külön korlátozzuk.

17.4.5 Bináris, függőségek és buildprofil

A review tárgya a kiadott bináris is. Az auditcsomagban szerepeljen a forrás commitazonosítója és módosítottsága, a teljes fordítási és linkelési parancs, a fordítóverzió, a célarchitektúra, valamint az artifact ellenőrző összege.

Tartsunk külön:

GNU binutils eszközprofil: ha az auditált artifact neve build/release/diag, az alábbi parancsok futtatás nélkül vizsgálják a binárist:

readelf -hW build/release/diag
readelf -dW build/release/diag
readelf --dyn-syms -W build/release/diag
nm -u build/release/diag

Az ELF-fejlécből a formátumot és géptípust, a dinamikus szakaszból többek között a deklarált közvetlen függőségeket vizsgáljuk. A feloldatlan dinamikus szimbólum önmagában nem linkelési hiba: annak futásidejű feloldása lehet szándékos.

A DT_NEEDED lista nem teljes futásidejű függőségtérkép: tranzitív és dinamikusan betöltött komponensek is létezhetnek. Ellenőrizzük, hogy a release-be nem került sanitizer-runtime, és a megosztott könyvtár exportlistáján nem jelentek meg véletlenül belső segédfüggvények.

17.4.6 Memória-, allokáció- és syscall-mérés

Mérés előtt rögzítsük a kérdést. Más kísérlet kell a kapcsolatmemória felső korlátjához, és más az egy kérésre jutó rendszerhívások számához.

A jegyzőkönyv tartalmazza a CPU-t, a kernelt, a libc-t, a konténer- és cgroup-korlátokat, a terhelésgenerátor helyét, a kéréseloszlást, a párhuzamos kapcsolatok számát, a bemelegítést és az ismétlésszámot. A localhostteszt elsősorban helyi adapterköltséget mutat, nem távoli hálózati teljesítményt.

Mennyiség Lehetséges megfigyelés Korlát
Élő kért heapméret Saját allokátorhatár számlálói Nem tartalmazza az allokátor teljes overheadjét
RSS/PSS Linux /proc memóriaadatok Nem azonos a heapfoglalással
Allokációhívások malloc/calloc/realloc körüli számlálás Csak a lefedett foglalási útvonalakat látja
Rendszerhívásszám Linux strace összesítés A tracing módosítja a futást
Késleltetés és throughput Külön, tracing nélküli mérés Terheléstől és ütemezéstől függ

Ha a kapcsolatobjektum külön foglalt, és két külön foglalt buffert tulajdonol, akkor a közvetlenül kért tároló:

Bconn=sizeof(𝚜𝚝𝚛𝚞𝚌𝚝 𝚌𝚘𝚗𝚗𝚎𝚌𝚝𝚒𝚘𝚗)+Cin+Cout. B_{\text{conn}} = \operatorname{sizeof}(\texttt{struct connection}) + C_{\text{in}} + C_{\text{out}}.

Itt minden tag C bájtban értendő. Beágyazott tömböt nem adunk hozzá még egyszer. A képlet nem tartalmaz allokátormetadata-, stack- vagy kernel-socketbuffer-költséget, ezért nem RSS-becslés.

Egy rögzített mérési intervallumban, N>0N>0 sikeresen kiszolgált kérésre:

a=AN,s=SN, a=\frac{A}{N}, \qquad s=\frac{S}{N},

ahol AA az intervallumban számlált allokációhívások, SS a kiválasztott rendszerhívások száma. Rögzíteni kell, hogy a kapcsolatlétrehozás benne van-e az intervallumban, és mely syscallok kerültek SS-be.

Ne vonjuk össze a sanitizeres, tracinges és tiszta release-mérést. Mindegyik más kérdésre válaszol. A felszabadítás után magas maradó RSS sem bizonyít automatikusan leaket: az allokátor megtarthat felszabadult tárolót. Az ownership-számlálás és az OS-szintű memóriaadat együtt értelmezendő.

17.4.7 A C++-port funkcionális alapvonala

A későbbi összehasonlítás feltétele a megfigyelhető viselkedési szerződés befagyasztása. Ez nem azonos belső adatszerkezeteket követel, hanem azonos külső eredményt.

Rögzítsük a protokoll pontos bájtsorozatait, keret- és kapcsolatszámkorlátait, a hibakategóriákat, a válaszok sorrendjét, az EOF és a leállítás jelentését. A CLI-nél ide tartoznak a kilépési kódok és a standard kimenetre kerülő géppel feldolgozható adatok is.

A közös tesztcsomag tartalmazzon bemenet–eredmény párokat, darabolási menetrendeket és hibainjektálási forgatókönyveket. Időbélyeges diagnosztikai sorok helyett strukturált eseményeket hasonlítsunk össze, ha a szöveges forma nem része a szerződésnek.

A C++-változatnak nem kell ugyanannyi malloc() hívást vagy ugyanazokat a belső szimbólumokat produkálnia. Az allokáció, memória és syscallszám összehasonlítási mutató; a funkcionális megfelelés külön kapu.

17.4.8 Feladatok

17.4.8.1 Ownership-hiány felismerése

Az elfogadó ág létrehoz egy socketet, majd kapcsolatobjektumot foglal. Sikertelen foglaláskor hibát naplóz és visszatér. Kié a socket, és mely ellenőrzés hiányzik?

17.4.8.2 Hibainjektálási próba bővítése

Egészítsd ki a result típust egy második, külön foglalt tárolóval. A konstrukció most három allokációt végezzen. Ellenőrizd mindhárom meghibásodási pontot és a sikeres esetet.

17.4.8.3 Részleges írás diagnosztikája

A helyettesített író 3, EAGAIN, 4 eredményt ad. A kliens hét helyett tíz bájtot kap, mert az első három bájt megismétlődik. Miért lehet tiszta az ASan-futás, és milyen állapotot kell naplózni?

17.4.8.4 Mérési állítás értékelése

A C-programot sanitizerrel, a C++-változatot optimalizált release-ben mérték. A C++-változat kisebb RSS-t mutatott. Levonható-e következtetés a nyelvek memóriahatékonyságáról?

17.4.9 Megoldások

17.4.9.1 Ownership-hiány felismerése

A sikeres átadásig az elfogadó ág a tulajdonos. Sikertelen kapcsolatfoglaláskor neki kell lezárnia a socketet. Ellenőrzési feltétel: ismételt injektált hibák után a folyamat nyitott descriptorainak száma nem növekszik tartósan, és ugyanazt a descriptort nem zárják kétszer.

17.4.9.2 Hibainjektálási próba bővítése

A harmadik foglalás hibájánál fel kell szabadítani a második lépésben létrehozott tárolót és a result objektumot. A destruktor mindkét tárolót rendezze. A fail_at értékei legyenek 1, 2, 3, majd 4; az utolsó nem talál foglalási pontot. Minden eset után live == 0, hibánál pedig a kimeneti pointer változatlanul null marad.

17.4.9.3 Részleges írás diagnosztikája

A hiba protokollszintű duplikáció, nem feltétlenül tiltott memória-hozzáférés. Naplózandó a küldés előtti sent, a kért hossz és a visszatérési eredmény. A harmadik hívás kezdőpozíciója 3, kért hossza 4 legyen. Az orákulum a teljes fogadott bájtsorozatot is ellenőrizze.

17.4.9.4 Mérési állítás értékelése

Nem. A sanitizer saját memóriaköltsége és az eltérő optimalizálási profil összekeveredik az implementáció különbségével. Azonos terheléssel, összevethető release-profilban, több ismétléssel kell újramérni. Az RSS mellé kértbájt- és allokációszámlálás is szükséges.

17.4.10 Összefoglaló

A záróreview eredménye nem egyetlen „sikeres” jelzés, hanem visszakövethető bizonyítékcsomag: ownership-térkép, invariánsok, hibautas tesztek, parser-fuzzolás, I/O- és shutdown-vizsgálat, binárisaudit és dokumentált mérés. A C++-port akkor hasonlítható tisztességesen a C-változathoz, ha előbb ugyanazt a funkcionális szerződést teljesíti, és csak ezután értékeljük az eltérő erőforrás- és teljesítményköltségeket.