Lucrarea 9
Canale pipe fără nume (anonime) 

 

1. Scopul lucrării

Lucrarea de față prezintă o modalitate de comunicare între procese folosind o tehnică valabilă în orice versiune UNIX și anume prin canale de comunicație, numite și conducte sau pipe. În lucrare sunt prezentate atât conductele unidirecționale cât și cele bidirecționale precum și modul în care se poate evita blocarea (deadlock) proceselor.

2. Considerații teoretice

Pipeurile sunt canale de comunicație între procese, prin care informațiile sunt transferate de la un proces la altul printr-un mecanism FIFO.

Canalele de comunicație sunt familiare utilizatorilor sistemului UNIX ca facilitate a interpretorului de comenzi shell. De exemplu, pentru a tipări o listă ordonată a utilizatorilor ce au deschis o sesiune de lucru în sistem se folosește comanda:

who | sort | pr

Există trei procese conectate prin două conducte. Fluxul datelor este de la stânga la dreapta.

Cu ajutorul conceptului de pipe-line, rezolvarea unei probleme mari se face descompunând-o în probleme mici, cu avantajele corespunzătoare:

  • scrierea programelor se face fără dificultate;
  • testarea și punerea la punct a programelor se face ușor;
  • posibilitatea lucrului în echipă.
Sub UNIX însă, pipeurile se pot crea și prin program, rezultând de aici mai multa flexibilitate, posibilitatea de a crea legături circulare între procese, etc.

2.1. Apelul sistem PIPE

Crearea unui fișier pipe se face cu ajutorul funcției de sistem pipe, a cărei interfață este următoarea:

#include <unistd.h>

int pipe ( int pfd[2]);

Returnează 0 în caz de succes, (-1) în caz de eroare.

Argumentul pfd este un tablou cu două elemente, care după execuția funcției va conține:

  • descriptorul de fișier pentru citire pfd[0] și
  • descriptorul de fișier pentru scriere pfd[1].
Apelul pipe creează un canal de comunicație în care se pot scrie și citi date. Canalul este reprezentat prin doi descriptori de fișier care sunt returnați în sirul pfd. Scrierea în pfd[1] pune datele în pipe, iar citirea din pfd[0] preia datele din pipe.

În caz de eroare variabila errno indică eroarea apărută.

Proces1     
Proces 2 
 
write(pfd[1] ,... ) 
read (pfd [0], ...) 
 
  pipe  
             
   


Fig.1. Pipe între două procese.

Citirea și scrierea în fișiere pipe se face conform algoritmului FIFO (primul intrat, primul ieșit). Sincronizarea între procesele care scriu în fișier și cele care citesc din fișier se face pe principiul producător/consumator, care constă în următoarele:

a) Un proces de scriere ("producător") va putea scrie în fișier dacă acesta nu este plin. În caz contrar, procesul de scriere va fi întârziat (blocat) până când un proces de citire ("consumator") ia date din fișier.

b) Un proces de citire ("consumator") va putea prelua date din fișier dacă acestea există. În caz contrar, procesul de citire ("consumator") va fi întârziat (blocat) până când un proces de scriere ("producător") nu a depus date în fișier.

În cazul în care se cer mai multe date decât există se preiau câte există.

Caracteristicile fișierelor pipe sunt următoarele:

a) Dimensiunea unui fișier pipe este limitată la maximum 10 blocuri. Gestionarea blocurilor se face ca într-un buffer circular sau coadă circulară. Adresarea blocurilor de date se face direct (acces rapid la ele).

b) Dacă s-au citit date din fișier, asupra lor nu se mai poate reveni. Sistemul de gestionare a fișierelor menține în tabela fișierelor adresa de citire și scriere în intrări diferite.

c) Pentru a asigura respectarea algoritmului FIFO, la un fișier pipe pot avea acces numai procesul care l-a creat și descendenții săi. În mod normal pipe-ul este creat de un proces care a apelat fork, și pipe-ul este folosit între procesul părinte și fiu. Prin apelul fork, intrările în tabela fișierelor sunt partajate între părinte și fiu. De aici rezulta concluzia, ca prin fișiere pipe pot comunica procesul părinte cu procesele fiu sau procese care au un strămoș comun.

d) într-un fișier pipe nu este posibil accesul direct, ci numai secvențial. Acest lucru este impus pentru ca datele să fie preluate strict în ordinea depunerii lor. Ca urmare, un utilizator nu poate modifica deplasamentul într-un fișier pipe cu funcția de sistem lseek.

e) Dacă un proces încearcă să scrie într-un fișier pipe o înregistrare de dimensiune mai mare ca spațiul liber din fișier, sistemul de gestionare a fișierelor procedează astfel: scrie date până umple spațiul gol, apoi blochează procesul. Dacă între timp a avut loc o citire, procesul va fi deblocat. Dar s-ar putea ca un alt proces de scriere să fie activat înaintea celui care a fost blocat. De aceea s-ar putea crea interclasări de date scrise de procese diferite. Acest lucru trebuie evitat printr-o sincronizare corectă între procese.

f) Operațiile asupra unui fișier pipe sunt următoarele:

  • crearea fișierului cu funcția de sistem pipe;
  • citirea/scrierea cu funcțiile de sistem read/write;
  • setarea indicatorului O_NDELAY prin funcția de sistem fcntl, care are următorul efect: dacă funcțiile de sistem read sau write nu pot fi completate, procesul care le-a apelat nu va fi blocat până la îndeplinirea totală a sarcinii de scriere sau citire, ci funcțiile de read/write se vor termina returnând zero.
  • închiderea fișierului cu funcția de sistem close;
  • ștergerea fișierului, mai precis a inode-ului corespunzător; aceasta se face de către sistemul de gestionare a fișierelor când numărul de referiri din inod devine zero.
2.2. Apelurile sistem DUP și DUP2

Pentru a duplica un descriptor de fișier se folosesc apelurile sistem dup și dup2:

#include <unistd.h>

int dup( int fd);

int dup2( int fd, int nfd);

Returnează descriptorul nou în caz de succes, -1 în caz de eroare.

Se creează un descriptor nou pe lângă descriptorul de fișier existent. Descriptorul de fișier returnat de dup este numărul de descriptorul cel mai mic disponibil. Folosind acest lucru se poate redirecta cu ușurință intrarea și /sau iesirea standard. Asupra acestui aspect vom reveni pe parcurs.

Folosind dup2, se poate specifica valoarea descriptorului nou prin argumentul ndf. Dacă fd era deschis acesta este închis.

Noul descriptor returnat referă aceeași intrare în tabela fișierelor ca și vechiul, ceea ce semnifică că există un pointer în fișier unic și aceleași drepturi de acces asupra fișierului pentru fiecare descriptor. Apelul eșuează dacă argumentul este eronat (nu este deschis) sau dacă sunt deja 20 de descriptori deschiși.

Pentru compatibilitate cu versiunile mai vechi se poate folosi și apelul fcntl, cu argumentele precizate astfel:

nfd=fcntl( fd, F_DUPFD, x);

Apelul asigură că descriptorul returnat este mai mic sau egal cu x.

Efectul apelurilor sistem de I/E asupra acestor descriptori este însă diferit față de efectul lor asupra unui descriptor de fișier obișnuit. Din acest motiv se redescriu aceste apeluri sistem:

Write: datele sunt scrise în conducta în ordinea în care ele sosesc. Capacitatea unei conducte variază funcție de versiunea UNIX, dar de regulă ea nu este mai mica decât 4096 octeți. Dacă un canal este plin apelul write se blochează până un alt proces golește pipe-ul prin citire. Nu există scrieri parțiale; apelul write nu revine decât după o scriere completș (excepție fac situațiile în care fanionul O_NDELAY a fost poziționat prin apelul fcntl). Singurul mod de pune un sfârșit de fișier într-un canal este de a închide descriptorul de scriere.

Read: datele sunt citite din conducta în ordinea în care ele sosesc. Odată citită o dată nu se poate reciti sau repune în conductă. Un apel read revine chiar dacă nu s-au găsit toți octeții, valoarea returnată fiind numărul de octeți efectiv citiți. Dacă conducta este goală citirea se blochează până când o dată devine disponibilă pentru citire (excepție face poziționarea fanionului O_NDELAY). În cazul în care pfd[1] este închis valoarea returnată de read este 0, aceasta fiind modul prin care se semnalează procesului cu care se comunică, sfârșitul de fișier.

Close: semnifică pentru conducte mai mult decât disponibilizarea descriptorului. Dacă se închide pfd[1] aceasta semnifică și sfârșit de fișier pentru cel care citește conducta, iar dacă se inchide pfd[0], scrierea în descriptorul de fișier va produce eroare. În acest caz se generează semnalul SIGPIPE ("Write on a pipe not opened for reading" - a se vedea lucrarea cu semnale).

Fstat: apelul e puțin utilizat în cazul conductelor. Apelul întoarce ca rezultat numărul de octeți din conducta, dar acest lucru este puțin important. Acest apel este însă util pentru a determina dacă un descriptor de fișier corespunde sau nu unui pipe, prin testarea numarului de legături (links). O conductă se identifică prin valoarea 0 a numărului de legături.

Open, Creat, Lseek: nu se folosesc în cazul conductelor din rațiuni evidente.

Pipeul este folosit pentru comunicarea între două procese, așa încât nu există nici o rațiune în a folosi acest mecanism în cadrul unui proces. În acest caz este posibilă blocarea procesului (deadlock) dacă în conducta se scriu mai mulți octeți decât capacitatea conductei.

Pipe-urile folosesc același mecanism de buffer cache care se folosește și pentru fișierele de pe discuri. Scrierea unui bloc (uzual de 512 octeți) este o operație atomica, aceasta însemnând ca scrierea unui bloc implică o citire corespunzătoare tot de un bloc (dacă se dorește). Oricum, dacă nu se scriu blocuri complete, citirea nu este afectată, deoarece apelul read citește blocuri parțiale. Acest lucru nu se întâmplă însă dacă scrierea în pipe este mai rapidă decât citirea.

2.1. Conducte unidirecționale

Pentru ca între doua procese să se poată stabili un pipe unidirectional este necesar ca ambele procese să cunoască descriptorii asociați pipe-ului. Două procese deja create nu pot fi conectate printr-un pipe. Pipe-ul trebuie creat intr-unul din procese înaintea creării celui de-al doilea proces, pentru ca astfel ultimul să moștenească descriptorii de fișier ai pipe-ului. Rezultă că doua procese care pot comunica prin pipe trebuie să fie în relația părinte-fiu sau două procese cu strămoș comun; pipe-ul trebuie creat în primului proces. În practica acest lucru constituie o limitare serioasă, deoarece dacă un proces dispare nu există nici o cale pentru a-l recrea și reconecta la conducta sa. Procesul rămas trebuie terminat și pipe-ul trebuie recreat.

Este important de remarcat ca pipe-ul unidirectional, de tipul celor create de shell, nu poate conduce niciodată la blocare.

Pentru a interconecta doua procese prin pipe se pot urma pașii:

1) Primul proces construiește pipe-ul prin apelul pipe;

2) Se creează procesul fiu prin apelul fork;

3) În procesul fiu se închide descriptorul de scriere și se fac eventual alte pregătiri;

4) Se executa programul fiului (apelul exec);

5) În părinte se închide descriptorul de citire;

6) Dacă există și un al doilea fiu, care se dorește să scrie în pipe, se creează și execută programul corespunzător lui;

7) Procesul părinte scrie datele în pipe.

O rațiune pentru care apelurile fork și exec nu au fost contopite într-un unic apel sistem este ca între ele să se poată face unele prelucrări (vezi pasul 3) către altfel, făcute în alta parte, ar fi mult mai costisitoare.

Pentru a elimina transmiterea descriptorului de fișier ca argument în linia de comanda proiectanții sistemului UNIX propun o soluție mult mai elegantă. Ea are la bază ideea ca multe programe își iau datele de intrare din intrarea standard, care are asociat descriptorul 0 și scriu datele la ieșirea standard care are asociat descriptorul 1. Se conturează ideea ca pentru a conecta două comenzi în maniera de lucru shell trebuie să asociem pipe-ului acești doi descriptori. Prin simpla închidere a celor doi descriptori anterior menționați înainte de apelul pipe nu avem certitudinea ca pipe-ul creat va avea asociat acești doi descriptori.

Apelul dup duplică un descriptor de fișier astfel încât după apel fișierul care are asociat descriptorul fd să poată fi accesat și prin descriptorul întors de dup. În cazul de față, prin duplicarea descriptorului se dorește să se obțină un descriptor "particular" care să răspundă mai bine cerințelor apelantului. Particularitatea sa rezultă din faptul că apelul dup returnează descriptorul cu număr minim dintre cei neutilizați. Aceasta înseamnă că dacă înainte de apelul dup, am închis descriptorul 0, apelul dup va întoarce cu siguranță 0. În mod identic dacă s-a închis descriptorul 1, descriptorul 0 fiind utilizat, apelul dup va întoarce 1. În caz de eroare, valoarea returnată este -1.

Acest lucru permite obținerea descriptorului 0 ca descriptor de citire din pipe și descriptorului 1 ca descriptor de scriere în pipe.

2.2. Conducte bidirecționale

O conducta bidirecțională este un canal de comunicație între două procese în care fluxul de date se desfășoară în ambele direcții.

Două procese interconectate printr-o conducta aveau acțiuni clare și distincte: unul scrie în conductă, iar celălalt citește. Este tentant să se conecteze două procese care ambele încearcă să citească și să scrie. Din păcate această soluție nu funcționează deoarece nu există sincronizare între procese. Se poate întâmpla, ca citind dintr-o astfel de conductă, să se citească informația care anterior a fost scrisă pentru a fi transmisă spre celălalt proces, dar care însă nu a ajuns să fie citită de acesta. Apar astfel blocaje, deoarece al doilea proces poate să ajungă în situația de-a aștepta la infinit o informație care nu mai vine. Reușita unui pipe bidirecțional este dependentă de ordinea în care procesele partajează unitatea centrala. Se recomandă în locul folosirii conductei bidirecționale a două conducte unidirecționale. Descriptorii de fișiere blocati sunt tot doi pentru fiecare proces, ceilalti doi putând fi închisi după cum s-a văzut în exemplele anterioare.

De exemplu, să considerăm un program care citește datele din fișierul data și folosește filtrul sort pentru a le ordona alfabetic. În final, datele sortate sunt afișate. Aceasta înseamnă ca unul din procese scrie datele nesortate în conductă, iar celălalt le citește, le sortează și le rescrie în conductă pentru ca primul proces să le citească sortate. Rezolvarea problemei în această maniera oferă însă un rezultat surprinzător. Datele nu sunt sortate și programul se blochează ("agață"). Cauza este nesincronizarea între procese. După ce datele nesortate au fost scrise în pipe de procesul părinte, acesta începe să citească pipe-ul, presupunând ca deja citește rezultatul aplicării filtrului sort. Dar acest lucru este făcut înaintea aplicării filtrului și astfel se citesc înapoi datele nesortate.

Procesul fiu, care executa filtrul, începe să-și citească intrarea standard, care este goală din moment ce procesul părinte tocmai a citit tot. Indiferent dacă intrarea e plină sau goală, sort se blochează în așteptarea unui sfârșit de fișier, care este generat la închiderea descriptorului de scriere. Cu siguranță, procesul ce scrie datele va închide acest descriptor, dar procesul fiu îl are încă deschis, deoarece el trebuie să scrie datele acolo. Ca atare, fiul a fost blocat.

În general, un filtru implicat în scrierea și citirea unei conducte ajunge la blocaj. În cazul în care datele ce necesită a fi sortate nu determină umplerea conductei, punerea în așteptare a procesului părinte până la terminarea procesului fiu este o soluție comoda. Dacă această alternativa nu satisface rămâne folosirea a două conducte cu trafic unidirecțional. Rezolvarea corectă a acestei probleme este prezentată în aplicația 3.3.

Blocajul este posibil și cu doua conducte în cazul în care procesele se blochează pe apeluri write. Aceasta se produce când ambele conductele devin pline, procesele nefăcând suficiente citiri din ele. Fiecare caz trebuie analizat cu grijă pentru a preveni blocajul.

3. Mersul lucrării

3.1. Se considera următorul programul:

#include <stdio.h>

char msg1[]="abcdefghij";

char msg2[]="1234567890"; /* funcție de mesaje */

main()

{

char buf[128];

int pfd[2], pid;

pipe( pfd);

if (( pid=fork())==0) {

printf("Fiul transmite: %s\n", msg1);
write( pfd[1], msg1, 11);
read( pfd[0], buf, 13);
printf("Fiul receptat: %s\n", buf);
exit(1);
}

else {

read( pfd[0], buf, 11);

printf("Tatal receptat: %s\n", buf);

write( pfd[1], msg2, 13);

printf("Tatal transmite: %s\n", msg2);

}

}

Explicați funcționarea acestui program. Ce probleme pot să apară ?

3.2. Să se scrie un program care afișează conținutul fișierului primit ca argument în linia de comandă. Programul afișează conținutul fișierului pagină cu pagină.

#include <sys/wait.h>

#include "hdr.h"

#define DEF_PAGER "/usr/bin/more"

main( int argc, char *argv[])

{

int pfd[2], n;

pid_t pid;

char buf[MAXLINE], *pager, *arg;

FILE *fp;

if ( argc !=2 )

err_quit("Utilizare: a.out <path>\n");
if ( pipe( pfd) < 0 )
err_sys("Eroare pipe");
if ( ( fp=fopen( argv[1], "r")) == NULL)
err_sys("Eroare fopen %s", argv[1]);
switch ( fork() ) {
case 1: err_sys("Eroare fork");
case 0: /* fiul citeste */
close( pfd[1]);
if ( pfd[0] != 0) {
if ( dup2( pfd[0], 0) != 0)
err_sys("Eroare dup2");
close( pfd[0]); /* nu mai e necesar */
}
if ( ( pager=getenv("PAGER")) == NULL)
pager=DEF_PAGER;
if ( ( arg=strrchr( pager, '/')) != NULL)
arg++;
else
arg=pager;
if ( execl( pager, arg, NULL) < 0)
err_sys("Eroare execl la %s", pager);
default: /* scrie părintele */
close( pfd[0]);
while ( fgets( buf, MAXLINE, fp) != NULL) {

n=strlen( buf);

if ( write( pfd[1], buf, n) != n)

err_sys("Eroare write");
}

if ( ferror( fp))

err_sys("Eroare ferror");
close( pfd[1]);

if ( waitpid( pid, NULL,0) < 0)

err_sys("Eroare waitpid");
exit(0);

}

}

3.3. Să se scrie un program care citește datele dintr-un fișier, primit ca argument în linia de comandă și folosește filtrul sort pentru a le ordona alfabetic. În final, datele sortate sunt afișate.

/*

A se compila cu: gcc o fsort 4_7.c err.o

*/

#include <stdio.h>

#include "hdr.h"

void fsort( char *);

int main( int argc, char *argv[])

{

if ( argc < 2)

err_quit("Utilizare: fsort <nume.txt>\n");
fsort( argv[1]);

}

void fsort( char *path)

{

int pfdout[2], pfdin[2], fd, nr;

char buf[512];

if ( pipe( pfdout) < 0 || pipe( pfdin) < 0)

err_sys("Eroar pipe");
switch( fork()) {
case 1: err_sys("Eroare fork");
case 0:
/* procesul fiu nu va citi din pfdin[0] și nu va scrie în pfdout[1] */
if ( close( pfdin[0]) < 0 || close( pfdout[1]) < 0)
err_sys("Eroare close unused");
if ( close(0) < 0)
err_sys("Eroare close stdin");
if ( dup( pfdout[0]) != 0)
err_sys("Eroare dup la stdin");
if ( close(1) < 0)
err_sys("Eroare close stdout");
if ( dup( pfdin[1]) != 1)
err_sys("Eroare dup la stdout");
/* se elibereaza descriptorii anterior folositi */
if ( close(pfdout[0]) < 0 || close( pfdin[1]) < 0)
err_sys("Eroare close");
execlp("sort", "sort", NULL);
err_sys("Eroare execlp");
}
/* procesul părinte */
if ( close( pfdout[0]) < 0 || close( pfdin[1]) < 0)
err_sys("Părinte: Eroare close unused");
/* deschiderea fișierului de date */
if ( ( fd=open( path, 0)) < 0)
err_sys("Eroare open");
/* scrierea datelor nesortate în pipe */
while (( nr=read( fd, buf, sizeof(buf))) != 0) {
if ( nr < 0)
err_sys("Eroare read date nesortate");
if ( write( pfdout[1], buf, nr) < 0)
err_sys("Eroare write date nesortate");
}
/* inchiderea fișierului și EOF pentru pipe */
if ( close(fd) < 0 || close( pfdout[1]) < 0)
err_sys("Eroare close DATA & pfdout[1]");
/* citirea datelor din conducta */
while ( ( nr=read( pfdin[0], buf, sizeof( buf))) != 0) {
if ( nr < 0)
err_sys("Eroare read date sortate");
if ( write( 1, buf, nr) < 0)
err_sys("Eroare write date sortate");
}
if ( close(pfdin[0])==1)
perror("close");
}
4. Probleme propuse

4.1. Care sunt problemele care apar dacă procesul fiu nu cunoaște descriptorul pentru pipe ?

4.2. Ce se întâmplă dacă există un singur fișier pipe la aplicația 3.3. ?

4.3. Să se implementeze apelul sistem dup folosind apelul sistem fcntl.

4.4. Să se scrie un program în care se creează două procese în relația părinte fiu. Procesul părinte scrie un șir în pipe, iar procesul fiu afișează conținutul pipe-ului folosind comanda unix cat.

4.5. Să se scrie folosind un pipe rutine de sincronizare pentru procese aflate în relația părinte-fiu.

4.6. Un filtru e numit coproces când același program își generează intrarea și își citește ieșirea. Să se scrie un astfel de program care citește două numere din intrarea standard și scrie suma lor la ieșirea standard.

4.7. Să se scrie un program care apelează coprocesul de la problema anterioară. Programul citește două numere din intrarea standard, le transmite coprocesului care calculează suma lor și afișează acest rezultat la ieșirea standard.

4.8. Folosind apelurile sistem pipe, fork și exec să se scrie programul care echivalează linia de comanda a shelului:

comanda1 | comanda2

Cele două comenzi se citesc ca argumente din linia de comandă.

4.9. Să se generalizeze problema 4.6. pentru linii de comanda de forma:

cd1 arg1 arg2 ... | cd2

Testarea programul se poate realiza prin comenzi de forma:

a.out cat 4_1.c 4_2.c pipe more

a.out ls -l /root pipe more

a.out cat 4_1.c pipe sort

4.10. Să se implementeze un editor interactiv folosind editorul ed și apelul fork și pipe. Ce probleme apar ?