24 Jun 2023, 8:32 PM
24 Jun 2023, 8:32 PM

8. Piping

Il piping connette l’output (stdout e stderr) di un comando all’input (stdin) di un altro comando, consentendo dunque la comunicazione tra i due. Esempio:

ls . | sort -R #stout -> stdin #Questo comando elenca i file nella directory corrente e li ordina in modo casuale. | instrada l'output standard del comando di sinistra al comando a destra.

ls nonExistingDir |& wc #stdout e stderr -> stdin #Tenta di elencare i contenuti di una directory inesistente e instrada sia l’output standard che l’errore standard al comando 'wc', che conta il numero di righe, parole e caratteri

cat /etc/passwd | wc | less #out -> in, out-> in #Visualizza il contenuto del file `/etc/passwd`, lo instrada al comando `wc` per contare il numero di righe, parole e caratteri e quindi instrada l’output al comando `less` per la paginazione

#I 3 comandi sono indipendenti l'uno dagli altri

I processi sono eseguiti in concorrenza utilizzando un buffer, il processo scrittore (left) scrive dati nel buffer e il processo lettore legge i dati dal buffer:

Esempio 1.

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

// output.out
#include <stdio.h>
#include <unistd.h>
int main()
{
	for (int i = 0; i < 3; i++)
	{
		sleep(2);
		fprintf(stdout,"Written in buffer"); //Ogni 2 secondi scrive la stringa "Written in buffer" nello standard output
		fflush(stdout);
	};
};

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

// input.out
#include <stdio.h>
#include <unistd.h>
int main() 
{
    char msg[50]; int n=3;
    while((n--)>0)
    {
        int c = read(0,msg,49); //Legge 49 caratteri dallo standard input
        if (c>0)
        {
            msg[c]=0;
            fprintf(stdout,"Read: '%s' (%d)\n",msg,c); //Scrive i caratteri, letti dallo standard input, nello stream di output standard
        };
    };
};

eseguiamoli nel seguente ordine:

$./output.out | ./input.out$ #L'output di output.out viene reindirizzato all'input di input.out, quindi la stringa "Written in buffer" verrà letta da input.out e stampata sul suo output standard insieme al numero di caratteri letti

L'output è il seguente:

Read: 'Written in bufferWritten in bufferWritten in buff' (49)
Read: 'er' (2)

Ricordiamo che la funzione fflush(stdout); serve per svuotare il buffer dello standard output e scrivere effettivamente la stringa nello standard output (istantaneamente, senza aspettare la fine dell'esecuzione del programma).

Pipe anonime

Le pipe anonime, come quelle usate su shell, ‘uniscono’ due processi aventi un antenato comune (oppure tra padre-figlio). Il collegamento è unidirezionale ed avviene utilizzando un buffer di dimensione finita.

Per interagire con il buffer (la pipe) si usano due file descriptors: uno per il lato in scrittura ed uno per il lato in lettura. Quando un processo scrive dati nel file descriptor di scrittura di una pipe anonima, il sistema operativo prende questi dati e li scrive nel buffer della pipe. Quando un processo vuole leggere dati dal file descriptor di lettura di una pipe anonima, richiede al sistema operativo di leggere un certo numero di byte dal file descriptor di lettura e il sistema operativo preleva questi dati dal buffer della pipe e li restituisce al processo. Il processo non interagisce direttamente con il buffer della pipe, ma utilizza i file descriptor per comunicare con il sistema operativo e richiedere la lettura o la scrittura di dati.
Visto che i processi figli ereditano i file descriptors, questo consente la comunicazione tra i processi.

PipeAnonime.png

Creazione pipe

La funzione pipe() viene utilizzata per creare una coppia di file descriptor, che costituiscono un canale di comunicazione unidirezionale tra due processi. Un file descriptor rappresenta un canale di comunicazione o un file aperto.
La firma della funzione pipe() è la seguente:

int pipe(int pipefd[2]);

La funzione prende come argomento un array di due interi pipefd[2], che viene utilizzato per restituire i file descriptor associati alla pipe. pipefd[0] rappresenta l'estremità di lettura della pipe, mentre pipefd[1] rappresenta l'estremità di scrittura della pipe.

Dopo aver chiamato la funzione pipe(), si ottiene una pipe con un lato per la lettura e un lato per la scrittura. I dati scritti in un lato della pipe possono essere letti dall'altro lato.

La pipe è unidirezionale, il che significa che i dati possono fluire solo in una direzione. Se si desidera una comunicazione bidirezionale tra due processi, è necessario creare due pipe.

Ecco un esempio di utilizzo della funzione:

#include <stdio.h> //pipe.c
#include <unistd.h>
#include <fcntl.h> //Senza questa libreria non compila
int main()
{
	int fd[2], cnt = 0;
	while(pipe(fd) == 0) //Crea una serie di pipe anonime usando 2 file descriptors. In caso di successo la funzione restituisce 0, fd[0] viene impostato al file descriptor di lettura della pipe e fd[1] viene impostato al file descriptor di scrittura della pipe
	{
		cnt++; //Tiene traccia del numero di pipe create con successo
		printf("%d,%d,",fd[0],fd[1]); //Stampa i valori del file descriptor di lettura e scrittura della pipe appena creata
	}
	printf("\n Opened %d pipes, then error\n",cnt); //Appena la funzione pipe() restituisce un valore diverso da 0, il che indica che si è verificato un errore nella creazione della pipe, il programma stampa il numero totale di pipe create con successo
	int op = open("/tmp/tmp.txt",O_CREAT|O_RDWR,S_IRUSR|S_IWUSR); //Apre il file /tmp/tmp.txt, il secondo argomento sono dei flag che specificano come il file deve essere aperto, il terzo argomento specifica i permessi da utilizzare nel caso in cui il file debba essere creato. O_CREAT indica che deve essere creato se non esiste, mentre O_RDWR indica che il file deve essere aperto in lettura/scrittura. S_IRUSR e S_IWUSR indicano che il proprietario del file ha il permesso di leggere e scrivere il file
	printf("File opened with fd %d\n",op); //Stampa il file descriptor restituito dalla funzione open
}

L'output è simile al seguente:

3,4,5,6,7,8,9,...,10238,
Opened 5118 pipes, then error
File opened with fd 10239

Questo codice crea una serie di pipe anonime utilizzando la funzione pipe() e stampa i valori dei file descriptor di lettura e scrittura delle pipe appena create, finchè non si raggiunge il limite massimo di file descriptors (10238 in questo caso). Inizialmente, il programma esegue un ciclo while che continua a creare pipe finché la funzione pipe() restituisce 0, indicando che la creazione della pipe è avvenuta con successo. Ad ogni iterazione del ciclo, il programma incrementa il contatore cnt per tenere traccia del numero di pipe create con successo e stampa i valori dei file descriptor di lettura (fd[0]) e scrittura (fd[1]) della pipe appena creata.

Lettura pipe: int read(int fd[0],char * data, int num)

La lettura della pipe tramite il comando read restituisce valori differenti a seconda della situazione:

Esempio

#include <stdio.h> //readPipe.c
#include <unistd.h>
int main(void)
{
	int fd[2]; char buf[50];
	int esito = pipe(fd); //Crea una pipe anonima e quindi scrive e legge da essa. Se la funzione ha successo, restituisce 0, fd[0] viene impostato al file descriptor di lettura della pipe e fd[1] viene impostato al file descriptor di scrittura della pipe 
	if(esito == 0)
	{
		write(fd[1],"writing",8); //Scrive la stringa "writing" nel file descriptor di scrittura della pipe
		int r = read(fd[0],&buf,50); //Legge fino a 50 byte dal file descriptor di lettura della pipe e li memorizza in un buffer buf
		printf("Last read %d. Received: '%s'\n",r,buf); //Il numero di byte effettivamente letti viene restituito dalla funzione read() e viene stampato insieme al contenuto del buffer
		
		//close(fd[1]); //Questa funzione dovrebbe chiudere il file descriptor di scrittura della pipe, ma è commentata. Quindi il file descriptor di scrittura della pipe rimane aperto e quindi la seconda chiamata alla funzione read() si bloccherà indefinitamente
		
		r = read(fd[0],&buf,50); //Tenta di leggere nuovamente dal file descriptor di lettura della pipe, ma poiché non ci sono più dati disponibili nella pipe, questa chiamata alla funzione read() si bloccherà fino a quando non verranno scritti nuovi dati nella pipe o fino a quando il file descriptor di scrittura della pipe non verrà chiuso
		printf("Last read %d. Received: '%s'\n",r,buf);
	}
}

Scrittura: int write(int fd[1],char * data, int num)

La scrittura della pipe tramite il comando write restituisce il numero di bytes scritti. Tuttavia, se il lato in lettura è stato chiuso viene inviato un segnale SIGPIPE allo scrittore (default handler quit) e la chiamata restituisce -1.

In caso di scrittura, se vengono scritti meno bytes di quelli che ci possono stare (PIPE_BUF) la scrittura è “atomica” e quindi tutti i dati vengono scritti in una sola volta e non possono essere interrotti da altre operazioni di scrittura. In caso contrario non c’è garanzia di atomicità e potrebbero verificarsi problemi di concorrenza o di sovrascrittura dei dati e quindi la scrittura sarà bloccata (in attesa che il buffer venga svuotato) o fallirà se il flag O_NONBLOCK viene usato.

Per modificare le proprietà di una pipe, possiamo usare la system call fnctl, la quale manipola i file descriptors. In questo caso specifico viene utilizzata per impostare il flag O_NONBLOCK sul file descriptor fd, il che significa che le operazioni di lettura e scrittura sulla pipe associata a quel file descriptor non bloccheranno il processo se i dati non sono immediatamente disponibili o se il buffer è pieno. Invece, le operazioni di lettura e scrittura restituiranno un errore e il processo potrà continuare ad eseguire altre operazioni.

La firma della funzione fcntl() è la seguente:

int fcntl(int fd, F_SETFL, O_NONBLOCK);

Esempio

Di seguito un esempio di tentativo di scrittura nella pipe mentre il lato in lettura è chiuso.

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

#include <unistd.h>
#include <stdio.h>
#include <signal.h>
#include <errno.h>
#include <stdlib.h> //writePipe.c
void handler(int signo) //Funzione chiamata quando si tenta di scrivere sul lato di scrittura della pipe
{
	printf("SIGPIPE received\n");
	perror("Error in handler");
}
int main(void)
{
	signal(SIGPIPE,handler);
	int fd[2]; char buf[50];
	int esito = pipe(fd); //Crea una pipe senza nome
	close(fd[0]); //Chiude il lato di lettura della pipe
	printf("Attempting write\n");
	int numOfWritten = write(fd[1],"writing",8); //Tenta di scrivere sul lato di scrittura della pipe
	printf("I've written %d bytes\n",numOfWritten); //Dato che il lato di lettura è chiuso, viene generato un segnale SIGPIPE che viene catturato dalla funzione handler. Il programma stampa il numero di byte scritti, che in questo caso è -1 poiché la scrittura non è riuscita
	perror("Error after write");
}

L'output è il seguente:

Attempting write
SIGPIPE received
Error in handler: Undefined error: 0
I've written -1 bytes
Error after write: Broken pipe

Esempio 2. (comunicazione unidirezionale)

Un tipico esempio di comunicazione unidirezionale tra un processo scrittore P1 ed un processo lettore P2 è il seguente:

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h> //uni.c

int main()
{
	int fd[2]; char buf[50];
	pipe(fd); //Create unnamed pipe
	int p2 = !fork();
	if(p2)
	{
		close(fd[1]);
		int r = read(fd[0],&buf,50); //Read from pipe
		close(fd[0]);
		printf("Buf: %s\n",buf);
	} 
	else {
		close(fd[0]);
		write(fd[1],"writing",8); // Write to pipe
		close(fd[1]);
	}
	while(wait(NULL) > 0);
}

Esempio 3. (comunicazione bidirezionale)

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h> 
#define READ 0 
#define WRITE 1 //bi.c
int main()
{
	int pipe1[2], pipe2[2]; char buf[50];
	pipe(pipe1); pipe(pipe2); //Crea due pipe senza nome
	int p2 = !fork(); //p2 vale 1 nel processo figlio e 0 nel processo padre
	if(p2)
	{
		close(pipe1[WRITE]); close(pipe2[READ]); //Il processo figlio P2 chiude il lato di scrittura di pipe1 e il lato lettura di pipe2
		int r = read(pipe1[READ],&buf,50); //Legge un messaggio da pipe1
		close(pipe1[READ]); printf("P2 received: '%s'\n",buf); //Stampa il messaggio ricevuto
		write(pipe2[WRITE],"Msg from p2",12); //Scrive un messaggio su pipe2
		close(pipe2[WRITE]);
	}else
	{
		close(pipe1[READ]); close(pipe2[WRITE]); //Il processo padre P1 chiude il lato di lettura di pipe1 e il lato scrittura di pipe2
		write(pipe1[WRITE],"Msg from p1",12); //Scrive un messaggio su pipe1
		close(pipe1[WRITE]);
		int r = read(pipe2[READ],&buf,50); //Legge un messaggio da pipe2
		close(pipe2[READ]); printf("P1 received: '%s'\n",buf); //Stampa il messaggio ricevuto
	}
	while(wait(NULL)>0);
}

Gestire la comunicazione

Per gestire comunicazioni complesse c’è bisogno di definire un “protocollo”. Esempio:

Più in generale occorre definire la sequenza di messaggi attesi.

Esempio: redirige lo stdout di cmd1 sullo stdin di cmd2

#include <stdio.h> 
#include <unistd.h> 
#define READ 0 
#define WRITE 1 //redirect.c
int main (int argc, char *argv[]) 
{
	int fd[2];
	pipe(fd); //Crea una pipe senza nome
	if (fork() != 0) //Crea un processo figlio, ed il processo padre (colui che scrive) esegue l'if
	{
		close(fd[READ]); //Chiude il lato di lettura
		dup2(fd[WRITE], 1); //Chiude il descrittore di file 1 (che corrisponde a stdout) e lo sostituisce con una copia del descrittore di file fd[WRITE]. Qualsiasi output inviato a stdout dal programma eseguito successivamente con la funzione execlp() verrà in realtà scritto sulla pipe
		close(fd[WRITE]); //Chiude il lato di scrittura originale
		execlp(argv[1], argv[1], NULL); //Viene eseguito il programma specificato come primo argomento sulla riga di comando. Questo programma scriverà sulla pipe tramite stdout.
		perror("error"); // Should never execute
	} else //Parte eseguita dal figlio (colui che legge)
	{
		close(fd[WRITE]); //Chiude il lato di scrittura
		dup2(fd[READ], 0); //Chiude il descrittore di file 0 (che corrisponde a stdin) e lo sostituisce con una copia del descrittore di file fd[READ]. Qualsiasi input letto da stdin dal programma eseguito successivamente con la funzione execlp() verrà in realtà letto dalla pipe
		close(fd[READ]); //Chiude il lato di lettura originale
		execlp(argv[2], argv[2], NULL); //Viene eseguito il programma specificato come secondo argomento sulla riga di comando utilizzando la funzione execlp(). Questo programma leggerà dalla pipe tramite stdin
		perror("error"); // Should never execute
	}
}

Per eseguire il programma bisognerà passare come argomenti i nomi dei programmi da eseguire nel processo padre e nel processo figlio, come ad esempio:

./redirect ls wc

In questo caso il processo padre eseguirà il comando ls (primo argomento sulla linea di comando) ed il suo output verrà inviato al processo figlio tramite pipe.
Il processo figlio esegue il comando wc (secondo argomento sulla linea di comando), il quale riceverà l'output del comando ls come input e ne stamperà il conteggio delle righe, delle parole e dei caratteri.

Pipe con nome/FIFO

FIFO

Le pipe con nome, o FIFO, sono delle pipe che corrispondono a dei file speciali nel filesystem grazie ai quali i processi, senza vincoli di gerarchia (indipendentemente dalla loro posizione nella gerarchia del sistema operativo), possono comunicare. Un processo può accedere ad una di queste pipe se ha i permessi sul file corrispondente ed è vincolato, ovviamente, dall’esistenza del file stesso (se il file che rappresenta la pipe con nome viene eliminato o non esiste, il processo non può utilizzare quella specifica pipe per comunicare).

Le FIFO sono interpretate come dei file, perciò si possono usare le funzioni di scrittura/lettura dei file viste nelle scorse lezioni. Restano però delle pipe, con i loro vincoli e le loro capacità. Dato che non sono dei file effettivi, alcune operazioni che possono essere eseguite sui file normale, come lseek, non funzionano sulle FIFO. Inoltre il loro contenuto è sempre vuoto e non vi ci si può scrivere con un editor!

Quando un processo apre una FIFO per la comunicazione, normalmente viene bloccato fino a quando anche l’altro lato della FIFO viene aperto da un altro processo. Questo significa che entrambi i processi devono aprire la FIFO prima che possano iniziare a comunicare. Le differenze tra le pipe anonime e le FIFO risiedono principalmente nella loro creazione e gestione. Le pipe anonime vengono create e gestite automaticamente dal sistema operativo quando due processi si scambiano dati, mentre le FIFO devono essere create esplicitamente dai processi e gestite come file nel filesystem.

Creare una FIFO

Crea una FIFO (un file speciale), solo se non esiste già, con il nome pathname (es /tmp/myfifo). Il parametro mode può definire i privilegi del file nella solita maniera (es S_IRUSR, S_IWUSR, S_IRGRP, S_IWGRP, S_IROTH e S_IWOTH).

#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h> //fifo.c
int main(void)
{
	const char * fifoName = "/tmp/fifo1";
	mkfifo(fifoName,S_IRUSR|S_IWUSR); //Crea una pipe con nome (FIFO) se non esiste
	perror("Created?");
}

Comunicazione bloccata

#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h> //fifo.c
int main(void)
{
	const char * fifoName = "/tmp/fifo1";
	mkfifo(fifoName,S_IRUSR|S_IWUSR); //Crea una pipe con nome (FIFO) se non esiste
	perror("Created?"); //Stampa un messaggio di errore se la creazione della FIFO non è stata eseguita correttamente
	if (fork() == 0) //Parte eseguita da Child
	{
		open(fifoName,O_RDONLY); //Apre il lato di lettura della FIFO, ma rimane bloccato finché il lato di scrittura non viene aperto dal processo padre. Una volta che il processo padre ha aperto il lato di scrittura, il processo figlio può completare l’apertura del lato di lettura e stampare il messaggio “Open read”.
		printf("Open read\n");
	}else
	{
		sleep(3);
		open(fifoName,O_WRONLY); //Apre il lato di scrittura della FIFO. L’apertura del lato di scrittura della FIFO non si blocca e può essere eseguita anche se il lato di lettura non è ancora stato aperto (il lato di lettura invece deve attendere quello di scrittura)
		printf("Open write\n");
	}
}

Esempio comunicazione: writer (1/2)

#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h> //fifoWriter.c
int main (int argc, char *argv[])
{
	int fd; 
    const char * fifoName = "/tmp/fifo1";
	char str1[80];
    const char * str2 = "I'm a writer";
	mkfifo(fifoName,S_IRUSR|S_IWUSR); //Crea una pipe con nome (FIFO) se non esiste
	fd = open(fifoName, O_WRONLY); //Apre la FIFO in modalità di sola scrittura
	write(fd, str2, strlen(str2)+1); //La stringa puntata da str2 viene scritta nella FIFO
	close(fd); //La FIFO viene chiusa
	fd = open(fifoName, O_RDONLY); //Apre la FIFO in modalità di sola lettura (si può fare perchè è sufficiente aver aperto prima il lato di scrittura, anche se è stato chiuso prima di aver aperto quello di lettura)
	read(fd, str1, sizeof(str1)); //I dati vengono letti dalla FIFO e memorizzati nell'array di caratteri str1
	printf("Reader is writing: %s\n", str1); //Il contenuto dell'array str1 viene stampato
	close(fd); //La FIFO viene chiusa
}

Esempio comunicazione: reader (2/2)

#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
//fifoReader.c
int main (int argc, char *argv[])
{
	int fd; 
	const char * fifoName = "/tmp/fifo1";
	mkfifo(fifoName,S_IRUSR|S_IWUSR); //Crea una pipe con nome (FIFO) se non esiste
	char str1[80];
	const char * str2 = "I'm a reader";
	fd = open(fifoName , O_RDONLY); //Apre la FIFO in modalità di sola lettura
	read(fd, str1, 80); //Legge fino ad 80 caratteri dalla FIFO e li memorizza in str1
	close(fd); //La FIFO viene chiusa
	printf("Writer is writing: %s\n", str1); //Stampa il contenuto letto dalla FIFO
	fd = open(fifoName , O_WRONLY); //Apre la FIFO in modalità di sola scrittura
	write(fd, str2, strlen(str2)+1); //Scrive una stringa nella FIFO
	close(fd); //La FIFO viene chiusa
}

Per provare la comunicazione attraverso le pipe FIFO sarà necessario compilare entrambi gli eseguibili ed eseguirli in due terminali separati. Oppure far eseguire il primo programma in background

./fifoWriter &

e successivamente avviare il secondo programma

./fifoReader

Per avere entrambi gli output nella stessa finestra del terminale

Comunicazione sul terminale

È possibile usare le FIFO da terminale, leggendo e scrivendo dati tramite gli operatori di redirezione.

echo “message for pipe” > /path/nameOfPipe #scrive la stringa “message for pipe” nella pipe denominata situata in /path/nameOfPipe
cat /path/nameOfPipe #legge i dati dalla pipe denominata situata in /path/nameOfPipe e li visualizza nel terminale

NB: non si possono scrivere dati usando editor di testo! Una volta consumati i dati, questi non sono più presenti sulla FIFO.

Pipe anonime vs FIFO

pipe FIFO
Rappresentazione Buffer Buffer
Riferimento anonimo File
Accesso 2 File descriptors 1 File descriptors
Persistenza Eliminata alla terminazione di tutti i processi Esiste finché esiste il file
Vincoli accesso Antenato comune Permessi sul file
Creazione pipe() mkfifo()
Max bytes per atomicità PIPE_BUF = 4096 on Linux, minimo 512 Bytes POSIX

Conclusioni

PIPE e FIFO (“named pipes”) sono sistemi di comunicazione tra processi (“parenti”, tipicamente padre-figlio, nel primo caso e in generale nel secondo caso) che consentono scambi di informazioni (messaggi) e sincronizzazione (grazie al fatto di poter essere “bloccanti”).