Nei titoli e nei testi troverete qualche rimando cinematografico (ebbene si, sono un cinefilo). Se non vi interessano fate finta di non vederli, già che non sono fondamentali per la comprensione dei post...

Di questo blog ho mandato avanti, fino a Settembre 2018, anche una versione in Spagnolo. Potete trovarla su El arte de la programación en C. Buona lettura.

sabato 19 marzo 2022

Signal Handler
come si scrive un signal handler in C

Michele Apicella: ...il Mont Blanc si regge su un equilibrio delicato, non è come la Sacher Torte...
Mario: Cosa?
Michele Apicella: La Sacher Torte...
Mario: Cos'è?
Michele ApicellaCioè, lei praticamente non ha mai assaggiato la Sacher Torte?
Mario: No.
Michele Apicella: Vabbè, continuiamo così, facciamoci del male...

Signal Handler, un titolo secco e breve che mi fa venire in mente Bianca del grande Nanni Moretti (ogni scusa è buona per ricordarsi di un gran film). Anche qui un titolo secco per un film bellissimo, che ci presenta, con magistrali toni da commedia, argomenti tragici come solitudine, nevrosi e ossessioni... e il momento mitico citato in apertura ci ricorda che alcune cose date per scontate (la Sacher Torte) possono essere meno scontate di quello che sembra, come i Segnali di POSIX (e quindi di UNIX/Linux).

...Cioè, lei praticamente non ha mai scritto un Signal Handler?...

Allora, in questo articolo parleremo di segnali, che sono la forma più semplice e antica di IPC nei sistemi POSIX. Anzi, dovrei aggiungere che nel ciclo di articoli sulla POSIX IPC (qui, qui e qui le tre parti) non ne ho parlato volutamente: e perché? Beh, in quel ciclo si parlava di comunicazione tra processi a livello di trasferimento di dati (e quindi con messaggi, memoria condivisa, ecc.), con invio e ricezione delle più svariate informazioni. I segnali, invece, sono una forma di comunicazione sicura e potente, ma limitata a un set di informazioni ben precise e codificate. Ossia sono del tipo: "Ti invio il segnale n.9 e tu sai già cosa devi fare".

Come detto, i segnali sono la forma più antica di comunicazione tra processi: sono anteriori a POSIX (che si avviò nel 1988) e sono, addirittura, anteriori anche alla prima edizione del nome IPC, che venne usato per la prima volta nel 1983 per denominare il System V IPC di Unix SVR1 (Unix System V Release 1). Ebbene si, i segnali sono veramente antichi: sono apparsi per la prima volta in Unix V4 (Unix 4th Edition), nel 1973! Quanto tempo è passato...

Allora, come detto sopra, i segnali si limitano a "un set di informazioni ben precise e codificate", che si possono listare usando il comando "kill -l"  e sulla mia macchina Linux sono queste:

aldo@Linux $ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX

Sono un po' più di quelli standard (che sono 28) descritti nello standard POSIX, infatti almeno gli ultimi 30 della lista mostrata hanno un uso molto limitato e speciale. Il comando kill(1), è disponibile nella shell di Linux, e usa internamente le system call che ci interessano in questa sede, quelle relative ai segnali.

Il meccanismo d'uso dei segnali che può essere implementato nelle applicazioni che scriviamo è relativamente semplice:

  1. Un processo può inviare a un altro processo uno dei segnali listati qua sopra usando la system call kill(2).
  2. Il processo ricevente eseguirà l'azione di default collegata al segnale ricevuto, a meno che non abbia previamente definito un signal handler.
  3. Un processo può definire un signal handler (ad esempio usando la system call signal(2)) per trattare opportunamente i segnali ricevuti

Punto. Cioè, in realtà l'argomento è un po' più complicato, ma sommariamente si può sintetizzare con i tre punti appena descritti.

1) Si comincia con signal(2)

E quindi, cosa è un signal handler? È, come dice il nome, una funzione che "maneggia" opportunamente i segnali. Come dite? Non è chiaro? Volete un semplice esempio? Eccolo!

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

// prototipi locali
static void sigHandler(int signum);

// main() - funzione main
int main(void)
{
// WARNING! usiamo signal() solo per test!

// creo un signal handler con signal() per SIGKILL (sig n.9)
if (signal(SIGKILL, sigHandler) == SIG_ERR)
printf("non posso intercettare SIGKILL\n");

// creo un signal handler con signal() per SIGINT (sig n.2)
if (signal(SIGINT, sigHandler) == SIG_ERR)
printf("non posso intercettare SIGINT\n");

// un loop infinito per testare la applicazione
for (;;)
sleep(1);

return 0;
}

// sigHandler() - un semplice signal handler
static void sigHandler(int signum)
{
// WARNING! usiamo printf() solo per test!

// mostro l'arrivo di uno dei segnali intercettati
if (signum == SIGKILL)
printf("ricevuto un SIGKILL\n");
else if (signum == SIGINT)
printf("ricevuto un SIGINT\n");
}

Che ne dite? È un codice semplicissimo, di poche righe (e anche ben commentate).

Sorvolando sui due Warning inseriti nei commenti (ci torneremo dopo) è evidente che è relativamente semplice scrivere un signal handler: basta chiamare signal(2) indicando quale segnale vogliamo intercettare e con quale funzione vogliamo farlo. Poi, ovviamente, dobbiamo scrivere la funzione di handler, che può fare cose più o meno sofisticate, e in questo programma di test si limita a dire che ha intercettato il segnale. E vediamo cosa ci mostra il programma durante l'esecuzione:

aldo@Linux $ ./signal
non posso intercettare SIGKILL
^Cricevuto un SIGINT
ricevuto un SIGINT
^Cricevuto un SIGINT
Ucciso

Allora: ho eseguito il programma e subito ho ricevuto il messaggio di errore "non posso intercettare SIGKILL"  perché, sfortunatamente, alcuni segnali non si possono intercettare, e SIGKILL è proprio uno di questi (tenetelo presente quando scrivete un signal handler!). Dopodiché il programma non fa nulla (ha un loop infinito di sleep(3)) e si risveglia quando arriva un segnale intercettato, nel nostro caso SIGINT che è il "interrupt signal" e si può inviare semplicemente scrivendo "Ctrl + C" (righe 3 e 5 del test) oppure usando il comando shell kill(1) da un altro terminale (riga 4 del test). Quando il nostro programma riceve un segnale non intercettato esegue l'azione standard per quel tipo di segnale e, nel nostro caso, ho inviato un SIGKILL con "kill -9 pid-del-processo" , e il processo è stato ucciso (riga 6 del test. E questa è, tristemente, l'azione standard di SIGKILL).

La struttura del programma di test ci insegna che è possibile installare un signal handler che tratta più segnali alla volta, basta aggiungere altre chiamate a signal(2) nel main() e altri "else if"  nella funzione sigHandler(). E ogni segnale intercettato può eseguire diverse attività quando viene ricevuto, così si può personalizzare notevolmente il comportamento in varie situazioni di emergenza e non. E quale è un esempio molto tipico di uso un signal handler? Io direi la gestione dell'uscita controllata: ossia si può decidere che alla ricezione di un determinato segnale (tipicamente SIGTERM) il nostro programma esegua una serie di attività (tipo fermare dei thread, scrivere dei buffer su disco, liberare la memoria, ecc.) e poi uscire. Et voilà!

E ora veniamo alle dolenti note, quelle indicate nei due Warning del programma di test:

- WARNING! usiamo signal() solo per test!

Abbiamo visto che usare signal(2) è relativamente semplice, però, sfortunatamente, il suo uso è scoraggiato, e il motivo è ben spiegato nel manuale:

The behavior of signal() varies across UNIX versions, and has also var‐
ied historically across different versions of Linux. Avoid its use:
use sigaction(2) instead. See Portability below.

The effects of signal() in a multithreaded process are unspecified.

POSIX.1 solved the portability mess by specifying sigaction(2), which
provides explicit control of the semantics when a signal handler is in‐
voked; use that interface instead of signal().

da: SIGNAL(2) Linux Programmer's Manual

ossia, signal(2) è standard (è addirittura parte del ISO C/ANSI C/Standard C) però fornisce comportamenti imprevedibili nel multithreading e, soprattutto, ha problemi di portabilità (il che è un po' assurdo per una funzione standard del C!). POSIX ha risolto il problema deprecando signal(2) e inserendo la sigaction(2) che è, definitivamente, l'interfaccia raccomandata per tutte le nuove applicazioni (anche se, paradossalmente, questa nuova funzione è meno portabile in assoluto, perché non fa parte del Standard C ma solo di POSIX).

Comunque, è importante evidenziare che è assolutamente sconsigliato mischiare signal(2) e sigaction(2) nello stesso programma: lo standard POSIX stesso, per evitare problemi di questo tipo, raccomanda di implementare (a livello libreria) signal(2) usando la sigaction(2) stessa (e mi risulta che su Linux sia così). La sigaction(2) la vedremo tra poco.

- WARNING! usiamo printf() solo per test!

Questo è un problema più semplice da descrivere: la printf(3) è una funzione che non fa parte di una lista di funzioni che sono, per così dire, "signal-handler-safe" (in realtà il nome esatto è "async-signal-safe functions"). E la spiegazione è ben descritta qui:

When a signal occurs, the normal flow of control of a program is interrupted.
If a signal occurs that is being trapped by a signal handler, that handler is
invoked. When it is finished, execution continues at the point at which the
signal occurred. This arrangement can cause problems if the signal handler
invokes a library function that was being executed at the time of the signal.

da: C Rationale, 7.14.1.1 [C99 Rationale 2003]

2) Si prosegue con sigaction(2)

E ora credo che siamo pronti per esaminare una versione più accettabile del programma di test. Vai col codice!

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

#define SIGINT_MSG "ricevuto un SIGINT\n"

// prototipi locali
static void sigHandler(int signum);

/* un flag per mantenere attivo il loop pseudo-infinito.
volatile potrebbe essere necessario a seconda del sistema/implementazione
in uso. (vedere "C11 draft standard n1570: 5.1.2.3") */
volatile sig_atomic_t resta_attivo = 1;

// main() - funzione main
int main(int argc, char *argv[])
{
// preparo i dati per il signal handler
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = sigHandler;
sa.sa_flags = SA_RESTART;

// creo un signal handler con signal() per SIGINT (sig n.2)
if (sigaction(SIGINT, &sa, NULL) == -1)
printf("non posso intercettare SIGINT\n");

// un loop pseudo-infinito per testare la applicazione
while (resta_attivo)
sleep(1);

// il loop pseudo-infinito è stato interrotto da un SIGINT
printf("loop teminato\n");
return 0;
}

// sigHandler() - un semplice signal handler
static void sigHandler(int signum)
{
// mostro l'arrivo di un SIGINT e resetta il flag resta_attivo
write(STDERR_FILENO, SIGINT_MSG, sizeof(SIGINT_MSG));
resta_attivo = 0;
}

Il codice è molto simile a quello presentato prima, ma con alcune interessanti varianti:

  • visto che si usa la sigaction(2) si prepara anche una struttura dati "struct sigaction" che imposta il funzionamento. Associata a questa struttura ci sono un po' di variabili e funzioni di supporto che si possono utilizzare (ben descritte nel man-page della sigaction(2)). Questo esempio l'ho scritto semplificandolo al massimo (e funziona bene ugualmente) ma si può complicare a piacere, in base alle esigenze.
  • la sigaction(2) una volta impostati i dati, si usa, più o meno, come la signal(2).
  • la funzione sigHandler() non usa più printf(3) ma usa write(2) che è asynchronous-safe.
  • il loop infinito ora è un loop pseudo-infinito, con una uscita controllata da una variabile flag gestita dalla funzione sigHandler(): qui si nota che l'obbligo di usare funzioni asynchronous-safe dentro il signal handler si estende anche alle variabili, e quindi bisogna usare un tipo sig_atomic specificato come volatile per poter modificare il flag dentro il signal handler.

Ed ora possiamo vedere cosa ci mostra il programma durante l'esecuzione:

aldo@Linux $ ./sigaction
^Cricevuto un SIGINT
loop teminato
aldo@Linux $ ./sigaction
ricevuto un SIGINT
loop teminato
aldo@Linux $ ./sigaction
Ucciso

Allora: l'esecuzione del programma non mostra nessun messaggio di errore visto che intercetta solo SIGINT. Il programma non fa nulla (entra nel loop pseudo-infinito di sleep(3)) e si risveglia quando arriva un segnale SIGINT: la prima esecuzione ho scritto "Ctrl + C" (riga 2 del test) e il programma è uscito fermando il loop e scrivendo il messaggio di ricezione. Nella successiva esecuzione ho usato, da un altro terminale (riga 5 del test), il comando kill(1) per inviare il segnale SIGINT: anche in questa esecuzione il programma mi ha mostrato il messaggio di ricezione ed è uscito fermando il loop. Infine ho eseguito di nuovo il programma e ho inviato un SIGKILL dal secondo terminale: il processo è stato ucciso e non ha scritto nessun messaggio (SIGKILL non si può intercettare).

Che ne dite? Per oggi può bastare, no? Come dite? L'articolo non vi è piaciuto?  Vabbè , continuiamo così, facciamoci del male....

Ciao, e al prossimo post!