APPUNTI DI SISTEMI OPERATIVI 1 PER LE ESERCITAZIONI IN LABORATORIO
CHIAMATE DI SISTEMA - PARTE 2B 2C
****************************************
SEGNALI
****************************************
I segnali sono il meccanismo con cui il sistema operativo comunica
ad un processo il verificarsi di un evento, tipicamente inatteso, che
può essere:
- asincrono, cioè scorrelato con l'istruzione che il
processo stava eseguendo, ad esempio:
- l'utente ha battuto un carattere (o combinazione di caratteri;
tipicamente CTRL-C o DELETE) "di interruzione"
- è suonata una "sveglia" che il processo ha programmato
in precedenza
- un altro processo (per esempio uno che esegue il comando
"kill") ha eseguito una chiamata di sistema "kill" per inviare un
segnale a quel processo
- sincrono, cioè correlato con l'istruzione che il processo
stava eseguendo, ad esempio: il processo ha tentato di far riferimento
ad un indirizzo di memoria per lui non valido
Ad ognuno di questi tipi di eventi è associato un numero di
segnale, "nascosto" in una costante simbolica definita in un opportuno
file header signal.h da includere nei programmi che utilizzano i
segnali.
Nel primo, secondo e quarto esempio di cui sopra le costanti si
chiamano: SIGINT, SIGALRM, SIGSEGV, mentre con la chiamata di sistema e
il comando kill si può inviare qualunque segnale. Tuttavia
è bene evitare di inviare segnali come SIGSEGV che hanno un
preciso significato, ha invece senso inviare i segnali il cui scopo
è terminare il processo, come SIGTERM e SIGKILL, o i segnali il
cui significato è "definito dall'utente" SIGUSR1 e SIGUSR2,
sebbene questi ultimi possano essere utilizzati solo
per meccanismi molto rudimentali di sincronizzazione tra processi.
Successivamente ai segnali, sono state introdotte in Unix altre
primitive di sincronizzazione descritte più avanti nel corso ed
è bene utilizzare quelle.
Per ogni segnale esiste un comportamento di default associato dal
sistema operativo che può essere:
- il processo che ha ricevuto il segnale termina;
- idem, con in più il salvataggio di un file di nome "core"
che contiene l'immagine di memoria del processo al momento della
terminazione, e può essere utilizzato per capire il motivo del
segnale (in particolare nel caso di SIGSEGV);
- il processo ignora il segnale
Il man ("man -s 7 signal" su Linux) descrive l''elenco
completo dei tipi di segnali con il loro identificatore numerico, il
corrispondente nome simbolico, il comportamento associato per default
al segnale, e il significato del segnale (tipicamente, evento in
conseguenza del quale viene inviato).
Un processo può modificare il proprio comportamento associato
alla ricezione del segnale, tranne per il segnale SIGKILL che esiste
proprio per assicurare un modo di terminare un processo contro cui il
processo
stesso non può fare nulla.
********************************************************************
Una interfaccia semplice, ma non robusta, di gestione
dei segnali, (la documentazione si riferisce a questo caso con il
termine unreliable signals - segnali inaffidabili) che
è rimasta parte dello standard dell'ANSI C ma non fa parte dello
standard POSIX dei sistemi Unix.
In questo caso il comportamento associato ad un segnale può
essere modificato con la funzione "signal"
Una seconda interfaccia per i segnali (reliable signals
- segnali affidabili - nella documentazione) è definita nello
standard POSIX e prevede la seguente chiamata di sistema:
SIGACTION:
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact)
La "struct sigaction" passata come secondo parametro include i seguenti
campi:
void (*sa_handler)(int); /* SIG_DFL, SIG_IGN, or pointer to a function */
sigset_t sa_mask; /* Additional signals to be blocked during execution of
signal-catching function */
int sa_flags; /* Special flags to affect behavior of signal */
Questa funzione setta il comportamento associato al segnale "signum" a
quello specificato in *act (se act non è NULL) e restituisce in
*oldact (se oldact non è NULL) il comportamento precedente.
Nel caso in cui
non ci interessi memorizzare il comportamento precedente, si può chiamare:
act.sa_handler=f; /* oppure SIG_IGN oppure SIG_DFL */
sigemptyset(&act.sa_mask);
sa.sa_flags = 0;
sigaction(s,&act,NULL)
In base allo standard (e quindi su tutte le implementazioni conformi
allo standard), per default, cioè se non lo si chiede con
opportune opzioni, il comportamento NON viene rimpiazzato con quello
per default quando il
segnale viene ricevuto (come invece avviene per la funzione "signal").
La chiamata di sigaction associa alla ricezione del segnale ("signum" nel prototipo) il comportamento specificato dal campo
sa_handler della struttura puntata dal secondo argomento; tale campo
può
essere:
- SIG_IGN per indicare che il segnale è da ignorare;
- SIG_DFL: per ottenere il comportamento per default associato
al segnale (utile ovviamente in caso si voglia ripristinare il
comportamento per default modificato in precedenza);
- Un puntatore ad una funzione con argomento intero e che
restituisce il tipo generico "void". In questo caso si dice che il
processo "cattura" il segnale: quando il segnale viene "consegnato" al
processo, viene chiamata la funzione, passandogli come argomento il
numero del segnale ricevuto (avere questo argomento può essere
utile per associare una stessa funzione a più segnali, ma fare
in modo che la funzione sappia quale segnale in particolare è
stato consegnato). Al termine dell'esecuzione della funzione (se ci si
arriva, cioè se questa non chiama exit) il processo riprende ad
eseguire dal punto in cui era arrivato al momento della consegna del
segnale.
E' possibile (vedere il man, i testi etc) utilizzare come "handler"
funzioni con un altro prototipo (usando un altro campo della "struct
sigaction").
Catturare i segnali può servire a vari scopi, ad
esempio:
- Eseguire alcune "ultime volontà" prima di terminare, per
fare in modo ad esempio che quando si chiede di terminare il processo,
prima
di terminare davvero, esso rimuova ad esempio dei file temporanei che
aveva aperto per svolgere il suo lavoro. In questo caso nella funzione
associata al segnale si chiamerà "exit" alla fine
- Andare a leggere dei file di configurazione. Questo meccanismo
è usato ad esempio per dei processi che svolgono sulla macchina
un servizio, in base a informazioni di configurazione contenute in un
file, che viene letto
normalmente quando viene fatto partire il servizio, ad esempio
all'avvio del
sistema. Se vogliamo cambiare queste informazioni, ma non far ripartire
il
servizio da zero, in particolare non far ripartire tutto il sistema
operativo, possiamo associare, nel codice del processo che svolge il
servizio, alla ricezione
di un particolare segnale la rilettura delle informazioni di
configurazione.
Il seguente esempio illustra come modificare il comportamento associato
ai segnali, prevedendo l'esecuzione di una funzione:
::::::::::::::
sig.c
::::::::::::::
#include <stdio.h>
#include <signal.h>
void f_intr(int sig)
{
printf("ricevuto il segnale numero : %d\n",sig);
}
int main(int argc, char *argv[])
{
int m,n;
struct sigaction sa;
sa.sa_handler = f_intr;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGINT,&sa,NULL);
for (m=0;m<100;m++)
{
for (n=0;n<200000000;n++);
printf("working\n");
}
return 0;
}
::::::::::::::
Dando il carattere di interruzione (CTRL-c) viene chiamata f_intr.
I segnali possono essere inviati anche con la s.c. kill e il comando
kill (vedere il man corrispondente).
La sleep utilizzata in precedenza è tipicamente realizzata
mediante i segnali (vedere man -s 3 sleep, man -s 2 alarm, e man -s 2
pause): alarm(n) "prenota" un segnale SIGALRM che verrà inviato
al processo corrente dopo n secondi; pause() sospende il processo fino
all'arrivo di un (qualunque) segnale, quindi se nel frattempo arriva
qualche altro segnale, il processo viene risvegliato prima degli n
secondi.
ESERCIZIO 2.8: eseguire
l'eseguibile "sig" di "sig.c" in una finestra; da un'altra, individuare
con "ps -a" il pid del processo che esegue "sig" ed inviargli con il
comando kill il segnale di interruzione (SIGINT), quello di
terminazione (SIGTERM) e (in una diversa esecuzione) quello di accesso
non valido alla memoria
(SIGSEGV).
ESERCIZIO 2.9: modificare il programma in modo da ignorare il segnale
di interruzione.
ESERCIZIO 2.10: verificare, integrando gli esempi precedenti che
utilizzano fork, exec e sigaction, se le disposizioni "ignorare il
segnale" ed "eseguire una funzione" vengono:
- "ereditate" da un processo figlio, qualora richieste dal processo
padre prima della fork;
- mantenute da un processo che effettua una system call exec.
Un'altra caratteristica utile dell'interfaccia detta "reliable signals"
è la possibilità per un processo di chiedere che la
consegna di alcuni segnali inviati al processo venga bloccata fino a
quando il processo non dispone lo sblocco, o dispone di ignorare il
segnale (si veda Stevens & Rago, "Advanced Programming in the Unix
Environment").
****************************************
PIPES
****************************************
La system call:
int pipe(int fildes[2]);
crea una pipe, valorizzando fildes[0] con un file descriptor (indice
nella tabella dei files aperti) che può essere usato per leggere
dalla pipe, e fildes[1] con uno che può essere utilizzato per
scrivere.
L'esempio seguente qualcosa di simile a quanto avviene nell'interprete dei comandi
per eseguire:
ls -l | wc -l
in cui lo standard output del comando "ls -l" (elenco in formato lungo
dei file nella directory corrente) diventa lo standard input del
comando "wc" (conta le
righe di un file testo).
::::::::::::::
pipes.c
::::::::::::::
#include <stdio.h>
#include <unistd.h>
#include <wait.h>
#include <sys/types.h>
int main(int argc, char *argv[])
{
int pipefd[2];
pipe (pipefd);
if (fork() == (pid_t)0) {
/* primo figlio esegue "wc -l " (conta righe) prendendo
l'input da pipefd[0] */
dup2(pipefd[0],0);
close(pipefd[0]);
close(pipefd[1]);
execlp("wc","wc","-l",NULL);
perror("exec wc fallita");
}
else if (fork() == (pid_t)0) {
/* secondo figlio esegue "ls -l" mandando l'output
su pipefd[1] */
dup2(pipefd[1],1);
close(pipefd[0]);
close(pipefd[1]);
execlp("ls","ls","-l",NULL);
perror("exec ls fallita");
}
else
{ /* processo padre chiude entrambi gli estremi della pipe
e attende i figli */
close(pipefd[0]);
close(pipefd[1]);
wait(NULL);
wait(NULL);
}
return 0;
}
::::::::::::::
N.B. un motivo per cui ci sono le varie "close" è la buona norma di chiudere i file descriptor non utilizzati da un
processo, per non occupare inutilmente elementi della tabella dei file
aperti.
Inoltre, la chiusura della pipe in scrittura da parte dei processi che
non la usano per scrivere (padre e primo figlio in questo caso)
può essere essenziale per la terminazione del processo che legge
dalla pipe: infatti "wc" continua a leggere finché non trova
EOF (e non scrive finché non
legge l'ultima riga, dovendole contare).
L'EOF si ottiene su una pipe quando non ci sono più processi
che hanno la pipe aperta in scrittura (in questo caso, quando il
processo
che esegue "ls" termina).
ESERCIZIO 2.11: verificare che cosa accade se nel padre la
close(pipefd[1]) viene tolta oppure messa dopo le wait.
La creazione della pipe avviene a livello delle system calls; per
leggere e scrivere sulla pipe, si possono usare le chiamate di
sistema read e write, o le funzioni di libreria:
- se dopo aver aperto la pipe si usa la f. di libreria fdopen che
"trasforma" una apertura a livello di system calls in una apertura a
livello di f. di libreria, in cui viene allocata la struttura di tipo
FILE, contenente ad esempio il buffer utilizzato per risparmiare il
numero di chiamate di sistema
- se, come nell'esempio, il processo con opportune "dup" ha alcuni
dei files standard associati a una pipe. In questo caso si sfruttano le
strutture "FILE" della libreria già allocate per la gestione dei
3 file standard . Il processo può quindi chiamare printf, scanf,
getchar, putchar e queste daranno luogo (almeno in qualche caso, quando
non
sfruttano la bufferizzazione) a chiamate di read e write sui
descrittori
0 e 1 che possono essere stati associati alla pipe con le dup. In
particolare
se il processo fa exec di un programma; tale programma legge dal suo
standard
input e scrive sul suo standard output e funziona indipendentemente dal
fatto
che essi siano associati al terminale, a un file su disco o a una pipe.
I
due comandi ls e wc dell'esempio presumibilmente sono scritti
in
C utilizzando le funzioni di libreria per l'I/O.
Le pipes possono essere usate anche per comunicazioni "uno a molti",
"molti a uno", "molti a molti". Si ha un comportamento
nondeterministico del tipo "produttori e consumatori": se diversi
processi scrivono sulla stessa pipe, nel flusso di dati risultante
l'output dei diversi processi viene
intercalato a seconda dell'effettivo ordine delle chiamate di sistema
"write"
sulla pipe da parte dei diversi processi; analogamente, se diversi
processi
leggono dalla stessa pipe, il flusso di dati viene consumato in parte
da
un processo, in parte dall'altro etc. a seconda di quando occorrono
effettivamente
le chiamate di sistema "read" e di quanti dati sono presenti nella pipe
nel
momento in cui occorrono.