3.5 Leállás, cleanup és nem lokális vezérlés

Egy folyamat megszűnése és egy alkalmazási művelet szabályos befejezése nem ugyanaz. A kernel visszaveheti a folyamat memóriáját és lezárhatja file descriptorait, miközben az utolsó naplóüzenet még egy felhasználói térben élő pufferben van, a konfigurációs fájl félkész, a távoli tranzakció pedig rendezetlen.

A main, startup, argumentumok és környezet részben megismert programindulás párja ezért nem egyetlen „kilépés” művelet. Meg kell különböztetnünk a C runtime által végzett lezárást, a kernel erőforrás-visszavételét és az alkalmazás saját helyreállítási feladatait.

Az alábbi nyelvi szabályok ISO C17 szerint értendők; a Linux/POSIX sajátosságokat külön jelöljük. A példák választott laborprofilja Linux, x86-64 System V ABI, LP64, glibc 2.39, GCC 14.2 vagy Clang 19.1, explicit C17 módban. A parancsok fordítási útmutatók, nem dokumentált teszteredmények.

3.5.1 A leállítási utak szerződése

A normál programleállás a C szabvány szerinti kategória: nem feltétlenül jelent sikeres üzleti műveletet. Az exit(EXIT_FAILURE) például normál leállás, sikertelenséget jelző állapottal.

Művelet Hatás Automatikus alkalmazásszintű cleanup
return egy közönséges függvényből Visszatérés a hívóhoz Csak a ténylegesen végrehajtott kód
return status a main kezdeti hívásából Normál leállás, az exit(status) leállítási viselkedésével atexit callbackek és standard streamlezárás
exit(status) Normál programleállás atexit callbackek, streamürítés és -zárás
_Exit(status) Normál leállás kilépési callbackek nélkül Az ISO C szerint a streamkezelés implementáció által meghatározott
POSIX _exit(status) Folyamatleállítás a szokásos C cleanup nélkül Nincs atexit, nincs stdio-pufferürítés
quick_exit(status) Korlátozott normál leállás at_quick_exit callbackek, majd _Exit
abort() Rendellenes leállás Nincs atexit vagy at_quick_exit callback

ISO C17: az exit, _Exit, quick_exit, abort és a callback-regisztráló függvények deklarációja a <stdlib.h> fejlécben található. A main végének elérése return 0 hatású. Hordozható sikerjelzés a 0 vagy az EXIT_SUCCESS, hordozható sikertelenségjelzés az EXIT_FAILURE; más állapotértékek értelmezése implementációfüggő.

Az exit nem tér vissza, és nem hajtja végre az elhagyott függvények hátralévő utasításait. C-ben nincs olyan általános mechanizmus, amely minden lokális erőforráshoz automatikus lezárást kapcsolna.

Linux/POSIX: a <unistd.h> fejlécben deklarált _exit és az _Exit hatása egyenértékű. Nem ürítik a C FILE streamek felhasználói térbeli puffereit, de a folyamat file descriptorai lezáródnak. Az „azonnali” ezért nem valós idejű késleltetési garancia: a kerneloldali lezárás is igénybe vehet időt.

Az abort() rendellenes leállást kezdeményez, a SIGABRT jelzéshez kapcsolódva. Core dump keletkezése nem garantált: rendszerbeállításoktól is függ. ISO C17 szerint a streamek ürítése és lezárása ennél is implementáció által meghatározott; naplómegőrzési stratégiát nem szabad erre építeni.

3.5.2 Kilépési callbackek: hasznos tartalék, nem tranzakciókezelés

Az

int atexit(void (*func)(void));

deklaráció egy paraméter és visszatérési érték nélküli függvényt regisztrál normál leállásra. A regisztráció sikere nulla, hibája nem nulla visszatérési értékkel jelezhető.

ISO C17: az előre regisztrált callbackek a regisztráció fordított sorrendjében futnak. Ha előbb az alapinfrastruktúra, majd az arra épülő komponens cleanupját regisztráljuk, utóbbi záródik le előbb. Ez azonban csak akkor helyes, ha a valódi függőségek megfelelnek ennek a sorrendnek.

Az at_quick_exit külön callbacklistát kezel. A quick_exit nem futtatja az atexit listáját; az exit pedig nem futtatja az at_quick_exit listáját.

Egy kilépési callbacknél négy szerződést kell tisztázni:

Az utolsó különösen fontos: a callback void visszatérésű, tehát egy fclose hibáját nem tudja egyszerű visszatérési értékkel átadni az exit hívójának. A kritikus lezárást ezért még a végső kilépési állapot kiválasztása előtt végezzük el. Kilépési callbackből ne próbáljunk longjmp segítségével „visszamenekülni”; ez nem megengedett helyreállítási út.

3.5.3 Pufferelés és ellenőrizhető lezárás

Egy sikeres fputs jelentheti csupán azt, hogy az adat bekerült a stdio-pufferbe. A tényleges továbbítás hibája később, az fflush vagy az fclose során is jelentkezhet.

ISO C17: az exit a callbackek után üríti és lezárja a nyitott streameket, és eltávolítja a tmpfile által létrehozott fájlokat. Ebből nem következik, hogy a folyamat kilépési állapota automatikusan sikertelenségre változik egy ürítési hiba miatt.

Az alábbi teljes program, shutdown.c, ezért explicit ellenőrzi a lezárást. Külön laborkönyvtárban használjuk: a shutdown-demo.txt meglévő tartalmát felülírja.

#include <stdio.h>
#include <stdlib.h>

static FILE *owned;

static int close_owned(void)
{
    FILE *file = owned;
    owned = NULL;
    return file == NULL ? 0 : fclose(file);
}

static void fallback_cleanup(void)
{
    (void)close_owned();
}

int main(void)
{
    if (atexit(fallback_cleanup) != 0) {
        return EXIT_FAILURE;
    }

    owned = fopen("shutdown-demo.txt", "w");
    if (owned == NULL) {
        return EXIT_FAILURE;
    }

    int failed = fputs("completed\n", owned) == EOF;

    if (fflush(owned) == EOF) {
        failed = 1;
    }
    if (close_owned() == EOF) {
        failed = 1;
    }

    return failed ? EXIT_FAILURE : EXIT_SUCCESS;
}

Fordítás a megadott profil valamelyik fordítójával:

gcc -std=c17 -Wall -Wextra -Wpedantic -O2 shutdown.c -o shutdown-gcc
clang -std=c17 -Wall -Wextra -Wpedantic -O2 shutdown.c -o shutdown-clang
Elem Tulajdonlás és élettartam Érvénytelenítés, hibakimenet
owned Statikus tárolási idejű pointerobjektum; a program streamtulajdonlását jelöli NULL jelentése: nincs birtokolt stream
Megnyitott stream Sikeres fopen után a program birtokolja Az fclose után nem használható, hiba esetén sem
Lokális file A lezáráshoz átvett handle Nem őrizhető meg a lezárás után
fallback_cleanup A runtime normál leálláskor hívja; csak statikus állapotot ér el Hibát nem továbbít a kilépési állapotba

Az owned nullázása még az fclose előtt kizárja, hogy a későbbi fallback ugyanazt a streamet ismét lezárja. Az explicit út minden hibát sikertelenségre fordít; a callback csak tartalék.

Az explicit fflush itt ellenőrzési pont: az fclose egyébként maga is megkísérli a pufferelt adatok továbbítását. Ürítési hiba után sem szabad kihagyni a lezárást.

Linux/POSIX: a sikeres fflush nem jelent tartós tárolóra írást. A kernelhez továbbított adat még elveszhet rendszerösszeomláskor. A tartóssági szerződés további műveleteket igényel; ezt a fájlrendszer-műveletek és tartósság rész tárgyalja.

3.5.4 Mit vesz vissza a kernel?

Linux/POSIX: folyamatmegszűnéskor a kernel felszabadítja a folyamat címterét, és lezárja a folyamat file descriptorait. Ez nem egyenértékű a következőkkel:

Az alapvető különbség: az erőforrás visszavétele nem alkalmazási tranzakció. A kernel azt tudja, mely descriptorok tartoznak a folyamathoz; azt nem, hogy egy konfigurációcsere mikor tekinthető befejezettnek.

Ezért a helyes terv két rétegű: normál működésben ellenőrzött cleanup, megszakadás esetére pedig helyreállítható állapot és újraindítási protokoll.

3.5.5 Nem lokális vezérlés: setjmp és longjmp

ISO C17: a <setjmp.h> fejléc setjmp makrója egy jmp_buf objektumba ment visszatérési környezetet. Közvetlen meghívásakor nullát ad; egy megfelelő longjmp után ugyanaz a setjmp nem nulla értékkel tér vissza.

A longjmp(env, value) által előidézett visszatérési érték value, kivéve, hogy a nulla argumentum helyett egy lesz. Ez vezérlésátadás, nem állapot-visszagörgetés: fájlírást, memóriamódosítást és erőforrásfoglalást nem von vissza.

A teljes jump.c példa:

#include <setjmp.h>
#include <stdlib.h>

static jmp_buf checkpoint;

static void deep_call(void)
{
    longjmp(checkpoint, 7);
}

int main(void)
{
    volatile int phase = 0;

    if (setjmp(checkpoint) == 0) {
        phase = 1;
        deep_call();
    }

    return phase == 1 ? EXIT_SUCCESS : EXIT_FAILURE;
}
gcc -std=c17 -Wall -Wextra -Wpedantic -O2 jump.c -o jump-gcc
clang -std=c17 -Wall -Wextra -Wpedantic -O2 jump.c -o jump-clang

A checkpoint statikus tárolója a programé, de a bele mentett környezet csak addig használható, amíg a hozzá tartozó main-hívás aktív. A tároló hosszú élettartama nem hosszabbítja meg a mentett hívási környezet érvényességét. Másik szálból ugrani, vagy már visszatért függvény környezetébe visszaugrani nem definiált viselkedés.

3.5.5.1 Miért szükséges itt a volatile?

A longjmp után indeterminate értékűek azok a lokális változók, amelyek egyszerre:

  1. automatikus tárolási idejűek;
  2. a setjmp hívását tartalmazó függvényhez tartoznak;
  3. nem volatile minősítésűek;
  4. a setjmp és a longjmp között megváltoztak.

A phase az első, második és negyedik feltételt teljesíti. A volatile nélkül a visszaugrás utáni összehasonlítására nem építhetnénk helyes programot. A volatile itt nem szálbiztonságot és nem erőforrás-biztonságot jelent.

Helyességi indoklás: a közvetlen visszatéréskor belépünk az if törzsébe, majd a phase egy lesz. A longjmp után a feltételben szereplő setjmp értéke hét, így a törzs kimarad. A volatile változó megtartott értéke alapján a példa sikeres vezérlés-visszatérést jelez.

A setjmp használati helye is korlátozott: a bemutatott if-feltétel megengedett, de az int result = setjmp(checkpoint); inicializálás nem hordozható, szabályos C17 használat.

Ha a deep_call erőforrást foglalna, majd annak lezárása előtt ugrana, az elhagyott cleanup utasítás nem futna le. Biztonságosabb az erőforrást a helyreállítási pont tulajdonában tartani, vagy az ugrás előtt explicit felszabadítani. Általános C hibakezeléshez rendszerint egyszerűbbek a visszatérési értékek és a közös cleanup ágak.

3.5.6 Signal miatti leállás és a cleanup határa

Linux/POSIX: egy alapértelmezett leállító signal nem az exit hívásán keresztül szünteti meg a folyamatot. Az atexit callbackekre és a stdio ürítésére ilyenkor nem támaszkodhatunk. A SIGKILL nem fogható el és nem blokkolható.

Egy SIGTERM kezelőjéből sem helyes tetszőleges cleanupkódot meghívni. A handler megszakíthatott egy könyvtári műveletet; a stdio-függvények és az exit nem általánosan async-signal-safe műveletek.

A szokásos terv: a handler megengedett módon jelzi a leállítási igényt, a normál vezérlés pedig elvégzi a cleanupot. Ennek pontos feltételeit a signal és graceful shutdown rész adja meg. Még ilyen terv mellett is kell helyreállítási stratégia a kikényszerített leállásra.

3.5.7 Feladatok

3.5.7.1 Leállítási garanciák

Linux/POSIX alatt egy teljesen pufferelt streamben ki nem írt adat van. Mi várható exit, _exit és abort esetén? Melyik garantálja a tartósságot?

3.5.7.2 Ellenőrzött lezárófüggvény

Készíts finish_output(int prior_error) függvényt a shutdown.c programhoz. Előfeltétele, hogy owned érvényes stream. Ürítési hiba után is zárjon, és minden hibát őrizzen meg a kilépési állapotban.

3.5.7.3 Ugrás és erőforrásvesztés

A jump.c programban elhagyják a volatile minősítőt. Emellett a deep_call fájlt nyit, de a longjmp miatt nem éri el az fclose hívását. Mi a két különálló probléma?

3.5.8 Megoldások

3.5.8.1 Leállítási garanciák

Az exit megkísérli az ürítést és a lezárást; az I/O sikere nem garantált. A POSIX _exit nem üríti a stdio-puffert. Az abort esetén nem szabad hordozható ürítési garanciát feltételezni. Tartósságot egyik sem garantál.

Ellenőrzési feltétel: különítsük el az ürítési kísérletet, annak sikerét és a tartós tárolást.

3.5.8.2 Ellenőrzött lezárófüggvény

A következő részlet a close_owned után illeszthető; az eredeti fordítási parancsok használhatók:

static int finish_output(int prior_error)
{
    int failed = prior_error != 0;

    if (fflush(owned) == EOF) {
        failed = 1;
    }
    if (close_owned() == EOF) {
        failed = 1;
    }

    return failed ? EXIT_FAILURE : EXIT_SUCCESS;
}

A main sikeres megnyitást követő része így helyettesíthető:

return finish_output(fputs("completed\n", owned) == EOF);

Ellenőrzési feltétel: minden hibaág eléri a lezárást, a korábbi hiba nem vész el, és a fallback már NULL értéket talál.

3.5.8.3 Ugrás és erőforrásvesztés

A módosított, nem volatile phase értéke a visszaugrás után indeterminate; nincs garantált eredmény. Ettől függetlenül kimarad a megnyitott stream explicit lezárása.

A volatile visszaállítása csak az első problémát javítja. A stream tulajdonlását olyan szintre kell helyezni, amely a visszaugrás után is elvégzi és ellenőrzi a lezárást.

Ellenőrzési feltétel: mind a változóérték érvényességére, mind az erőforrás minden vezérlési úton történő lezárására külön indoklás szükséges.

3.5.9 Összefoglaló

A leállítási utak eltérő runtime-garanciákat adnak. A kritikus I/O-t a végső kilépési állapot kiválasztása előtt kell ellenőrizni; az atexit legfeljebb kiegészítő védelem. A kernel erőforrás-visszavétele nem helyettesíti a tranzakciólezárást. A longjmp pedig csak a vezérlést helyezi vissza: sem az erőforrásokat, sem az alkalmazási állapotot nem állítja automatikusan helyre.