12.4 Signal, megszakítás és graceful shutdown

A signalkezelő nem egyszerűen egy másik callback: olyan ponton szakíthatja meg a programot, ahol egy könyvtári művelet belső állapota még átmeneti. Ezért a szabályos leállítás alapelve nem az, hogy „a handler mindent felszabadít”, hanem az, hogy a handler leállítási szándékot jelez, a normál vezérlési út pedig végrehajtja a leállítás protokollját.

A fork, exec, pipe és gyermekfolyamat részben megismert erőforrás-életciklus itt is érvényes: minden erőforrásnak legyen lezárásért felelős tulajdonosa. Az errno szabályai pedig különösen fontossá válnak, mert a signalkezelő a megszakított kód hibajelzését is elronthatja.

12.4.1 Signalállapot és kézbesítés

ISO C17: a <signal.h> korlátozott signalmodellt biztosít, többek között a signal, raise és sig_atomic_t eszközökkel. A POSIX szolgáltatások signalkezeléséhez ennél részletesebb interfész szükséges.

POSIX: egy signalhoz tartozó disposition, vagyis kezelési beállítás lehet alapértelmezett művelet, figyelmen kívül hagyás vagy saját handler. Ezt célszerű sigaction segítségével beállítani, amely a handler mellett maszkolási és működési szabályokat is megad.

A signal blokkolása nem figyelmen kívül hagyást jelent. A blokkolt signal függőben maradhat, majd a blokkolás megszüntetése után kézbesíthető. A hagyományos, nem valós idejű signalok nem megbízható eseményszámlálók: több azonos, függőben lévő jelzés összeolvadhat.

POSIX folyamat- és szálmodell:

Egyszálú programban a maszkot sigprocmask kezeli. Többszálú programban erre pthread_sigmask használatos; ott a sigprocmask viselkedésére nem építünk. A SIGKILL és SIGSTOP nem fogható el, nem ignorálható és nem blokkolható.

12.4.2 Mit szabad a handlerben?

Az aszinkron signalbiztonság (async-signal-safety) azt jelenti, hogy egy művelet akkor is használható handlerből, amikor egy signal a normál végrehajtást tetszőleges ponton szakította meg. Ez erősebb követelmény, mint a szálbiztosság: egy belső mutex megvédhet több száltól, de holtpontot okozhat, ha a handler ugyanazon a szálon újra megszerezné.

Az alábbi részlet hibás tanítási példa, nem végrehajtási minta:

/* ISO C17 / POSIX: hibás, aszinkron handlerbe nem való műveletek. */
static void bad_handler(int signo)
{
    printf("signal: %d\n", signo);
    void *temporary = malloc(128);
    free(temporary);
}

POSIX: a printf, malloc és free nem async-signal-safe. A megszakítás történhet éppen a stdio-puffer vagy az allocator belső adatszerkezetének módosítása közben. Az eredmény lehet holtpont vagy sérült állapot; nincs garantált kimenet.

Ezzel szemben a write POSIX szerint async-signal-safe, de ettől még blokkolhat. A signalbiztonság tehát nem időkorlát és nem általános helyességi garancia.

12.4.2.1 A korlátozott jelzőminta

A legegyszerűbb szabályos megoldás:

/* ISO C17; a teljes POSIX-példa ugyanezt használja. */
static volatile sig_atomic_t stop_requested = 0;

static void request_stop(int signo)
{
    (void)signo;
    stop_requested = 1;
}

A sig_atomic_t olyan egész típus, amelynek hozzáférése aszinkron megszakítás mellett is atomi egységként kezelhető. A volatile biztosítja a jelző megfelelő megfigyelhetőségét ebben a mintában; nem teszi általánosan szálbiztossá a programot, és nem publikál más adatokat.

A szerződés szándékosan szűk:

Ez a handler nem módosítja az errno értékét. Ha egy POSIX-handler olyan megengedett függvényt hív, amely módosíthatja, belépéskor el kell mentenie, visszatérés előtt pedig helyreállítania.

12.4.3 Megszakítás és újrapróbálás

POSIX: a sigaction SA_RESTART jelzője bizonyos blokkoló műveletek automatikus újraindítását kéri a handler visszatérése után. Nem minden rendszerhívásra érvényes; az egyes interfészek és a platform szabályait külön kell vizsgálni.

Leállításnál az automatikus újraindítás hátrány is lehet: a handler beállítja a jelzőt, de a program visszatér a blokkoló várakozásba. A SA_RESTART elhagyása önmagában szintén nem garantálja, hogy minden várakozás megszakad.

Az EINTR kezelése ezért alkalmazási policy:

  1. Ha a művelet pozitív elemszámmal tért vissza, a haladást először könyveljük el.
  2. Ha -1 az eredmény és errno == EINTR, megvizsgáljuk a leállítási állapotot.
  3. Csak akkor próbálkozunk újra, ha ezt az aktuális állapot megengedi.

A „minden EINTR után feltétel nélkül újra” ciklus könnyen leállíthatatlan szolgáltatást eredményez.

12.4.3.1 A jelzőellenőrzés és az elalvás közötti rés

Ez a részlet hibás szinkronizációs minta:

/* POSIX-részlet: elveszett ébresztés lehetséges. */
while (!stop_requested) {
    pause();
}

Ha a signal az ellenőrzés után, de a pause előtt érkezik, a jelző beáll, majd a program mégis elalszik.

POSIX: a pselect a várakozás idejére atomian cseréli a hívó szál signalmaszkját. Így a jelző ellenőrzése alatt blokkolhatjuk a leállító signalokat, és csak a várakozás megkezdésével együtt engedélyezzük őket.

12.4.4 Kidolgozott példa: minimális handler, normál cleanup

A következő program az ébresztési és tulajdonlási mintát mutatja, nem teljes szolgáltatás. A tmpfile egy helyettesítő alkalmazási erőforrás; a program leállításkor normál kódból ír bele és zárja le.

Profil: ISO C17 és POSIX interfészek, Linux hosted környezet. Választott fordítási célprofil: x86-64 System V ABI, LP64, GCC 14.2.0 vagy Clang 19.1.7, glibc 2.40. A példa nem közöl ellenőrzött fordítási vagy futtatási eredményt.

#define _POSIX_C_SOURCE 200809L

#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/select.h>

static volatile sig_atomic_t stop_requested = 0;

static void request_stop(int signo)
{
    (void)signo;
    stop_requested = 1;
}

int main(void)
{
    sigset_t stop_set;
    sigset_t old_mask;
    sigset_t wait_mask;

    sigemptyset(&stop_set);
    sigaddset(&stop_set, SIGINT);
    sigaddset(&stop_set, SIGTERM);

    if (sigprocmask(SIG_BLOCK, &stop_set, &old_mask) == -1) {
        perror("sigprocmask");
        return EXIT_FAILURE;
    }

    struct sigaction action = {0};
    action.sa_handler = request_stop;
    action.sa_flags = 0;
    sigemptyset(&action.sa_mask);
    sigaddset(&action.sa_mask, SIGINT);
    sigaddset(&action.sa_mask, SIGTERM);

    if (sigaction(SIGINT, &action, NULL) == -1 ||
        sigaction(SIGTERM, &action, NULL) == -1) {
        perror("sigaction");
        return EXIT_FAILURE;
    }

    wait_mask = old_mask;
    sigdelset(&wait_mask, SIGINT);
    sigdelset(&wait_mask, SIGTERM);

    FILE *journal = tmpfile();
    if (journal == NULL) {
        perror("tmpfile");
        return EXIT_FAILURE;
    }

    int failed = 0;

    while (!stop_requested) {
        int result = pselect(0, NULL, NULL, NULL, NULL, &wait_mask);

        if (result == -1 && errno != EINTR) {
            perror("pselect");
            failed = 1;
            break;
        }
    }

    /* Normál vezérlési út: itt már használható stdio. */
    if (fputs("shutdown\n", journal) == EOF) {
        perror("fputs");
        failed = 1;
    }
    if (fclose(journal) == EOF) {
        perror("fclose");
        failed = 1;
    }

    return failed ? EXIT_FAILURE : EXIT_SUCCESS;
}

Fordítás a kiválasztott kiadásokkal:

gcc -std=c17 -O2 -Wall -Wextra -Wpedantic shutdown_wait.c -o shutdown_wait_gcc
clang -std=c17 -O2 -Wall -Wextra -Wpedantic shutdown_wait.c -o shutdown_wait_clang

A parancsok a tanuló külön laborjához tartoznak. Ellenőrzési feltétel: a futó programnak küldött SIGTERM után a várakozás véget ér, és sikeres cleanup esetén a kilépési státusz sikeres.

Objektum vagy erőforrás Tulajdonos és élettartam Érvénytelenítés, hibakimenet
stop_requested Statikus tárolási idejű objektum A folyamat végéig él; a handler csak értéket rendel hozzá
journal által elért stream A main a tulajdonos; a handler nem fér hozzá fclose után nem használható, hibás visszatérés esetén sem
&wait_mask kölcsönzött hozzáférés A tároló a main lokális objektuma A pselect hívás teljes ideje alatt érvényes; nincs megtartott pointer
Signalbeállítások A teljes program által kezelt folyamatállapot A példa kilépésig megtartja őket; nem általános könyvtári inicializáló

Bizonyításvázlat. A ciklusfeltétel vizsgálatakor a két leállító signal blokkolt. Ha már függőben vannak, a pselect ideiglenes maszkja kézbesíthetővé teszi őket. Ha később érkeznek, megszakíthatják a várakozást. Visszatéréskor az eredeti, blokkoló maszk helyreáll, majd a ciklus újra ellenőrzi a jelzőt. Nincs külön, védelem nélküli rés az ellenőrzés és az elalvás között.

A példa korlátja fontos: a cleanup alatt a leállító signalok blokkoltak, és a fclose nem rendelkezik általános időkorláttal. Ez helyes egyszeri leállítási minta, de még nem határidős shutdown.

12.4.5 Linux-alternatíva: signalfd

Linux-only: a signalfd segítségével kiválasztott signalok file descriptoron olvasható eseményekké alakíthatók. Ehhez az útvonalhoz nem szükséges aszinkron handler.

A használati szerződés:

  1. A kiválasztott signalokat blokkoljuk.
  2. Létrehozzuk a descriptort, jellemzően SFD_CLOEXEC | SFD_NONBLOCK jelzőkkel.
  3. A descriptort az eseményhurok figyeli.
  4. Olvashatóságkor read segítségével struct signalfd_siginfo rekordokat veszünk át.
  5. A feldolgozás normál vezérlési úton módosítja a shutdown állapotát.

A puffernek legalább egy teljes rekordot kell befogadnia. Pozitív olvasási eredménynél a teljes rekordokat dolgozzuk fel; EAGAIN esetén pillanatnyilag nincs további esemény, EINTR esetén a normál megszakítási policy érvényes. A hagyományos signalok összeolvadását a signalfd nem szünteti meg.

A descriptor tulajdonosa az eseményhurok; a regisztráció és a kiolvasás kölcsönzött hozzáférés. A tulajdonos lezárása érvényteleníti ezeket. Többszálú programban a maszkot célszerű a szálak létrehozása előtt beállítani: különben egy másik, nem blokkoló szálhoz kerülhet a signal. Másik szálnak címzett signalt a descriptor olvasója nem vehet át tetszőlegesen.

A descriptor eseményhurokba kötését a poll/epoll és explicit eseményállapotgép rész bontja ki.

12.4.6 A graceful shutdown állapotgépe

A szabályos leállítás nem egyetlen close, hanem állapotátmenetek sora:

Állapot Megengedett munka Következő átmenet
RUNNING Új munka fogadása és feldolgozása Leállítási kérés → QUIESCING
QUIESCING Új munka kizárása, befejezési határidő rögzítése → DRAINING
DRAINING Már elfogadott munka korlátozott befejezése Kész → CLOSING; határidő → megszakítás
CLOSING Erőforrások egyszeri lezárása → kilépés

A türelmi időt monoton órával kell értelmezni. Ha TT a határidő, tt az aktuális idő, akkor az eseményvárakozás legfeljebb

Δt=max(0,T−t) \Delta t = \max(0, T-t)

ideig tarthat. Ez csak akkor ad alkalmazási szintű korlátot, ha a köztes munkalépések sem blokkolhatnak korlátlanul.

Egy újabb, külön megfigyelt leállítási kérés gyorsíthatja a megszakítást, de az összeolvadás miatt nem szabad „pontosan két signalra” építeni. A határidő és a külső supervisor kényszerített leállítása külön védelmi réteg.

12.4.7 Gyakori hibák

A handlerből végzett naplózást normál kódba kell áttenni. A volatile jelzőt nem szabad általános konkurens adatcsatornává bővíteni. A vak EINTR-újrapróbálás helyett állapotfüggő döntés szükséges. Végül a „graceful” nem jelenthet végtelen várakozást: külön kell tervezni az új munka kizárását, a folyamatban lévő munka befejezését és a kényszerített megszakítást.

12.4.8 Feladatok

  1. Magyarázó: miért nem biztonságos a handlerből meghívni egy egyébként szálbiztos naplózót? Miért nem helyettesíti ezt a problémát a volatile?
  2. Kódolási: írj döntési részletet egy pozitív kapacitású read után: külön kezelje a haladást, EOF-ot, megszakítás miatti leállást, újrapróbálást és egyéb hibát.
  3. Diagnosztikai: egy szolgáltatás jelzőt ellenőriz, majd pause-t hív. Néha csak a második SIGTERM után áll le. Adj eseménysorrendet és javítást.

12.4.9 Megoldások

12.4.9.1 Szálbiztosság és újrabelépés

A naplózó tarthat belső lockot éppen akkor, amikor a signal megszakítja. A handler ugyanazt a lockot kérve a megszakított művelet befejezésére várhat, amely viszont csak a handler visszatérése után folytatódhat. A volatile nem old fel lockot és nem teszi újrabeléphetővé a könyvtárat.

Ellenőrzési feltétel: a handlerben ne legyen nem async-signal-safe hívás; a naplózás normál útvonalon történjen.

12.4.9.2 Olvasási döntés

A következő C17/POSIX-részlet egy ciklusba illeszthető. n a read eredménye, saved_errno pedig a hiba után azonnal elmentett errno. A feldolgozó és hibakezelő műveletek alkalmazásfüggők.

if (n > 0) {
    /* Könyveld el és dolgozd fel az n bájtnyi haladást. */
} else if (n == 0) {
    /* EOF. */
    break;
} else if (saved_errno == EINTR) {
    if (stop_requested) {
        break;
    }
    continue;
} else {
    /* Egyéb hiba kezelése. */
    break;
}

Ellenőrzési feltétel: pozitív eredménynél nincs újraküldés vagy újrafeldolgozás ugyanarra a tartományra. Ez a részlet az eredményt osztályozza; önmagában nem oldja meg a következő blokkoló hívás előtti ébresztési rést.

12.4.9.3 Elveszett ébresztés

Az eseménysorrend: hamis jelző beolvasása → signal → handler beállítja a jelzőt → visszatérés → pause. A program ekkor újabb signalra vár.

A javítás blokkolt jelzőellenőrzés és atomikus maszkkal végzett pselect, vagy Linuxon blokkolt signalok feldolgozása signalfd-n keresztül.

Ellenőrzési feltétel: ne maradjon olyan végrehajtási rés, amelyben a leállítási eseményt már feldolgoztuk, de utána még korlátlanul elalhatunk.

12.4.10 Összefoglaló

A minimális handler csak jelez. A normál vezérlési út dönt az újrapróbálásról, vezeti a shutdown állapotgépét és zárja le az erőforrásokat. A helyességhez együtt szükséges az async-signal-safety, a maszkolás, az elveszett ébresztés kizárása és a határidős leállítási policy.