In un sistema operativo, la terminazione di un processo Γ¨ l’evento in cui un processo smette di essere eseguito e le sue risorse vengono rilasciate dal sistema.

1 - Tipi di terminazione di un processo

La terminazione di un processo puΓ² essere volontaria o forzata.

1.1 - Terminazione volontaria di un processo

In una terminazione volontaria (o normale), il processo completa l’esecuzione del suo codice (ad esempio, l’utente chiude un programma).

Ogni processo puΓ² chiamare esplicitamente la funzione di terminazione, come exit() in UNIX o ExitProcess() in Windows.

1.2 - Terminazione forzata di un processo

In una terminazione forzata (o anomala), il processo viene terminato a causa di un errore o un’eccezione non gestita, come una divisione per zero o un accesso in memoria non valido.

Il sistema operativo o un altro processo puΓ² terminare forzatamente un processo tramite chiamate di sistema (ad esempio, tramite l’invio di un segnale con kill() in UNIX o TerminateProcess() in Windows). Generalmente solo il processo padre del processo che si vuole terminare puΓ² invocare una chiamata di sistema di questo tipo, altrimenti gli utenti potrebbero causare arbitrariamente la terminazione forzata di processi di chiunque.

2 - Fasi principali della terminazione di un processo

Le fasi principali della terminazione di un processo sono:

  1. Ricezione della richiesta di terminazione: il processo che deve terminare chiama una funzione di uscita (exit(), _exit() o abort()) nel caso della terminazione volontaria o riceve un segnale di terminazione da un altro processo tramite kill() (es. SIGTERM, SIGKILL) nel caso della terminazione forzata.
  2. Esecuzione di funzioni all’uscita: se il processo terminazione volontariamente tramite exit(), vengono eseguite prima le funzioni registrate tramite atexit(), in ordine inverso rispetto alla loro registrazione.
  3. Chiusura e liberazione delle risorse: il sistema operativo chiude automaticamente tutti i file aperti dal processo. Anche altri tipi di risorse come socket, canali di comunicazione, semafori o altre strutture di dati vengono rilasciate, così come lo spazio di memoria assegnato al processo (sia quella per il codice che per i dati) viene liberato, in modo che possa essere utilizzato da altri processi.
  4. Aggiornamento dello stato del processo: nel PCB, lo stato del processo viene aggiornato in terminated e viene salvato il codice di uscita. FinchΓ© il processo padre non recupera lo stato del processo figlio tramite la funzione wait(), il processo figlio rimane nello stato zombie.
  5. Liberazione del PCB dalle code di schedulazione: il processo viene rimosso dalla coda dei processi pronti o da altre code di schedulazione. Il PCB del processo viene marcato per la rimozione o, nel caso di un processo nello stato zombie, il PCB viene mantenuto in memoria fino a che il processo padre recupera il suo stato.
  6. Notifica del processo padre: se il processo Γ¨ stato generato da un processo padre, quest’ultimo viene notificato della terminazione del figlio tramite un segnale SIGCHLD. In UNIX, se il processo padre termina prima del figlio, tutti i figli vengono adottati dal processo init, che diventa il loro nuovo padre e sarΓ  responsabile della loro gestione.
  7. Rimozione del processo dal sistema operativo: una volta che tutte le risorse sono state rilasciate e, se necessario, il processo padre (se presente) ha recuperato il codice di uscita del figlio, il sistema operativo rimuove il PCB e tutte le strutture associate al processo dalla memoria. A questo punto, il processo Γ¨ completamente terminato.

2.1 - Codice di uscita

Quando un processo termina, spesso restituisce un codice di uscita (in inglese exit code) al sistema operativo o al processo padre. Questo codice Γ¨ un valore che indica se il processo Γ¨ terminato correttamente o se si Γ¨ verificato un errore. I valori, contenuti in un intero di cui vengono considerati solo gli 8 piΓΉ significativi bit, sono:

  • Valore 0: indica che il processo Γ¨ terminato con successo.
  • Un valore diverso dallo 0: indica un errore o una condizione specifica che ha causato la terminazione del processo (ogni numero viene solitamente assegnato a un errore specifico che lo identifica).

Nella libreria stdlib.h sono definite le macro:

  • EXIT_SUCCESS: di solito 0.
  • EXIT_FAILURE: di solito 1.

È bene usare queste macro anziché direttamente i valori 0 e 1 perché può capitare che un sistema operativo possa utilizzare valori diversi da questi due per indicare il successo e il fallimento.

3 - Terminazione di un processo in C

In C, Γ¨ possibile terminare un processo in diversi modi:

3.1 - Terminazione volontaria di un processo tramite _exit()

La funzione _exit() Γ¨ una chiamata di sistema utilizzata per terminare volontariamente un processo.

Il suo prototipo Γ¨ il seguente:

#include <unistd.h>
 
void _exit(int status);

dove:

3.2 - Terminazione volontaria di un processo tramite exit()

Di solito, il codice dei programmi non contengono la funzione _exit(), ma contengono invece la funzione exit(), che costituisce anch’essa una chiamata di sistema utile a terminare volontariamente un processo.

Il suo prototipo Γ¨ il seguente:

#include <unistd.h>
 
void exit(int status);

dove:

Possiamo notare che il prototipo Γ¨ in tutto e per tutto identico a quello della funzione _exit(), ma la exit(), oltre alla terminazione del processo, compie anche altre azioni:

  1. Esegue i gestori di uscita: se sono stati registrati dei gestori di uscita (exit handlers) tramite atexit(), vengono eseguiti prima della chiusura del processo.
  2. Scarica e svuota i buffer di output: se ci sono dati ancora presenti nei buffer di output (es. stdout, stderr, ecc.), questi vengono scritti effettivamente nel file o sullo schermo.
  3. Chiude tutti i file aperti: i file aperti con fopen() vengono chiusi automaticamente.
  4. Rilascia risorse di librerie standard: alcune librerie standard possono eseguire operazioni di pulizia della memoria.
  5. Restituisce il codice di uscita: il valore passato alla funzione exit() nel parametro status viene restituito come codice di uscita del processo.

3.3 - Terminazione anomala di un processo tramite abort()

La funzione abort() Γ¨ una chiamata di sistema utilizzata per terminare un processo in modo anomalo. In particolare, quel che fa Γ¨:

  • Invia un segnale SIGABRT al processo corrente, che di default lo termina generando un core dump (se abilitato).
  • Non esegue i gestori registrati tramite atexit(), ignorandoli completamente.
  • Non garantisce la scrittura dei buffer di output stdout, i cui dati potrebbero essere persi se non sono stati svuotati manualmente.
  • Non chiude i file aperti.

Il suo prototipo Γ¨ il seguente:

#include <stdlib.h>
 
void abort();

3.3.1 - Uso di abort() con assert()

La funzione abort() viene usata all’interno della chiamata della funzione assert() (della libreria assert.h): nel caso in cui l’asserzione fallisca, infatti, il programma viene terminato tramite abort().

3.4 - Registrazione di funzioni da eseguire alla terminazione volontaria di un processo tramite atexit()

La funzione atexit() Γ¨ una funzione della libreria standard C che permette di registrare funzioni da eseguire automaticamente alla normale terminazione del processo, quando viene chiamata la funzione exit(), in ordine inverso di registrazione (ossia secondo una politica LIFO).

Il suo prototipo Γ¨ il seguente:

#include <stdlib.h>
 
int atexit(void (*func)(void));

dove:

  • func: puntatore a una funzione senza argomenti e senza valore di ritorno.
  • valore di ritorno int:
    • 0: la registrazione ha successo.
    • Valore diverso da 0: c’è stato un errore nella registrazione.

Attenzione: limite di funzioni registrabili

Il numero massimo di funzioni registrabili tramite atexit() dipende dall’implementazione, ma in genere Γ¨ 32.

3.5 - Monitoraggio della terminazione di un processo figlio qualsiasi tramite wait()

La funzione wait() è una chiamata di sistema usata per sospendere un processo padre (ossia metterlo nello stato waiting) fino alla terminazione di un qualsiasi processo figlio. È utile per evitare processi zombie e per recuperare il codice di uscita di un figlio.

Il suo prototipo Γ¨ il seguente:

#include <sys/wait.h>
 
pid_t wait(int *status);

dove:

  • status: puntatore a un intero dove verrΓ  memorizzato il codice di uscita del figlio.
  • pid_t restituito:
    • -1: c’è stato un errore (es. nessun figlio esistente, indicato dal valore ECHILD di errno).
    • Valore diverso da -1: PID del figlio terminato con successo.

3.6 - Monitoraggio della terminazione di un particolare processo figlio tramite waitpid()

La funzione waitpid() Γ¨ una chiamata di sistema usata per sospendere un processo padre (ossia metterlo nello stato waiting) fino alla terminazione di un particolare processo figlio (a differenza della funzione wait() che aspetta la terminazione di un figlio qualsiasi).

Il suo prototipo Γ¨ il seguente:

#include <sys/wait.h>
 
pid_t waitpid(pid_t pid, int *status, int options);

dove:

  • pid: PID del processo figlio che si vuole monitorare, in particolare:
    • pid > 0: monitora il processo figlio specifico con PID uguale a pid.
    • pid == 0: monitora tutti i processi figli nel gruppo di processi del chiamante (padre).
    • pid == -1: monitora tutti i processi figli (equivalente all’uso della funzione wait()).
    • pid < -1: monitora tutti i processi figli nel gruppo di processi specificato dal valore assoluto pid (cioΓ¨ -pid).
  • status: puntatore a un intero dove verrΓ  memorizzato il codice di uscita del figlio.
  • options: bit mask che puΓ² includere (in OR) zero o piΓΉ dei seguenti flag:
    • WUNTRACED: oltre a restituire informazioni quando un figlio termina, restituisce informazioni quando il figlio viene bloccato da un segnale.
    • WCONTINUED: restituisce informazioni anche nel caso il figlio sia nello stato stopped e venga risvegliato da un segnale SIGCONT.
    • WNOHANG: se nessun figlio specificato da pid ha cambiato stato, restituisci immediatamente, invece di bloccare il chiamante. In questo caso, il valore di ritorno di waitpid() Γ¨ 0. Se il processo chiamante non ha figli con il PID richiesto, waitpid() fallisce con l’errore ECHILD.
  • pid_t restituito:
    • -1: c’è stato un errore (es. nessun figlio esistente, indicato dal valore ECHILD di errno).
    • Valore diverso da -1: PID del figlio terminato con successo.

3.7 - Estrazione del codice di uscita tramite la macro WEXITSTATUS(status)

La macro WEXITSTATUS(status) in C Γ¨ usata per estrarre il codice di uscita di un processo figlio terminato normalmente. Questa macro viene utilizzata in combinazione con la funzione wait() o waitpid(), che restituiscono un valore di stato che contiene informazioni sulla terminazione del figlio.

È definita nel seguente modo:

#define WEXITSTATUS(x) ((x) >> 8)

dove:

  • x: codice di uscita ottenuto dall’invocazione di una wait() o waitpid().
  • Valore restituito: primi bit piΓΉ significativi di x.

3.8 - Gestione dei processi orfani

In generale, puΓ² succedere uno dei seguenti due casi:

  • Il processo figlio viene terminato prima del processo padre.
  • Viceversa, il processo padre viene terminato prima di suo figlio e quest’ultimo viene detto orfano.

Chi diventa il padre di un processo figlio orfano? In UNIX, il figlio orfano viene automaticamente adottato dal processo init, il progenitore di tutti i processi, il cui PID Γ¨ uguale a 1.

CiΓ² significa che, dopo che il genitore di un processo figlio termina, una chiamata alla funzione getppid() restituirΓ  il valore 1, ossia il PID del processo init (il valore potrebbe differire in base al sistema operativo): ciΓ² puΓ² essere utile per capire se il vero padre di un processo figlio Γ¨ ancora vivo.

3.9 - Gestione dei processi zombie

Cosa capita a un figlio che termina prima che il padre abbia avuto modo di controllare il suo stato tramite la funzione wait()?

Il kernel garantisce che il padre possa avere la possibilitΓ  di eseguire una wait() in un momento successivo alla sua terminazione trasformando il figlio in uno zombie. Gran parte delle risorse gestite da un figlio sono rilasciate al sistema per essere assegnate ad altri processi e l’unica parte del processo che resta Γ¨ un’entry nella tabella dei processi che registra il PID del figlio, il suo codice di uscita, e le statistiche sull’utilizzo delle risorse. Inoltre, un processo zombie non puΓ² essere ucciso da un segnale, neppure SIGKILL. Questo assicura che il genitore possa sempre eventualmente eseguire una wait().

Quando il padre esegue una wait(), il kernel rimuove lo zombie, dal momento che l’ultima informazione sul figlio Γ¨ stata fornita all’interessato. Se il genitore termina senza fare la wait(), il processo init adotta il figlio ed esegue automaticamente una wait(), rimuovendo dal sistema il processo zombie.

Se un processo padre crea un processo figlio, ma fallisce la relativa wait(), un elemento relativo allo zombie sarΓ  mantenuto indefinitamente nella tabella dei processi del kernel. Se il numero degli zombie cresce eccessivamente, gli zombie possono riempire la tabella dei processi, e questo impedirebbe la creazione di altri processi.

PoichΓ© gli zombie non possono essere uccisi da un segnale, l’unico modo per rimuoverli dal sistema Γ¨ uccidere il loro padre (o attendere la sua terminazione). A quel momento gli zombie possono essere adottati dal processo init e automaticamente rimossi.

4 - Terminazione a cascata

La terminazione a cascata Γ¨ un meccanismo che si verifica in un sistema operativo quando un processo termina e i suoi processi figli vengono automaticamente terminati insieme ad esso. Questo comportamento Γ¨ spesso associato ai processi di tipo daemon o ai servizi che, quando vengono interrotti, devono anche interrompere i processi che hanno creato.

4.1 - Funzionamento della terminazione a cascata

Quando un processo padre termina, i processi figli possono essere influenzati in diversi modi a seconda della loro relazione con il processo padre e della gestione dei segnali. Alcuni punti chiave della terminazione a cascata sono:

  • Segnale di terminazione: quando un processo padre termina, puΓ² inviare segnali ai suoi processi figli. Se i processi figli non sono in grado di gestire il segnale di terminazione (come SIGTERM o SIGKILL), verranno terminati dal sistema operativo.
  • Processi orfani: se un processo padre termina, i suoi processi figli diventano orfani. In UNIX, i processi orfani vengono adottati dal processo init, che si occupa di gestire la loro terminazione. I processi orfani non vengono necessariamente terminati a meno che non vengano esplicitamente chiesti di farlo.
  • Comportamento di wait(): quando un processo padre utilizza la funzione wait(), attende la terminazione di uno dei suoi processi figli. Se il padre termina prima dei figli, il sistema operativo puΓ² terminare i figli se la relazione di dipendenza Γ¨ tale che non ha senso che continuino a vivere.

4.2 - Esempio di terminazione a cascata in linguaggio C

Ecco un esempio in linguaggio C, in cui un processo padre crea un processo figlio e poi termina. Se il processo padre termina, il comportamento dei processi figli puΓ² essere osservato.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
 
int main() {
    pid_t pid = fork();
 
    if (pid < 0) {
        perror("fork failed");
        exit(EXIT_FAILURE);
    } else if (pid == 0) {
        // Questo Γ¨ il processo figlio
        while (1) {
            printf("Processo figlio in esecuzione (PID: %d).\n", getpid());
            sleep(1);
        }
    } else {
        // Questo Γ¨ il processo padre
        printf("Processo padre (PID: %d) in attesa di 5 secondi prima di terminare.\n", getpid());
        sleep(5);
        printf("Processo padre sta per terminare.\n");
        exit(0); // Termina il processo padre
    }
 
    return 0;
}

Durante l’esecuzione di questo programma, si puΓ² notare che il processo figlio continua a stampare i messaggi anche dopo la terminazione del processo padre, poichΓ© diventa un processo orfano e viene adottato dal processo init. Se invece il processo padre inviasse un segnale di terminazione, come SIGKILL o SIGTERM, anche il processo figlio verrebbe terminato, mostrando un comportamento di terminazione a cascata.