24 Jun 2023, 8:27 PM
31 Dec 2024, 12:47 PM

5.1 Kernel

Il kernel è l’elemento di base di un sistema Unix-like, ovvero il nucleo del sistema operativo. Il kernel è incaricato della gestione delle risorse essenziali: CPU, memoria, periferiche, ecc…
Ad ogni boot il sistema verifica lo stato delle periferiche, monta la prima partizione (root file system) in read-only (la prepara per renderla accessibile al sistema operativo) e carica il kernel in memoria. Il kernel lancia il primo programma (systemd, sostituto di init) che, a seconda della configurazione voluta (target), inizializza il sistema di conseguenza.
Il resto delle operazioni, tra cui l’interazione con l’utente, vengono gestite con i programmi eseguiti dal kernel.

Kernel e memoria virtuale

I programmi utilizzati dall’utente che vogliono accedere alle periferiche chiedono al kernel di farlo per loro.
L’interazione tra programmi ed il resto del sistema viene mascherata da alcune caratteristiche intrinseche ai processori, come la gestione hardware della memoria virtuale (attraverso la MMU, che è una classe di componenti hardware che gestisce le richieste di accesso alla memoria generate dalla CPU).
Ogni programma vede se stesso come unico possessore della CPU e non gli è dunque possibile disturbare l’azione degli altri programmi → stabilità dei sistemi Unix-like!

Privilegi

Nei sistemi Unix-like ci sono due livelli di privilegi:

La differenza principale tra i due spazi è che lo spazio utente è limitato e fornisce un ambiente protetto per l'esecuzione delle applicazioni, mentre lo spazio del kernel ha pieno accesso alle risorse hardware e alla memoria del sistema.

Livelli-Privilegi.png|500

5.2 System calls

Le interfacce con cui i programmi accedono all’hardware si chiamano system calls.
Letteralmente “chiamate al sistema” che il kernel esegue nel kernel space, restituiscono i risultati al programma chiamante nello user space.

SystemCalls.png|600

Le chiamate restituiscono “-1” in caso di errore e settano la variabile globale errno. Errori validi sono numeri positivi e seguono lo standard POSIX, il quale definisce degli alias.

Librerie di sistema

Utilizzando il comando di shell ldd su di un eseguibile si possono visualizzare le librerie condivise, ovvero file che contengono codice e dati che possono essere utilizzati da più programmi contemporaneamente. Quando un programma viene avviato, le librerie condivise di cui ha bisogno vengono caricate in memoria e collegate al programma durante l'esecuzione. Fra queste, vi sono tipicamente anche ld-linux.so, e libc.so.

Librerie-di-sistema.png|500

Vediamo ora diversi esempi di chiamate di sistema.

Get time: time() e ctime()

time_t time( time_t *second )
char * ctime( const time_t *timeSeconds )

#include <time.h> //time.c
#include <stdio.h>
int main()
{
	time_t theTime;
	time_t whatTime = time(&theTime); //seconds since 1/1/1970
	//Print date in Www Mmm dd hh:mm:ss yyyy
	printf("Current time = %s= %ld\n", ctime(&whatTime),theTime);

	return 0;
}

Working directory: chdir(), getcwd()

int chdir( const char *path );
char * getcwd( char *buf, size_t sizeBuf );

#include <unistd.h> //chdir.c
#include <stdio.h>
int main()
{
	char s[100];
	getcwd(s,100); // copy path in buffer
	printf("%s\n", s); //Print current working dir
	chdir(".."); //Change working dir
	printf("%s\n", getcwd(NULL,100)); // Allocates buffer
	
	return 0;
}

Operazioni con i file

int open(const char *pathname, int flags, mode_t mode);
int close(int fd);
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
off_t lseek(int fd, off_t offset, int whence);

FILE *fopen(const char *filename, const char *mode)
int fclose(FILE *stream)

Duplicazione file descriptors: dup(), dup2()

int dup(int oldfd);
int dup2(int oldfd, int newfd);

#include <unistd.h> 
#include <stdio.h>
#include <fcntl.h>
int main(void)
{
	char buf[51];
	int fd = open("file.txt",O_RDWR); //file exists
	int r = read(fd,buf,50); //Read 50 bytes from ‘fd’ in ‘buf’
	buf[r] = 0; printf("Content: %s\n",buf);
	int cpy = dup(fd); // Create copy of file descriptor
	dup2(cpy,22); // Copy cpy to descriptor 22 (close 22 if opened)
	lseek(cpy,0,SEEK_SET); // Move I/O on all 3 file descriptors!
	/*
		Writes the text "This is a fine" to the file associated with file descriptor 22. 
		Since a duplicate of the original file descriptor (cpy) was created earlier, 
		file descriptor 22 now points to the same file. The number 15 represents 
		the bytes to be written ("This is a fine\n" are 15 characters (15 bytes)).
	*/
	write(22,"This is a fine\n", 15); // Write starting from 0-pos
	close(cpy); //Close ONE file descriptor
}

Il primo descrittore di file fd viene creato quando il file input.txt viene aperto utilizzando la funzione open. Il secondo descrittore di file cpy viene creato utilizzando la funzione dup, che crea una copia del descrittore di file fd. Infine, il terzo descrittore di file 22 viene creato utilizzando la funzione dup2, che copia il descrittore di file cpy nel descrittore di file 22. Tutti e tre i descrittori di file fanno riferimento allo stesso file aperto e possono essere utilizzati per le operazioni di I/O su quel file.

La funzione dup crea una copia del descrittore di file specificato e restituisce il descrittore di file più basso disponibile per quel processo, mentre dup2 copia il descrittore di file specificato in un altro descrittore di file specificato. Se il descrittore di file di destinazione è già aperto, viene chiuso prima di essere sovrascritto.

File descriptors.png|600

Permessi: chmod(), chown()

int chown(const char *pathname, uid_t owner, gid_t group);
int fchown(int fd, uid_t owner, gid_t group)
int chmod(const char *pathname, mode_t mode)
int fchmod(int fd, mode_t mode)

#include <fcntl.h> //chown.c
#include <unistd.h>
#include <sys/stat.h>
void main()
{
	int fd = open("file",O_RDONLY);
	fchown(fd, 1000, 1000); // Change owner to user:group 1000:1000
	chmod("file",S_IRUSR|S_IRGRP|S_IROTH); // Cambia i permessi del file "file" in modo che il proprietario, il gruppo e gli altri utenti abbiano solo il permesso di lettura (r-- r-- r--).
}
$ cd .. ; touch file ; ls -la # ls -la elenca tutti i file e le directory presenti nella directory corrente, compresi quelli nascosti, in un formato di output lungo che mostra informazioni dettagliate su ciascun file e directory. (cd .. sembra messo inutilmente)
$ ./executable.o ; ls -la # da me c'è ancora il permesso di esecuzione

Il proprietario e il gruppo del file vengono cambiati in 1000:1000. In generale, il proprietario e il gruppo di un file possono essere cambiati per vari motivi, ad esempio per concedere o revocare l'accesso al file a determinati utenti o gruppi. Ad esempio, se un file deve essere accessibile solo da un determinato utente o gruppo, il proprietario e il gruppo del file possono essere cambiati in quell'utente o gruppo e i permessi del file possono essere impostati di conseguenza.

Programs execution: the exec family

La famiglia di funzioni exec è un insieme di funzioni che permettono di eseguire un nuovo programma all'interno del processo corrente. Quando viene chiamata una funzione exec, il programma corrente viene sostituito da un nuovo programma specificato nella chiamata alla funzione exec e quindi viene sostituita l'immagine del processo corrente, ovvero l'insieme delle aree di memoria e delle strutture dati associate al processo, con una nuova immagine. Ciò significa che le aree di memoria e le strutture dati associate al processo vengono aggiornate per contenere il codice, i dati, PC, registri, stack e le informazioni di stato del nuovo programma.
Il PID del processo e la sua file table non cambiano! La chiamata di sistema di base è execve(), ma vedremo tutti i suoi alias che differiscono solo per gli argomenti accettati, mantenendo lo stesso identico comportamento. Ogni alias è composto dalla parola chiave exec seguita dalle seguenti lettere:

NB: Ogni vettore di argomenti deve terminare con un elemento NULL!

int execv (const char *path, char *const argv[])
int execvp (const char *file, char *const argv[])
int execve (const char *path, char *const argv[], char *const envp[]);
int execvpe (const char *file, char *const argv[], char *const envp[])
int execl (const char *path, const char * arg0,…,argn,NULL)
int execlp (const char *file, const char * arg0,…,argn,NULL)
int execle (const char *path, const char * arg0,…,argn,NULL, char *const envp[])
int execlpe(const char *file, const char * arg0,…,argn,NULL, char *const envp[])

Esempio: execv()

Dato il seguente script in C chiamato "execv1.c"

//execv1.c
#include <unistd.h>
#include <stdio.h>
int main()
{
	char * argv[] = {"par1","par2",NULL}; //Il warning compare perche' sto cercando di assegnare "par1" e "par2", che sono stringhe costanti, ad un puntatore char *. Ma paccos, va bene cosi.
	execv("./execv2.out",argv); //Replace current process
	printf("This is execv1\n"); //Will never be printed

	return 0;
}

e il seguente script in C chiamato "execv2.c"

//execv2.c
#include <stdio.h>
int main(int argc, char ** argv)
{
	printf("This is execv2 with %s and %s\n",argv[0],argv[1]);
    return 0;
}

compiliamoli con i seguenti comandi

$ gcc execv1.c -o execv1.out
$ gcc execv2.c -o execv2.out

ed eseguiamo ./execv1.out.
L'output sarà il seguente:

This is execv2 with par1 and par2

Questo perchè, quando eseguiamo ./execv1.out, il programma execv1.out chiamerà la funzione execv per sostituire il processo corrente con il nuovo processo specificato dal primo argomento, in questo caso ./execv2.out. Quindi, verrà eseguito il programma execv2.out e verrà visualizzato l'output sopra riportato. Il messaggio "This is execv1\n" nel primo script non viene mai raggiunto ed eseguito, poiché il processo è stato sostituito dalla chiamata execv.

Esempio: execle()

Dato il seguente script in C chiamato "execv1.c"

//execle1.c
#include <unistd.h>
#include <stdio.h>
int main()
{
	char * env[] = {"CIAO=hello world",NULL}; //Crea un array di puntatori a caratteri chiamato env che contiene una variabile d'ambiente chiamata CIAO con il valore "hello world".
	execle("./execle2.out","par1","par2",NULL,env); //Sostituisce il processo corrente con il nuovo processo specificato dal primo argomento, ovvero ./execle2.out. Gli argomenti "par1" e "par2" vengono passati al nuovo processo e l'array env viene utilizzato per impostare le variabili d'ambiente del nuovo processo.
	printf("This is execle1\n"); //Will never be printed
	return 0;
}

e il seguente script in C chiamato "execv2.c"

//execle2.c
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char ** argv)
{
    printf("This is execle2 with par: %s and %s. CIAO = %s\n",argv[0],argv[1],getenv("CIAO")); //Utilizza getenv per recuperare il valore della variabile d'ambiente CIAO e lo visualizza insieme agli argomenti passati al programma.
    
    return 0;
}

compiliamoli con i seguenti comandi

$ gcc execv1.c -o execv1.out
$ gcc execv2.c -o execv2.out

ed eseguiamo ./execv1.out.
L'output sarà il seguente:

This is execle2 with par: par1 and par2. CIAO = hello world

La differenza principale tra execv e execle è che execle accetta un numero variabile di argomenti, mentre execv accetta solo due argomenti (percorso del nuovo processo e un array di puntatori a caratteri che rappresenta gli argomenti da passare al nuovo processo). Quando non si ha bisogno di modificare le variabili d'ambiente si può utilizzare execv che permetterà al nuovo processo di ereditare le variabili d'ambiente del processo corrente (per accedere alle variabili d'ambiente ereditate si può utilizzare la funzione getenv). Altrimenti si può utilizzare execle per impostare specifiche variabili d'ambiente.

Nella funzione execle("./execle2.out","par1","par2",NULL,env); le variabili d'ambiente sono specificate dall'ultimo argomento, env. env è un array di puntatori a caratteri che rappresenta le variabili d'ambiente da impostare per il nuovo processo. Ogni elemento dell'array env rappresenta una variabile d'ambiente nel formato NOME=VALORE, dove NOME è il nome della variabile d'ambiente e VALORE è il valore da assegnare alla variabile.

Nel codice precedente, l'array env contiene solo una variabile d'ambiente chiamata CIAO con il valore "hello world". Quando viene chiamata la funzione execle, questa variabile d'ambiente verrà impostata per il nuovo processo.

Esempio: dup2/exec

Dato il seguente script in C chiamato "time.c"

//time.c
#include <time.h>
#include <stdio.h>
int main()
{
	time_t theTime;
	time_t whatTime = time(&theTime); //seconds since 1/1/1970
	//Print date in Www Mmm dd hh:mm:ss yyyy
	printf("Current time = %s= %ld\n", ctime(&whatTime),theTime);

	return 0;
}

e dato il seguente script in C chiamato "execvpDup.c"

//execvpDup.c
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
int main() 
{
	int outfile = open("output.txt", O_RDWR | O_CREAT, S_IRUSR | S_IWUSR);
	dup2(outfile, 1); // copia il file descriptor in outfile in 1, che è l'stdout
	char *argv[]={"./time.out",NULL}; // Viene utilizzato il programma ./time.out
	execvp(argv[0],argv); // Il contenuto del file ouput.txt viene sovrascritto con l'output del programma ./time.out
	return 0;
}

compiliamoli con i seguenti comandi

$ gcc time.c -o time.out
$ gcc execvpDup.c -o execvpDup.out

ed eseguiamo ./execvpDup.out.
L'output, contenuto in "output.txt", sarà il seguente:

Current time = Sat May 27 11:09:33 2023
= 1685178573

Ma come mai avendo usato printf() l'output ce lo ritroviamo in output.txt?
In breve, quando utilizziamo la funzione dup2(), duplichiamo il file descriptor di "outfile" nel file descriptor 1, che corrisponde all'output standard (stdout). Questo significa che tutto ciò che verrà scritto su stdout verrà dirottato nel file "output.txt".

Chiamare la shell: system()

int system(const char * string);

Dato il seguente script in C

#include <stdlib.h>
#include <stdio.h>
#include <sys/wait.h> /* Il file di intestazione wait.h, che si trova nella directory sys, fornisce le definizioni per le funzioni di gestione dei processi figli e la loro terminazione. La macro WEXITSTATUS è definita in questo file di intestazione e viene utilizzata per restituire lo stato di uscita di un processo figlio terminato*/
void main()
{
	int outcome = system("echo ciao"); // Esegue nella shell il comando echo ciao, che stampa la stringa "ciao" sulla console. Il risultato di questo comando viene salvato nella variabile outcome e stampato utilizzando printf
	printf("Outcome = %d\n",outcome);
	outcome = system("if [[ $PWD < \"ciao\" ]]; then echo min; fi"); // Dato che stiamo eseguendo un comando bash con una shell
	printf("Outcome = %d\n",outcome); // Dato che if è un comando valido allora l'Outcome è valido (quindi 0), ma in realtà la restante parte del comando da errore perché la shell non supporta le [[ (perché è un comando bash quindi è come se non ci fossero), per cui è come se cercasse di inserire il contenuto del file ciao all'interno di $PWD, ma il file ciao non esiste.
	outcome = system("notExistingCommand"); // Stiamo eseguendo un comando che non esiste e quindi verrà generato un errore
	printf("Outcome = %d\n",WEXITSTATUS(outcome)); // Il valore di output però non è -1, perché non abbiamo avuto un problema con la system call perché difatti non è fallita ed invece ha avuto successo. L'outcome che ci risulta è quello della nostra chiamata e non quello della system call
	return 0;
}

L'output è il seguente:

ciao
Outcome = 0
sh: 1: cannot open ciao: No such file
Outcome = 0
sh: 1: notExistingCommand: not found
Outcome = 127

Analizziamo i 3 valori di outcome:

  1. La prima chiamata a system("echo ciao") esegue il comando "echo ciao" nella shell, che stampa la stringa "ciao" sulla console. Poiché il comando ha avuto successo, la funzione system() restituisce 0.
  2. La seconda chiamata a system("if [[ $PWD < \"ciao\" ]]; then echo min; fi") esegue un comando bash utilizzando la shell. Tuttavia, si verifica un errore perché la shell non supporta la sintassi "if [[ ... ]]". Tuttavia, poiché la chiamata a system() ha avuto successo nell'eseguire il comando, restituisce 0.
  3. La terza chiamata a system("notExistingCommand") cerca di eseguire un comando che non esiste. Poiché il comando non può essere trovato, la shell restituisce un errore "not found". In questo caso, la chiamata a system() restituisce il valore di uscita del comando (di solito grande) che però contiene al suo interno tante informazioni, tra le quali il codice di ritorno della nostra chiamata. Per interpretarlo dobbiamo utilizzare la macro WEXITSTATUS("il nostro output", outcome in questo caso). In questo caso stiamo restituendo l'exit status della chiamata e non quello della system call.

System call “fork”

Il forking è la “generazione” di nuovi processi (uno alla volta) partendo da uno esistente. La syscall principale per il forking è “fork()”.
Quando un processo attivo invoca questa syscall, il kernel lo “clona” modificando però alcune informazioni, in particolare quelle che riguardano la sua collocazione nella gerarchia complessiva dei processi.
Il processo che effettua la chiamata è definito “padre/genitore”, quello generato è definito “figlio”.

Identificativi dei processi

Ad ogni processo è associato un identificativo univoco per istante temporale, sono organizzati gerarchicamente (genitore-figlio) e suddivisi in insiemi principali (sessioni) e secondari (gruppi). Anche gli utenti hanno un loro identificativo e ad ogni processo ne sono abbinati due: quello reale e quello effettivo (di esecuzione).

Gerarchia: genitore-figlio

Fork.png|400

Fork: elementi clonati e elementi nuovi

Durante l'operazione di fork, il kernel copia gli elementi principali come il PC (Program Counter), i registri, i dati di processo (variabili), i descrittori di file e la tabella dei descrittori di file del processo padre nel processo figlio. Ciò che non viene copiato sono le descrizioni di file aperte sottostanti e i buffer di file. I processi padre e figlio condividono le stesse descrizioni di file aperte e i buffer di file, permettendogli di comunicare tra loro utilizzando queste risorse (ad esempio se il processo padre scrive dati in un file aperto prima della fork, il processo figlio li può leggere dallo stesso file dopo la fork). Tuttavia, alcune meta-informazioni del processo (definite così perché descrivono il processo e forniscono informazioni su di esso), come il pid (ID del processo) e il ppid (ID del processo padre), vengono aggiornate per riflettere il fatto che si tratta di un nuovo processo. Ciò significa che il processo figlio eredita una copia dello stato del processo padre, ma resta comunque un processo separato e indipendente che può eseguire le proprie istruzioni. Una volta che il processo figlio inizia ad eseguire le proprie istruzioni, il suo stato (come il suo PC e i suoi registri) saranno aggiornati indipendentemente dal processo padre.

D'altra parte, quando un processo esegue execve, sostituisce l'immagine del processo corrente con una nuova immagine del processo specificata dal programma passato come argomento a execve, ma a parte ciò lo stato del processo viene conservato. Ciò significa che il codice del processo corrente viene sostituito con il codice del nuovo programma e il processo inizia ad eseguire le istruzioni del nuovo programma. Questo significa che i descrittori di file aperti nella tabella dei descrittori di file del processo prima della chiamata a execve sono ancora presenti in quella tabella dopo la chiamata, quindi il nuovo codice del processo eredita l'accesso a queste risorse.

L’esecuzione procede per entrambi (quando saranno schedulati!) da PC+1, quindi entrambi i processi continueranno ad eseguire dallo stesso punto nel codice in cui è stata effettuata la chiamata di sistema fork (tipicamente l’istruzione seguente il fork o la valutazione dell’espressione in cui essa è utilizzata):

Prossimo step: printf Prossimo step: assegnamento ad f
fork();
printf(“\n”);
f=fork();
printf(“\n”);

getpid(), getppid()

pid_t getpid() : restituisce il PID del processo attivo
pid_t getppid() : restituisce il PID del processo genitore

#include <stdio.h> 
#include <unistd.h> 
#include <stdlib.h> //ppid.c
int main()
{
	printf("Subshell $ = "); // Scrive i dati in stdout
	fflush(stdout); // Forza l’output di tutti i dati presenti nel buffer di output standard (stdout)
	system("echo $"); // crea una subshell, esegue il comando echo $ all'interno della subshell e stampa il PID della subshell
	printf("PID: %d PPID: %d\n",getpid(),getppid()); // stampa il PID ed il PPID del processo corrente in cui il programma viene eseguito

	return 0;
}

Includendo <sys/types.h> e <sys/wait.h> si può dichiarare il tipo di dato pid_t, definito dal sistema operativo, per memorizzare un PID. Questo permette di gestire correttamente i PID su tutti i sistemi.
Le funzioni getpid() e getppid() sono incluse nella libreria <unistd.h>

fork: valore di ritorno

La funzione restituisce un valore che solitamente è catturato in una variabile (o usato comunque in un’espressione).
Come per tutte le syscall in generale, il valore è -1 in caso di errore (in questo caso non ci sarà nessun nuovo processo, ma solo quello che ha invocato la chiamata).
Se ha successo, entrambi i processi ricevono un valore di ritorno:

Esempio

#include <stdio.h> 
#include <unistd.h>
int main(void) 
{
	int result = fork();
	if(result == 0)
	{
		printf("I’m the child\n");
		return 0;
	}
	else
	{
		printf("I’m the parent\n");
	}
	return 0;
}

int isChild = !fork();// Sarà 1 (vero) nel processo figlio e 0 (falso) nel processo genitore
int isParent = fork()!=0// Sarò 0 (falso) nel processo figlio e 1 (vero) nel processo genitore

fork: relazione tra i processi

I processi genitore-figlio:

File Descriptors con fork

FD-Fork.png|400

fork: wait()

pid_t wait (int *status)

Attende il cambio di stato di un processo figlio (uno qualsiasi) restituendone il PID e salvando in status lo stato del figlio (se il puntatore non è NULL). Il cambio di stato avviene se il figlio viene terminato o la sua esecuzione interrotta/ripresa a seguito di un segnale. Se non esiste alcun figlio restituisce -1.
Nel nostro caso ci interessa principalmente la terminazione del figlio. Questa system call ci permette di bloccare il processo (anche sincronizzarlo) fino a quando il figlio non ha finito le sue operazioni.
while(wait(NULL)>0);questo comando aspetta tutti i figli!

Wait: interpretazione stato

Lo stato di ritorno è un numero che comprende più valori “composti” interpretabili con apposite macro, molte utilizzabili a mo’ di funzione (altre come valore) passando lo “stato” ricevuto come risposta come ad esempio:

Example

#include <stdio.h> 
#include <unistd.h>
#include <sys/wait.h>
int main(void) 
{
	int isChild = !fork(); // Crea un processo figlio
	if(isChild)
	{
		sleep(3); // Processo figlio dorme per 3 secondi
		return 5; // Restituisce il codice di uscita 5 per il processo
	}
	int childStatus;
	wait(&childStatus); // Il processo padre attende che il processo figlio termini
	printf("Children terminated? %d\nReturn code: %d\n", WIFEXITED(childStatus),WEXITSTATUS(childStatus)); // WIFEXITED = 1 se il processo è terminato normalmente. WEXITSTATUS stampa il codice di uscita (5 in questo caso)
	return 0;
}

fork: waitpid()

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

Consente un’attesa selettiva basata su dei parametri. pid specifica il processo da attendere e può essere:

options sono i seguenti parametri ORed:

Per utilizzare queste 3 costanti bisogna includere l'header <sys/wait.h>

wait(st) è l’equivalente di waitpid(-1, st, 0), perché avendo messo options a 0 non viene utilizzato.

Sia nella funzione wait() che nella funzione waitpid() il parametro status viene utilizzato per salvare lo stato del processo figlio quando termina e quindi non è necessario che abbia un valore perché verrà sovrascritto con lo stato del processo figlio quando termina.

Esempio fork multiplo

Ovviamente è possibile siano presenti più “fork” dentro un codice. Quante righe saranno generate in output dal seguente programma? 8.
Ad ogni fork, ogni processo crea un processo figlio ed in questo caso è 23.

#include <stdio.h> //fork1.c
#include <unistd.h>
int main() 
{
	fork(); fork(); fork();
	printf("hello\n");
	return 0;
}

Esempio fork&wait

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <time.h>
#include <sys/wait.h> //fork2.c
int main() 
{
	int fid=fork(), wid, st, r; // Generate child
	srand(time(NULL)); // Initialise random
	r=rand()%256; // Get random between 0 and 255
	if (fid==0) // If it is child
	{ 
		printf("Child... (%d)", r); // Il processo figlio stampa il messaggio che è il processo figlio ed il numero casuale che ha generato
		fflush(stdout); // Svuota il buffer di output per assicurarsi che i dati vengano scritti immediatamente sullo schermo (o su un file)
		sleep(3); // Pause execution for 3 seconds
		printf(" done!\n"); // Messaggio stampato subito dopo l'attesa di 3 secondi
		exit(r); // Termina restituendo il numero casuale come codice di uscita
	} else // If it is parent
	{ 
		printf("Parent...\n"); // Il processo padre stampa il messaggio che è il processo padre
		wid=wait(&st); // attende che termini un processo figlio qualsiasi (in questo caso è uno solo)
		printf("...child's id: %d==%d (st=%d)\n", fid, wid, WEXITSTATUS(st)); // Stampa l'ID del processo figlio, l'ID restituito dalla funzione wait() e il codice di uscita del processo figlio utilizzando la macro WEXITSTATUS
	}
}

I processi “zombie” e “orfani”

Normalmente quando un processo termina il suo stato di uscita viene “catturato” dal genitore: alla terminazione il sistema tiene traccia di questo insieme di informazioni (lo stato) fino a che il genitore le utilizza consumandole (con wait o waitpid). Se il genitore non cattura lo stato d’uscita, i suoi processi figli vengono definiti “zombie” (in realtà non ci sono più, ma esiste un riferimento in sospeso nel sistema).
Se un genitore termina prima del figlio, quest’ultimo viene definito “orfano” e viene “adottato” dal processo principale (tipicamente “systemd” con pid pari a 1). Il processo principale è responsabile di gestire i processi orfani e periodicamente esegue le funzioni wait() o waitpid()() per recuperare lo stato di uscita dei processi orfani e prevenire la creazione di processi zombie.
Un processo zombie che diventa anche orfano è poi gestito dal processo principale che lo adotta e che effettua periodicamente dei wait/waitpid appositamente.

Per ispezionare la lista di processi attivi usare il comando ‘ps’ con le seguenti opzioni:

ps a -H -e -f
UID        PID  PPID  C STIME TTY      STAT   TIME CMD
root         1     0  0 09:00 ?        Ss     0:01 /sbin/init
root         2     0  0 09:00 ?        S      0:00 [kthreadd]
root         3     2 99 Sep20 ?        R      -1-01 [ksoftirqd/0]
root         5     2  0 09:00 ?        S<     0:00 [kworker/0:0H]
root         7     2  0 09:00 ?        S      0:01 [rcu_sched]
root       123     1  0 09:01 ?        Ss     0:00 /usr/sbin/sshd
root       456   123 99 Sep20 pts/1    T      -1-01 top
user       789   456 99 Sep20 pts/2    Z      -1-01 [my_script.py] <defunct>

In questo esempio, il processo con PID 1 è il processo init, che è il primo processo avviato dal sistema operativo. Il processo con PID 123 è il demone sshd, che gestisce le connessioni SSH in entrata. La colonna STAT mostra lo stato del processo. Ad esempio, il processo con PID 1 ha lo stato Ss, il che significa che è un processo di sistema in attesa. Il processo con PID 5 ha lo stato S<, il che significa che è un processo in attesa con priorità inferiore. Il processo con PID 3 ha lo stato R, il che significa che è in esecuzione o pronto per l’esecuzione. Il processo con PID 456 ha lo stato T, il che significa che è stato fermato (ad esempio tramite il comando kill -STOP). Il processo con PID 789 ha lo stato Z, il che significa che è un processo zombie.

CONCLUSIONI

Tramite l’uso dei file-descriptors, di fork e della famiglia di istruzioni exec è possibile generare più sottoprocessi e “redirezionare” i loro canali di in/out/err.
Sfruttando anche wait e waitpid è possibile costruire un albero di processi che interagiscono tra loro (non avendo ancora a disposizione strumenti dedicati è possibile sfruttare il file-system - ad esempio con file temporanei - per condividere informazioni/dati).