Sygnały w Linuksie — SIGKILL, SIGTERM, SIGCHLD i anatomia handlera

Wysyłasz SIGTERM do procesu, a on dalej działa. Wysyłasz SIGKILL i znika natychmiast — ale zostawia po sobie wpis w tablicy procesów. Twój handler SIGINT czasem łapie sygnał, a czasem nie, w zależności od tego, w którym syscallu akurat siedział proces. Sygnały w Linuksie wyglądają na prosty mechanizm — wyślij liczbę, proces reaguje — ale pod spodem to jeden z najbardziej subtelnych fragmentów interfejsu kernela, w którym łatwo o race condition i trudne do odtworzenia bugi.

Ten artykuł rozkłada sygnały na części: czym naprawdę jest dostarczenie sygnału, dlaczego niektórych nie da się złapać, jak SIGCHLD wiąże się z mechanizmem fork() i procesami zombie, oraz które operacje wolno wykonać w handlerze, a które wysadzą Ci proces w powietrze.

Czym jest sygnał — z perspektywy kernela

Sygnał to asynchroniczne powiadomienie dostarczane procesowi przez kernel. Nie jest to wywołanie funkcji ani komunikat w kolejce — to ustawienie bitu w strukturze procesu i (potencjalne) przerwanie jego normalnego wykonania. Każdy proces ma w deskryptorze dwie kluczowe maski bitowe: pending (sygnały dostarczone, ale jeszcze nieobsłużone) i blocked (sygnały tymczasowo wstrzymane przez proces).

Kiedy proces A robi kill(pid_B, SIGTERM), kernel nie przekazuje sterowania natychmiast do procesu B. Ustawia bit SIGTERM w masce pending procesu B i wraca. Faktyczne dostarczenie — moment, w którym proces B przerywa to, co robił, i wykonuje akcję dla sygnału — następuje dopiero przy najbliższym przejściu z trybu kernela do trybu użytkownika: zwykle po powrocie z syscalla albo z przerwania zegarowego.

To rozróżnienie między wygenerowaniem a dostarczeniem sygnału jest źródłem większości nieintuicyjnych zachowań. Proces zablokowany w długim syscallu może mieć sygnał w pending przez dłuższą chwilę, zanim go obsłuży.

Dyspozycje sygnałów: trzy możliwe reakcje

Dla każdego sygnału proces ma ustawioną jedną z trzech dyspozycji (signal disposition), która decyduje, co się stanie przy dostarczeniu:

  • Akcja domyślna (SIG_DFL) — zachowanie zdefiniowane przez kernel: zakończenie procesu, zakończenie ze zrzutem pamięci, zignorowanie albo zatrzymanie.
  • Ignorowanie (SIG_IGN) — sygnał jest odrzucany przy dostarczeniu.
  • Handler — funkcja użytkownika rejestrowana przez sigaction(), wywoływana w kontekście procesu w momencie dostarczenia.

Domyślne akcje dla najczęstszych sygnałów wyglądają tak:

SygnałNumerAkcja domyślnaMożna złapać?Typowe źródło
SIGTERM15ZakończenieTakUprzejma prośba o zamknięcie (kill)
SIGKILL9ZakończenieNieWymuszone zabicie procesu
SIGINT2ZakończenieTakCtrl+C w terminalu
SIGSEGV11Zrzut pamięciTakNaruszenie ochrony pamięci
SIGCHLD17IgnorowanieTakZmiana stanu procesu potomnego
SIGSTOP19ZatrzymanieNieWstrzymanie procesu
SIGHUP1ZakończenieTakRozłączenie terminala / reload configu

SIGKILL i SIGSTOP — dlaczego nie da się ich złapać

Dwa sygnały — SIGKILL (9) i SIGSTOP (19) — są nieprzechwytywalne z definicji. Nie zarejestrujesz dla nich handlera, nie ustawisz ich na ignorowanie, nie zablokujesz ich maską. Kernel obsługuje je sam, bez oddawania sterowania procesowi.

To celowa decyzja projektowa. Gdyby proces mógł przechwycić SIGKILL, mógłby zignorować polecenie zakończenia i stać się nieubijalny. SIGKILL jest mechanizmem ostatniej szansy dla systemu operacyjnego — gwarancją, że administrator zawsze odzyska kontrolę nad zasobami. Próba ustawienia handlera kończy się błędem EINVAL:

#include <signal.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>

int main(void) {
    struct sigaction sa = {0};
    sa.sa_handler = SIG_IGN;

    if (sigaction(SIGKILL, &sa, NULL) == -1) {
        // errno == EINVAL: SIGKILL nie może mieć handlera
        fprintf(stderr, "sigaction(SIGKILL): %s\n", strerror(errno));
    }
    return 0;
}

Praktyczna konsekwencja: jeśli proces nie reaguje na SIGTERM, to znaczy, że jego handler się zawiesił, utknął w nieprzerywalnym syscallu (stan D — uninterruptible sleep) albo celowo ignoruje sygnał. Dopiero wtedy sięgasz po SIGKILL. Ale proces w stanie D nie zareaguje nawet na SIGKILL do czasu, aż syscall się zakończy — dlatego czasem widzisz procesu, których kill -9 nie rusza.

SIGCHLD i procesy zombie

Tu sygnały spotykają się z zarządzaniem procesami. Kiedy proces potomny kończy działanie, kernel wysyła SIGCHLD do rodzica i — co kluczowe — utrzymuje minimalny wpis o procesie potomnym w tablicy procesów. Ten wpis zawiera kod wyjścia, statystyki zużycia zasobów i PID. Proces w tym stanie to zombie (stan Z): nie wykonuje już żadnego kodu, ale zajmuje slot w tablicy procesów.

Wpis znika dopiero, gdy rodzic wykona wait() lub waitpid() i odbierze kod wyjścia — operację nazywamy reaping (żniwa). To samo zagadnienie opisałem szczegółowo od strony tworzenia procesów w artykule o tym, co naprawdę robi fork() w Linuksie; tutaj patrzymy na nie od strony sygnałów.

Domyślna dyspozycja SIGCHLD to ignorowanie — ale samo zignorowanie sygnału nie reapuje zombie. Musisz albo aktywnie wywołać wait(), albo jawnie ustawić dyspozycję na SIG_IGN przez sigaction() (co na Linuksie powoduje automatyczny reaping), albo obsłużyć SIGCHLD w handlerze. Poprawny handler wygląda tak:

#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>

// Reaper: odbiera WSZYSTKIE zakończone procesy potomne.
// Pętla jest konieczna — pojedynczy SIGCHLD może reprezentować
// wiele zakończonych dzieci (sygnały się nie kolejkują).
void sigchld_handler(int signo) {
    int saved_errno = errno;   // handler musi zachować errno
    pid_t pid;

    while ((pid = waitpid(-1, NULL, WNOHANG)) > 0) {
        // zreapowano proces o danym PID
    }

    errno = saved_errno;
}

int install_reaper(void) {
    struct sigaction sa = {0};
    sa.sa_handler = sigchld_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;  // restart syscalli, ignoruj stop/cont

    return sigaction(SIGCHLD, &sa, NULL);
}

Pętla while z WNOHANG jest tu nieprzypadkowa. Sygnały standardowe (poniżej 32) nie kolejkują się: jeśli trzy procesy potomne zakończą się w krótkim odstępie, zanim handler zdąży się uruchomić, dostaniesz tylko jeden SIGCHLD, nie trzy. Handler, który wywołuje waitpid() tylko raz, zreapuje jedno dziecko i zostawi dwa zombie. Pętla odbiera wszystkie dostępne na raz.

Async-signal-safety: co wolno w handlerze

Handler sygnału wykonuje się asynchronicznie — może przerwać główny kod procesu w dowolnym momencie, również w środku wywołania biblioteki standardowej. To prowadzi do najczęstszego błędu w obsłudze sygnałów: wywołania funkcji, która nie jest async-signal-safe.

Wyobraź sobie, że proces jest w środku malloc(), który właśnie modyfikuje wewnętrzne struktury sterty i trzyma blokadę. Przychodzi sygnał, handler wywołuje printf() — który wewnętrznie też woła malloc(). Druga alokacja próbuje wziąć tę samą blokadę, którą trzyma przerwany kod. Rezultat to deadlock albo uszkodzenie sterty. To samo dotyczy każdej funkcji, która operuje na współdzielonym stanie globalnym.

POSIX definiuje wąską listę funkcji gwarantowanych jako async-signal-safe. Najważniejsze z nich:

Funkcje async-signal-safe vs. niebezpieczne w handlerze
Bezpieczne w handlerzeNIEbezpieczne — nigdy w handlerze
write()printf(), fprintf()
read()malloc(), free()
waitpid()fopen(), fclose()
_exit()exit() (woła atexit)
signal(), sigaction()większość funkcji stdio
kill()funkcje operujące na lokalizacji (setlocale)

Praktyczny wzorzec: handler powinien być maksymalnie krótki. W idealnym przypadku ustawia tylko flagę typu volatile sig_atomic_t i wraca, a całą logikę wykonuje główna pętla programu po sprawdzeniu flagi. Jeśli handler musi zalogować informację, używa write() bezpośrednio na deskryptorze, nigdy printf().

#include <signal.h>
#include <unistd.h>

// Flaga modyfikowana przez handler, odczytywana przez główną pętlę.
// volatile: kompilator nie może jej cache'ować w rejestrze.
// sig_atomic_t: gwarancja atomowego zapisu/odczytu.
static volatile sig_atomic_t shutdown_requested = 0;

void handle_term(int signo) {
    shutdown_requested = 1;
    // opcjonalny log — write() jest async-signal-safe
    const char msg[] = "SIGTERM odebrany, zamykanie\n";
    write(STDERR_FILENO, msg, sizeof(msg) - 1);
}

int main(void) {
    struct sigaction sa = {0};
    sa.sa_handler = handle_term;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    sigaction(SIGTERM, &sa, NULL);

    while (!shutdown_requested) {
        // główna pętla: tu wykonujesz całą realną pracę,
        // łącznie z czyszczeniem zasobów po wykryciu flagi
    }

    // graceful shutdown poza handlerem — tu wolno wszystko
    return 0;
}

SA_RESTART i przerwane syscalle

Jest jeszcze jeden mechanizm, który zaskakuje przy pierwszym kontakcie. Kiedy sygnał zostaje dostarczony w trakcie blokującego syscalla — na przykład read() czekającego na dane z gniazda — syscall może zostać przerwany i zwrócić błąd EINTR zamiast dokończyć operację.

Bez odpowiedniej obsługi prowadzi to do trudnych bugów: kod zakłada, że read() albo zwróci dane, albo zablokuje się do skutku, a tymczasem dostaje EINTR i — jeśli programista nie sprawdza tego przypadku — traktuje to jak błąd krytyczny. Masz dwie opcje. Pierwsza to flaga SA_RESTART w sigaction(), która każe kernelowi automatycznie wznowić przerwane syscalle. Druga to jawne sprawdzanie EINTR i ponawianie operacji:

#include <unistd.h>
#include <errno.h>

// Wrapper odporny na przerwanie sygnałem.
// Ponawia read() przy EINTR zamiast traktować to jak błąd.
ssize_t read_retry(int fd, void *buf, size_t count) {
    ssize_t n;
    do {
        n = read(fd, buf, count);
    } while (n == -1 && errno == EINTR);
    return n;
}

Uwaga: SA_RESTART nie działa dla wszystkich syscalli. Część z nich — między innymi te związane z czekaniem na zdarzenia, jak część wariantów poll() czy operacje na timerach — zwraca EINTR niezależnie od flagi. Dlatego defensywny kod sieciowy i tak powinien obsługiwać EINTR jawnie, nawet z ustawionym SA_RESTART.

Diagnostyka: podglądanie sygnałów w działającym procesie

Kiedy proces zachowuje się dziwnie wobec sygnałów, nie musisz zgadywać. Plik /proc/<pid>/status pokazuje aktualne maski sygnałów w postaci szesnastkowej:

$ grep -E 'Sig|Shd' /proc/$(pgrep -n nginx)/status
SigQ:   0/31228          # sygnały w kolejce / limit
SigPnd: 0000000000000000 # pending dla tego wątku
SigBlk: 0000000000000000 # zablokowane (maska)
SigIgn: 0000000000001000 # ignorowane
SigCgt: 0000000180014a07 # przechwytywane (mają handler)

Każdy bit odpowiada jednemu sygnałowi (bit 0 = sygnał 1). Maska SigCgt mówi, które sygnały proces obsługuje własnym handlerem — przydatne, gdy chcesz sprawdzić, czy aplikacja w ogóle reaguje na SIGHUP (reload configu), zanim wyślesz go na produkcji.

Do śledzenia dostarczenia sygnałów w czasie rzeczywistym użyjesz strace z filtrem na sygnały. Pokazuje dokładnie moment dostarczenia i to, czy handler został wywołany — temat narzędzia rozwijam w artykule o tym, jak czytać syscalle przez strace, gdy stack trace nie wystarcza:

$ strace -e trace=signal -p <pid>
--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=4821} ---
rt_sigreturn({mask=[]})  = 202
# widać dostarczenie SIGTERM i powrót z handlera

Podsumowanie

Sygnały w Linuksie to nie prosty mechanizm „wyślij liczbę, proces reaguje”. To asynchroniczny interfejs z trzema warstwami subtelności: rozróżnieniem między wygenerowaniem a dostarczeniem, ograniczeniami tego, co wolno zrobić w handlerze, i niekolejkowaniem sygnałów standardowych. Najczęstsze bugi biorą się z ignorowania tych trzech rzeczy — handler wołający printf(), reaper bez pętli while, kod sieciowy nieobsługujący EINTR.

Reguła praktyczna: traktuj handler jak kod przerwania sprzętowego. Im mniej w nim robisz, tym mniej rzeczy może pójść źle. Ustaw flagę, wróć, obsłuż resztę w głównej pętli — i nigdy nie zakładaj, że jeden sygnał oznacza jedno zdarzenie.

Zostaw komentarz

Twój adres email nie zostanie opublikowany. Wymagane pola są oznaczone *

Przewijanie do góry