Affinché sia possibile la cooperazione tra processi, è necessaria la presenza di primitive di comunicazione.
I segnali forniscono uno strumento primitivo di comunicazione.
Le primitive per Inter Process Communication (o IPC), consentono la comunicazione di messaggi arbitrari tra processi distinti.
In passato esistevano diverse primitive per IPC su sistemi Unix diversi. In molti casi, vista la stretta relazione tra kernel e primitive IPC, la stessa primitiva aveva comportamenti diversi su sistemi diversi, consentivano raramente la portabilità del codice.
Fortunatamente, l’opera di standardizzazione ha reso la situazione molto più agevole, rendendo uniforme interfacce e comportamenti delle singole primitive.
Abbiamo visto che i socket consentono la comunicazione di messaggi arbitrari tra processi che possono essere in esecuzione sullo stesso calcolatore o, persino, su su calcolatori distinti.
In questa lezione vedremo altri due strumenti di comunicazione tra processi in esecuzione sullo stesso calcolatore, pipe e fifo.
Le pipe (tubi) costituiscono, storicamente, la prima primitiva per IPC fornita dai sistemi Unix.
Le pipe hanno due grosse limitazioni:
Nonostante queste limitazioni, le pipe half-duplex sono uno degli strumenti per IPC più utilizzati nei sistemi Unix.
Ad esempio, per redirigere l’output di un processo sull’input del processo che lo segue in una pipeline, la shell crea una pipe ed esegue le opportune redirezioni degli standard output/input.
Per creare una pipe è possibile utilizzare la seguente system call:
int pipe(int fd[2]);
Riceve come parametro un array di due interi e restituisce 0 in caso di successo, -1 altrimenti.
Crea una pipe ed assegna due file descriptor ai due end-point della pipe: uno per la lettura e uno per la scrittura.
Per cancellare una pipe, è sufficiente chiudere i descrittori con la system call close.
Consideriamo il frammento di codice:
int fd[2];
if (pipe(fd)<0) perror("Pipe"), exit(-1);
dopo la sua esecuzione:
La fstat su uno dei due file descriptor ritorna il tipo di file FIFO.
Una pipe in un solo processo è praticamente inutile.
Tipicamente, un processo crea una pipe ed esegue la fork. Il processo figlio eredita dal padre tutti I file aperti ed, in particolare, I riferimenti ai file descriptor della pipe. Viene così creato un canale di comunicazione tra padre e figlio.
La figura in alto riporta graficamente lo stato della memoria dopo l’esecuzione della fork.
A questo punto, è necessario stabilire quale sia la direzione delle informazioni.
In caso la pipe debba essere utilizzata per trasferire informazioni dal figlio al padre:
La figura in basso riporta lo stato delle connessioni per il caso appena illustrato.
Esempio di creazione di una pipe: Mostra codice, per l'invio di messaggi dal processo figlio al processo padre.
Da rimarcare la necessità di creare la pipe prima dell'esecuzione della fork.
Il processo padre chiude il file descriptor associato all'end-point in scrittura. Ed esegue una lettura dalla pipe.
Il processo figlio chiude l'end-point in lettura e scrive un messaggio sulla pipe.
Sebbene sia possibile creare pipe "condivise" tra più processo scrittori e/o più processi lettori, tipicamente questi strumenti di comunicazione vengono utilizzati esattamente da uno scrittore ed un lettore.
Una pipe si dice broken (o rotta) quando tutti i processi che la condividono hanno chiuso il file descriptor in scrittura (risp., in lettura). In altri termini, una pipe è broken quando non esistono più processi che possono scrivere (o leggere dalla pipe).
Le operazioni di lettura e scrittura su una pipe seguono le seguenti regole:
Uno dei problemi legati all’esecuzione di programmi implementati da terzi è l’impossibilità di modificare il codice del programma prima di eseguirlo per indicare una sorgente alternativa di input od output. Ad esempio:
Una delle possibilità offerte dalle pipe consiste proprio nella possibilità di redirezione dell’input e dell’output come segue:
Il seguente programma: Mostra codice, è un semplice esempio di redirezione.
Il padre crea la pipe e crea un processo figlio.
Il figlio, chiude l'endpoint in lettura della pipe e redirige lo standard output sull'end-point in scrittura della pipe.
A questo punto esegue il comando "ls" attraverso l'invocazione della execl.
Si rammenta che le system call della famiglia exec ritornano solo in caso di errore.
Il processo padre, legge dalla pipe e scrive I messaggi ricevuti sullo standard error.
In breve, questo programma implementa il comando "ls" in cui lo standard output è rediretto sullo standard error.
Le funzioni che invocano i comandi ls e sort: Mostra codice
Un esempio in cui la pipe è utilizzata come canale di comunicazione tra I due figli di un stesso processo.
In particolare, questo programma esegue la pipeline "ls |sort", in cui ogni comando è eseguito da uno dei figli del processo padre.
Il processo padre: Mostra codice, crea la pipe, crea entrambi I figli, chiude entrambi gli end-point della pipe.
Importante: Le funzioni figliols e figliosort NON ritornano MAI. Se la exec ha successo, il codice del programma viene sostituito dal codice dell'eseguibile indicato dalla exec, altrimenti viene eseguita la system call "exit(-1)".
Le funzioni che invocano i comandi ls e sort: Mostra codice
Il corpo del processo padre: Mostra codice
I riferimenti all'end-point per la scrittura sulla pipe sono 3: uno nel padre, uno nel primo figlio e uno nel secondo figlio.
D'altro canto, il comando sort "esegue" l'ordinamento una volta che ha letto dallo standard input l'end-of-file.
Da quanto detto in precedenza, la lettura da una pipe ritorna "end-of-file" solo quando tutti I processi che condividono la pipe hanno chiuso il loro end-point in scrittura.
Affinchè il programma funzioni correttamente, è necessario, quindi, chiudere tutti I riferimenti non utilizzati all'end-point in scrittura della pipe per evitare che il comando sort si blocchi in attesa di input.
Si noti che:
Questa condizione di "attesa" imposta dalla semantica delle pipe, garantisce, infine, che sort ordinerà l'intero output di ls, dato che non inizierà l'ordinamento prima della terminazione del processo "ls".
Una delle restrizioni legate all’utilizzo delle pipe è che condizione necessaria affinché due processi possano utilizzare questo strumento è avere un antenato comune che crea la pipe.
Le FIFO, anche note come named pipe, consentono anche a processi tra cui non esiste una relazione, di comunicare utilizzando uno strumento del tutto simile alle pipe.
La creazione di una FIFO avviene utilizzando la seguente system call:
int mkfifo(const char *pathname, mode_t mode);
La system call mkfifo è molto simile alla creat. Difatti il primo parametro identifica il pathname della fifo mentre il secondo definisce le protezioni su di essa.
Questa system call crea un file speciale sul file system, identificato dal pathname, il cui “tipo fi file” definito nel campo st_mode della struttura stat è, appunto, FIFO.
Come nel caso delle pipe, una FIFO è broken quando non esistono processi per cui la fifo è aperta in lettura (resp., scrittura).
A differenza delle pipe, le FIFO sono inizialmente broken. Difatti, quando un processo crea una pipe, entrambi gli end-point sono “automaticamente” aperti. Ciò non è vero per le FIFO in cui i processi lettori/scrittori non sono tra loro relati. In questo caso un processo potrebbe aprire una FIFO in lettura prima che un altro processo l’abbia aperta in scrittura.
Per default, quindi, la open su una FIFO può bloccare il processo che la esegue. È possibile modificare questo comportamento indicando il flag O_NONBLOCK alla open. In questo caso è necessario tenere conto del valore di ritorno della open.
Come nel caso delle pipe:
Le FIFO possono essere utilizzate in architetture client-server per consentire a più client (scrittori) di inviare richieste all’unico server (lettore).
In questo caso il server crea una FIFO, detta “well-known”, nota a tutti I client. Ogni client crea una propria FIFO specifica utilizzata dal server per l’invio delle risposte al client.
In questo caso, per evitare che il server si blocchi in attesa che un lettore apra la FIFO in scrittura, il server può:
Un processo può essere visto come l’esecuzione di una sequenza di istruzioni. Il processo esegue “una sola cosa per volta”. Questa strategia di esecuzione:
Molti sistemi operativi moderni consentono la possibilità di eseguire, all’interno di uno stesso processo, più percorsi di esecuzione. Il “processo”, può eseguire più task contemporaneamente avendo accesso agli stessi dati.
Ogni percorso di esecuzione è noto come thread ed un processo “classico” può essere visto come una applicazione “multi-thread con un unico thread di controllo”.
La programmazione multi-thread consente di:
I thread vengono eseguiti nel contesto del processo e la terminazione del processo implica la terminazione di tutti I suoi thread di controllo.
Nei sistemi Unix ad ogni processo sono associate una serie di informazioni di controllo, tra cui ricordiamo:
Ogni thread possiede i propri:
“In breve” possiamo affermare che:
L’interfaccia che utilizzeremo per I thread è nota come pthread o POSIX thread.
Visto che i thread condividono la memoria, non è possibile utilizzare una variabile globale (come errno) per i codici d’errore. Le funzioni pthread restituiscono direttamente un codice d’errore.
Ad ogni processo il sistema operativo assegna un identificativo, il pid, univoco all’interno del sistema.
Allo stesso modo, ad ogni thread viene assegnato un identificativo, il thread_id o tid. A differenza del pid, però, il tid è “univoco” all’interno dell’applicazione.
Un tid è rappresentato dal tipo di dato pthread_t che è dipendente dallo sistema operativo.
La libreria pthread definisce le seguenti funzioni:
pthread_t pthread_self(void);
Ritorna il thread id del thread che la esegue
Essendo la struttura dati che rappresenta un tid system-dependent NON esiste un’unica funzione per verificare se due thread id sono identici.
Lo standard POSIX definisce la seguente interfaccia:
int pthread_equal(pthread_t t1, pthread_t t2);
Questa funzione ritorna un valore diverso da zero se i tid passati come argomento sono uguali, zero altrimenti.
Come abbiamo accennato, una applicazione può essere vista come un processo che esegue un unico thread di controllo.
La creazione di un nuovo thread all’interno dell’applicazione corrente viene effettuata utilizzando la seguente funzione:
int pthread_create(pthread_t *tid, const pthread_attr_t *attributes, void *(* start)(void *), void *argument);
Dove:
La funzione pthread_create ritorna 0 in caso di successo od un codice d’errore altrimenti.
In caso sia necessario passare al thread più parametri, è possibile creare una struttura dati e passare al thread il puntatore ad essa.
Quando il nuovo thread viene creato, eredita dal thread creatore la maschera dei segnali. Il nuovo thread, non eredita i segnali pendenti.
Un thread “vive” nel contesto del processo. In un qualsiasi momento, l’invocazione della system call exit() provoca la terminazione dell’intero processo, I.e., di tutti I thread dell’applicazione.
Un thread può terminare in tre modi:
La firma di pthread_exit è la seguente: void pthread_exit(void *ret);
Termina il thread corrente, con valore di uscita ret.
Altri thread possono leggere il valore di uscita usando pthread_join.
È necessario che i dati puntati da ret sopravvivano alla terminazione del thread!
ret non può puntare alle variabili locali del thread.
È possibile utilizzare variabili globali o allocate dinamicamente.
La firma di pthread_cancel è la seguente: int pthread_cancel(pthread_t tid);
Richiede la terminazione del thread specificato da tid, non aspetta la terminazione, restituisce 0 se OK, un codice d’errore altrimenti il valore di uscita di un thread cancellato è dato dalla costante THREAD_CANCELED.
Un thread può attendere la terminazione di un altro thread utilizzando la seguente funzione:
int pthread_join(pthread_t tid, void **ret);
La pthread_join attende che il thread specificato da tid termini
Restituisce 0 la join ha avuto successo, un codice d’errore altrimenti;
ret è un parametro di ritorno usato per restituire il valore d’uscita dell’altro thread..Attenzione al doppio puntatore!
È possibile passare null come secondo parametro della join nel caso in cui non si è interessati all’exit value del thread.
Le prossime slide illustrano un esempio di utilizzo di applicazioni multi-thread.
La struttura foo contiene due variabili intere.
Viene dichiarata una variabile globale test di tipi myfoo.
La routine di servizion stampa riceve come parametri una stringa ed un puntatore ad una variabile di tipo myfoo e visualizza la stringa, il tid del thread corrente ed I valori delle variabili intere compoenenti la struttura.
La funzione fun1 riceve come parametro un puntatore void *:
La struttura myfoo e la routine stampa: Mostra codice
La funzione fun1: Mostra codice
La funzione fun2: Mostra codice riceve come parametro un puntatore void *:
La funzione fun3: Mostra codice riceve come parametro un puntatore void *:
Il passaggio dei parametri all’atto della creazione di un nuovo thread è eseguito per riferimento. Quindi il thread creatore ed il nuovo thread condividono l’area di memoria contenente i parametri.
Ne consegue che una modifica fatta ai “parametri” da parte del thread creatore si ripercuote immediatamente sul valore dei parametri nel thread creato.
Inoltre, a meno di sincronizzazione esplicita, NON è possibile sapere quale sarà lo scheduling di esecuzione dei due thread.
È necessario, quindi, evitare modifiche dei parametri da parte del thread creatore.
Esempio di ERRORE classico: Mostra codice.
Tutti i thread aspettano 1 secondo prima di visualizzare il proprio tid ed il valore del parametro.
Il puntatore alla variabile i, indice del ciclo, viene passata come parametro per i thread. Tutti I thread avranno come parametro "la stessa area di memoria".
L'incremento di questa variabile è, quindi, immediatamente visibile a tutti I thread. Al termine del primo ciclo, i assume valore 10 e, nell'esempio, tutti I thread sono ancora in sleep. Per cui, all'atto del loro risveglio, visualizzano lo stesso valore.
L'output del programma: Mostra codice.
Nota: La presenza dell’istruzione “sleep(1)” in fun() è puramente didattica ed ha lo scopo di “simulare” quello che potrebbe succedere in una condizione operativa normale.
NON SI ASSUMA MAI, che “in un tempo x è possibile creare y thread”.
L’affermazione può sembrare sicuramente “ragionevole”.
Ma, i tempi di effettiva creazione dei thread dipendono dal carico macchina e dallo scheduling. Esistono affermazioni “ragionevoli” che possono risultare false in alcuni contesti.
Ad esempio, su un processore particolarmente carico, la creazione di 10 thread potrebbe richiedere più di 1 secondo!
Visto che il problema nasce dal fatto che il parametro passato ai diversi thread è memorizzato nella stessa area di memoria, la soluzione (ovvia) è eliminare questa coincidenza.
Di seguito una prima possibilità: Mostra codice.
Il master thread utilizza un array (di interi in questo caso) dove, per ogni thread, esiste una locazione che memorizza il valore del parametro associato.
Chiaramente queste soluzione è percorribile nel momento in cui è noto a priori il numero di thread da creare.
L'output del programma: Mostra codice.
Una seconda possibilità è: Mostra codice.
Il programma utilizza un unico puntatore allocato dinamicamente.
All'atto della crazione di un nuovo thread, viene allocata ("nuova") memoria per contenere il parametro da passare al nuovo thread.
Ora sebbene il puntatore al parametro da passare al thread sia unico, l'area di memoria da esso indirizzata cambia ad ogni invocazione della malloc, garantendo, quindi, che diversi thread riceveranno parametri memorizzati in aree di memoria diverse.
L'output del programma: Mostra codice
1. Introduzione ai sistemi Unix
2. Principi di programmazione Shell
3. Esercitazioni su shell scripting - parte prima
5. Esercitazioni su shell scripting - parte seconda
6. Espressioni Regolari ed Introduzione ad AWK
7. Esercitazioni su espressioni regolari ed awk scripting
9. Esercitazioni su awk scripting - parte seconda
10. Programmazione in linguaggio C: Input/Output di basso livello
11. Esercitazioni su I/O di basso livello
12. Interazione con file di sistema e variabili d'ambiente
13. Esercitazioni sulla gestione dei file di sistema e le variabili...
14. System call per la gestione di file e directory
15. Esercitazioni su gestione file e directory
16. La programmazione multi-processo
17. Esercitazioni su programmazione multi-processo
18. I Segnali
20. I Socket
W.R. Stevens – S.A. Rago, Advanced Programming in the Unix Environment
Capitolo 11 (11.1/11.5), Capitolo 15 (15.1, 15.2, 15.5).