Gestire Segnali con la funzione signal in Linux

In questa lezione vedremo come gestire i segnali in Linux attraverso la funzione signal. Vedremo come registrare un handler di segnale personalizzato per un segnale specifico e come ripristinare l'handler precedente.

La funzione signal()

Originariamente, i sistemi operativi UNIX, così come anche Linux, permettevano di cambiare la gestione di un segnale attraverso la funzione signal. Questa funzione rappresenta il modo più semplice per modificare il comportamento di un processo in seguito alla ricezione di un segnale.

Oggigiorno, si utilizza la funzione sigaction, che vedremo nelle prossime lezioni, in quanto signal è considerata obsoleta.

Il principale problema nell'utilizzo di signal è che esistono delle sottili differenze nel suo comportamento tra i vari sistemi operativi UNIX, e quindi non è garantito che il codice scritto sia portabile.

La funzione sigaction, invece, ha un comportamento standard. In questa lezione vedremo come usare signal ma, in generale, è consigliabile utilizzare sigaction. Inoltre, sotto Linux, in realtà, signal è implementata come un wrapper di sigaction.

In generale, la funzione signal ha la seguente firma:

#include <signal.h>

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);

La firma di signal è un po' complessa; esaminiamola punto per punto:

  • Il primo argomento signum rappresenta il segnale che vogliamo gestire;
  • Il secondo argomento handler rappresenta un puntatore a funzione. Si tratta di un puntatore all'handler di segnale che vogliamo associare al segnale signum.

    Un handler di segnale è una qualunque funzione con la seguente firma:

    void handler(int signum);
    

    Ossia, una funzione che accetta un intero come argomento e non restituisce alcun valore.

    Dopo vedremo come creare un handler di segnale personalizzato e qual è l'utilizzo del parametro signum all'interno dell'handler.

  • La funzione signal restituisce un puntatore a funzione. Questo puntatore rappresenta l'handler di segnale precedente associato al segnale signum. In questo modo, possiamo salvare l'handler precedente e ripristinarlo, eventualmente, in seguito.

    In caso di errore, la funzione restituisce il valore SIG_ERR e imposta la variabile errno con un valore appropriato.

Ad esempio, volendo gestire il segnale SIGINT (che corrisponde alla combinazione di tasti Ctrl + C), con un handler personalizzato per poi ripristinare l'handler precedente, possiamo scrivere il seguente codice:

sighandler_t vecchio_handler = nullptr;

vecchio_handler = signal(SIGINT, handler_personalizzato);

if (vecchio_handler == SIG_ERR) {
    perror("signal");
    exit(EXIT_FAILURE);
}

/* Ora il segnale SIGINT è gestito da handler_personalizzato */

/* Ripristiniamo l'handler precedente */
if (signal(SIGINT, vecchio_handler) == SIG_ERR) {
    perror("signal");
    exit(EXIT_FAILURE);
}
Definizione

Funzione signal()

La funzione signal permette di cambiare la gestione di un segnale. La funzione ha la seguente firma:

#include <signal.h>

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);
  • signum: rappresenta il segnale che vogliamo gestire;
  • handler: rappresenta un puntatore a funzione che rappresenta l'handler di segnale che vogliamo associare al segnale signum.

La funzione restituisce un puntatore a funzione che rappresenta l'handler di segnale precedente associato al segnale signum. In caso di errore, la funzione restituisce SIG_ERR e imposta la variabile errno.

Identificatori SIG_DFL e SIG_IGN

Attraverso la funzione signal si possono anche specificare altre azioni da eseguire in seguito alla ricezione di un segnale. In particolare, possiamo utilizzare due identificatori speciali:

  • SIG_DFL: che sta per Signal Default. Utilizzando questo identificatore, viene resettata la disposizione del segnale al valore di default come mostrato nella tabella vista in precedenza;

  • SIG_IGN: che sta per Signal Ignore. Utilizzando questo identificatore, il segnale viene ignorato.

Questi due identificatori possono essere restituiti dalla funzione signal.

Esempio

Proviamo, con un esempio, a mostrare il funzionamento di signal. Riprendiamo l'esempio di processo bersaglio che abbiamo creato per studiare la funzione kill.

Modifichiamo, però, l'esempio in modo tale da ignorare il segnale SIGINT:

/* bersaglio.c */

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

int main() {
    /* Stampa il proprio PID */
    printf("PID: %d\n", getpid());

    /* Ignora il segnale SIGINT */
    signal(SIGINT, SIG_IGN);

    while (1) {
        printf("Processo bersaglio\n");
        sleep(1);
    }
    return 0;
}

Compiliamo il codice e avviamo il processo bersaglio:

$ ./bersaglio
PID: 1234
Processo bersaglio
Processo bersaglio
Processo bersaglio
...

Ovviamente il PID del processo sarà diverso da 1234. Il processo bersaglio stamperà ripetutamente la stringa "Processo bersaglio" ogni secondo.

Ora, se proviamo a inviare il segnale SIGINT al processo bersaglio, vedremo che il processo non verrà terminato:

$ kill -SIGINT 1234

Non solo, ma anche premendo Ctrl + C non avremo alcun effetto.

Viceversa, se proviamo ad inviare il segnale SIGTERM, il processo verrà terminato:

$ kill -SIGTERM 1234
$ ./bersaglio
PID: 1234
Processo bersaglio
Processo bersaglio
Processo bersaglio
...
Terminated

Introduzione agli Handler di Segnali

Nella sezione precedente abbiamo visto come cambiare la disposizione di un segnale attraverso la funzione signal. Adesso introduciamo il concetto di handler di segnale.

Nella sua forma più semplice, un handler di segnale è una funzione che viene eseguita in seguito alla ricezione di uno specifico segnale.

L'invocazione di un handler potrebbe interrompere il flusso principale di un programma in qualunque momento. Il kernel del sistema invoca l'handler per conto del processo e, quando l'esecuzione dell'handler termina, l'esecuzione del programma riprende esattamente dal punto in cui era stata interrotta.

Per comprendere meglio, osserviamo la figura che segue:

Funzionamento di un Handler di Segnale
Figura 1: Funzionamento di un Handler di Segnale

Come si osserva dalla figura, il processo bersaglio è in esecuzione e, ad un certo punto, dopo aver eseguito l'istruzione n, riceve un segnale. A questo punto, il flusso di esecuzione passa all'handler di segnale che viene eseguito nella sua interezza e termina. Dopo che l'handler termina, il flusso di esecuzione del processo riprende esattamente dal punto in cui era stato interrotto, ossia dall'istruzione n+1.

Sebbene un handler di segnale è una funzione come un'altra, è sempre buona prassi mantenere l'handler il più semplice possibile. I motivi li esamineremo nelle prossime lezioni.

Per ora, vediamo come creare un handler di segnale personalizzato. Modifichiamo il codice del programma bersaglio.c in modo tale da creare un handler personalizzato per il segnale SIGINT. Questo handler farà due cose:

  • Stampa un messaggio a video;
  • Imposta una variabile globale in modo tale da chiudere in maniera pulita l'esecuzione del programma.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/* bersaglio_handler.c */

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

/* Variabile globale per terminare il programma */
int termina = 0;

/* Handler personalizzato per SIGINT */
void handler_sigint(int signum) {
    printf("Ricevuto segnale SIGINT\n");
    termina = 1;
}

int main() {
    /* Stampa il proprio PID */
    printf("PID: %d\n", getpid());

    /* Imposta l'handler personalizzato per SIGINT */
    signal(SIGINT, handler_sigint);

    while (!termina) {
        printf("Processo bersaglio\n");
        sleep(1);
    }

    printf("Uscita...\n");

    return 0;
}

Abbiamo modificato il codice in quattro punti:

  • Abbiamo dichiarato una variabile globale termina che utilizzeremo per terminare il ciclo while nel main (riga 8);
  • Abbiamo definito una funzione handler_sigint che stamperà un messaggio a video e imposterà la variabile termina a 1 (righe 11-14);
  • Abbiamo impostato l'handler personalizzato per il segnale SIGINT (riga 21);
  • Abbiamo modificato il ciclo while in modo tale che termini quando la variabile termina è diversa da 0 (riga 23).

Compiliamo il codice e avviamo il programma:

$ ./bersaglio_handler
PID: 1234
Processo bersaglio
Processo bersaglio
Processo bersaglio
...

Ora, se inviamo il segnale SIGINT al processo bersaglio, utilizzando la combinazione di tasti Ctrl + C, vedremo che il programma si chiuderà in maniera pulita stampando i messaggi che seguono:

$ ./bersaglio_handler
PID: 1234
Processo bersaglio
Processo bersaglio
Processo bersaglio
...
^CRicevuto segnale SIGINT
Uscita...

Questo codice, tuttavia, ha dei difetti nascosti che per ora non approfondiremo. In generale, non è una buona norma utilizzare funzioni di stdio.h come la printf all'interno di un handler. Inoltre, modificare il valore di variabili globali all'interno di un handler deve essere fatto con cautela. Analizzeremo nel dettaglio questi aspetti nelle prossime lezioni.

Una cosa importante da notare è che un handler riceve un parametro in ingresso: il parametro signum che rappresenta il segnale che ha scatenato l'invocazione dell'handler. Nell'esempio di sopra, poiché l'handler è stato registrato solo per il segnale SIGINT, questo parametro è di scarsa utilità. Tuttavia, in casi più complessi, potrebbe essere utile.

Ricapitolando:

Definizione

Handler di Segnale

Un Handler di Segnale è una funzione che viene eseguita in seguito alla ricezione di un segnale. L'esecuzione di un handler comincia immediatamente dopo la ricezione del segnale interrompendo il flusso principale del programma. Quando l'handler termina, l'esecuzione del programma riprende esattamente dal punto in cui era stata interrotta.

La firma di un handler di segnale è la seguente:

void handler(int signum);

Il parametro signum rappresenta il segnale che ha scatenato l'invocazione dell'handler.

Un handler di segnale può essere registrato per catturare uno o più segnali.

Esempio di Handler per più Segnali

Proviamo a scrivere un programma con un handler che gestisce più segnali.

Il programma che andiamo a realizzare è simile al programma bersaglio.c visto sopra ma con alcune differenze:

  • Per prima cosa, il programma, tra una stampa e l'altra del messaggio potrà aspettare una quantità di tempo differente;
  • La quantità di secondi di attesa sarà memorizzata in una variabile globale secondi_attesa inizialmente valorizzata a 1;
  • Quando il processo riceve il segnale SIGUSR1, la variabile secondi_attesa verrà incrementata di 1;
  • Quando il processo riceve il segnale SIGUSR2, la variabile secondi_attesa verrà decrementata di 1, fino al valore minimo di 1;
  • Infine, quando il processo riceve il segnale SIGINT, il processo terminerà.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/* bersaglio_handler_multi.c */

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

/* Variabile globale per terminare il programma */
int termina = 0;

/* Variabile globale per l'attesa */
int secondi_attesa = 1;

/* Handler multisegnale */
void handler_multisegnale(int signum) {
    switch (signum) {
        case SIGUSR1:
            secondi_attesa++;
            break;
        case SIGUSR2:
            if (secondi_attesa > 1) {
                secondi_attesa--;
            }
            break;
        case SIGINT:
            termina = 1;
            break;
    }
}

int main() {
    /* Stampa il proprio PID */
    printf("PID: %d\n", getpid());

    /* Imposta l'handler personalizzato per
     * SIGUSR1,
     * SIGUSR2,
     * SIGINT */
    signal(SIGUSR1, handler_multisegnale);
    signal(SIGUSR2, handler_multisegnale);
    signal(SIGINT, handler_multisegnale);

    while (!termina) {
        printf("Attendo %d secondi...\n", secondi_attesa);
        sleep(secondi_attesa);
    }

    printf("Uscita...\n");

    return 0;
}

Provando a compilare il programma ed eseguirlo, possiamo inviare i segnali SIGUSR1, SIGUSR2 e SIGINT al processo bersaglio per vedere come il programma si comporta.

$ ./bersaglio_handler_multi
PID: 1234
Attendo 1 secondi...
Attendo 1 secondi...
...

Inviamo, prima, il segnale SIGUSR1 al processo bersaglio:

$ kill -SIGUSR1 1234

Il processo stamperà:

Attendo 2 secondi...
Attendo 2 secondi...
...

Il tempo di attesa è aumentato di un secondo.

Inviamo, ora, il segnale SIGUSR2 al processo bersaglio:

$ kill -SIGUSR2 1234

Il processo stamperà:

Attendo 1 secondi...
Attendo 1 secondi...
...

Il tempo di attesa è diminuito di un secondo.

Infine, inviamo il segnale SIGINT al processo bersaglio:

$ kill -SIGINT 1234

Il processo stamperà:

Uscita...

In Sintesi

In questa lezione abbiamo visto come gestire i segnali attraverso la funzione signal. Abbiamo visto come registrare un handler di segnale personalizzato per un segnale specifico e come ripristinare l'handler precedente.

Abbiamo anche introdotto il concetto di handler di segnale e come esso interrompa il flusso principale del programma in seguito alla ricezione di un segnale.

Nella prossima lezione analizzeremo due modi che permettono ad un processo di inviare segnali a se stesso: raise e killpg.