Table of Contents

Laborator 4 - Procese

Materiale ajutătoare

Nice to read

Prezentare concepte

Un proces este un program în execuție. Procesele sunt unitatea primitivă prin care sistemul de operare alocă resurse utilizatorilor. Orice proces are un spațiu de adrese și unul sau mai multe fire de execuție. Putem avea mai multe procese ce execută același program, dar oricare două procese sunt complet independente.

Informațiile despre procese sunt ținute într-o structură numită Process Control Block (PCB), câte una pentru fiecare proces existent în sistem. Printre cele mai importante informații conținute de PCB regăsim:

În momentul lansării în execuție a unui program, în sistemul de operare se va crea un proces pentru alocarea resurselor necesare rulării programului respectiv. Fiecare sistem de operare pune la dispoziție apeluri de sistem pentru lucrul cu procese: creare, terminare, așteptarea terminării. Totodată există apeluri pentru duplicarea descriptorilor de resurse între procese, ori închiderea acestor descriptori.

Procesele pot avea o organizare:

În general, un proces rulează într-un mediu specificat printr-un set de variabile de mediu. O variabilă de mediu este o pereche NUME = valoare. Un proces poate să verifice sau să seteze valoarea unei variabile de mediu printr-o serie de apeluri de bibliotecă (Linux, Windows).

Pipe-urile (canalele de comunicație) sunt mecanisme primitive de comunicare între procese. Un pipe poate conține o cantitate limitată de date. Accesul la aceste date este de tip FIFO (datele se scriu la un capăt al pipe-ului pentru a fi citite de la celălalt capăt). Sistemul de operare garantează sincronizarea între operațiile de citire și scriere la cele două capete (Linux, Windows).

Există două tipuri de pipe-uri:

Procese în Linux

Lansarea în execuție a unui program presupune următorii pași:

Crearea unui proces în Linux

În UNIX un proces se creează folosind apelul de sistem fork:

pid_t fork(void);

Efectul este crearea unui nou proces (procesul copil), copie a celui care a apelat fork (procesul părinte). Procesul copil primește un nou process id (PID) de la sistemul de operare.

Această funcție este apelată o dată și se întoarce (în caz de succes) de două ori:

  • În părinte va întoarce pid-ul procesului nou creat (copil).
  • În procesul copil apelul va întoarce 0.

Pentru aflarea PID-ului procesului curent și al procesului părinte se vor apela funcțiile de mai jos.

Funcția getpid întoarce PID-ul procesului apelant:

pid_t getpid(void);

Funcția getppid întoarce PID-ul procesului părinte al procesului apelant:

pid_t getppid(void);

Înlocuirea imaginii unui proces în Linux

Familia de funcții exec va executa un nou program, înlocuind imaginea procesului curent, cu cea dintr-un fișier (executabil). Acest lucru înseamnă:

int execl(const char *path, const char *arg, ...); 
int execv(const char *path, char *const argv[]);
int execlp(const char *file, const char *arg, ...);

Exemplu de folosire a funcțiilor de mai sus:

execl("/bin/ls", "ls", "-la", NULL);
 
char *const argvec[] = {"ls", "-la", NULL};
execv("/bin/ls", argvec);
 
execlp("ls", "ls", "-la", NULL);

Primul argument este numele programului. Ultimul argument al listei de parametri trebuie să fie NULL, indiferent dacă lista este sub formă de vector (execv*) sau sub formă de argumente variabile (execl*).

execl și execv nu caută programul dat ca parametru în PATH, astfel că acesta trebuie însoțit de calea completă. Versiunile execlp și execvp caută programul și în PATH.

Toate funcțiile exec* sunt implementate prin apelul de sistem execve.

Așteptarea terminării unui proces în Linux

Familia de funcții wait suspendă execuția procesului apelant până când procesul (procesele) specificat în argumente fie s-a terminat, fie a fost oprit (SIGSTOP).

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

Starea procesului interogat se poate afla examinând status cu macrodefiniții precum WEXITSTATUS, care întoarce codul de eroare cu care s-a încheiat procesul așteptat, evaluând cei mai nesemnificativi 8 biți.

Există o variantă simplificată, care așteaptă orice proces copil să se termine. Următoarele secvențe de cod sunt echivalente:

wait(&status);                   |  waitpid(-1, &status, 0);

În caz că se dorește doar așteptarea terminării procesului copil, nu și examinarea statusului, se poate folosi:

wait(NULL);

Terminarea unui proces în Linux

Pentru terminarea procesului curent, Linux pune la dispoziție apelul de sistem exit.

void exit(int status);

Apeluri exit

Apeluri exit

Dintr-un program C există trei moduri de invocare a acestui apel de sistem:

1. apelul _exit (POSIX.1-2001):

void _exit(int status);

2. apelul _Exit din biblioteca standard C (conform C99):

void _Exit(int status);

3. apelul exit din biblioteca standard C (conform C89, C99), cel prezentat mai sus.

_exit(2) și _Exit(2) sunt funcțional echivalente (doar că sunt definite de standarde diferite):

  • procesul apelant se va termina imediat
  • toți descriptorii de fișier ai procesului sunt închiși
  • copiii procesului sunt “înfiați” de init
  • un semnal SIGCHLD va fi trimis către părintele procesului. Tot acestuia îi va fi întoarsă valoarea status, ca rezultat al unei funcții de așteptare (wait sau waitpid).

În plus, exit(3):

  • va șterge toate fișierele create cu tmpfile()
  • va scrie bufferele streamurilor deschise și le va închide

Conform ISO C, un program care se termină cu return x din main() va avea același comportament ca unul care apelează exit(x).

Un proces al cărui părinte s-a terminat poartă numele de proces orfan. Acest proces este adoptat automat de către procesul init, dar poartă denumirea de orfan în continuare deoarece procesul care l-a creat inițial nu mai există.

Un proces finalizat al cărui părinte nu a citit (încă) statusul terminării acestuia poartă numele de proces zombie. Procesul intră într-o stare de terminare, iar informația continuă să existe în tabela de procese astfel încât să ofere părintelui posibilitatea de a verifica codul cu care s-a finalizat procesul. În momentul în care părintele apelează funcția wait, informația despre proces dispare. Orice proces copil o să treacă prin starea de proces zombie la terminare.

Pentru terminarea unui alt proces din sistem, se va trimite un semnal către procesul respectiv prin intermediul apelului de sistem kill. Mai multe detalii despre kill și semnale în laboratorul de semnale.

Exemplu (my_system)

my_system.c
#include <stdlib.h>
#include <stdio.h>
 
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
 
int my_system(const char *command)
{
	pid_t pid;
	int status;
	const char *argv[] = {command, NULL};
 
	pid = fork();
	switch (pid) {
	case -1:
		/* error forking */
		return EXIT_FAILURE;
	case 0:
		/* child process */
		execvp(command, (char *const *) argv);
 
		/* only if exec failed */
		exit(EXIT_FAILURE);
	default:
		/* parent process */
		break;
	}
 
	/* only parent process gets here */
	waitpid(pid, &status, 0);
	if (WIFEXITED(status))
		printf("Child %d terminated normally, with code %d\n",
			pid, WEXITSTATUS(status));
 
	return status;
}
 
int main(void) {
	my_system("ls");
	return 0;
}

Copierea descriptorilor de fișier

dup duplică descriptorul de fișier oldfd și întoarce noul descriptor de fișier, sau -1 în caz de eroare:

int dup(int oldfd);

dup2 duplică descriptorul de fișier oldfd în descriptorul de fișier newfd; dacă newfd există, mai întâi va fi închis. Întoarce noul descriptor de fișier, sau -1 în caz de eroare:

int dup2(int oldfd, int newfd);

Descriptorii de fișier sunt, de fapt, indecși în tabela de fișiere deschise. Tabela este populată cu pointeri către structuri cu informațiile despre fișiere. Duplicarea unui descriptor de fișier înseamnă duplicarea intrării din tabela de fișiere deschise (adică 2 pointeri de la poziții diferite din tabelă vor indica spre aceeași structură din sistem, asociată fișierului). Din acest motiv, toate informațiile asociate unui fișier (lock-uri, cursor, flag-uri) sunt partajate de cei doi file descriptori. Aceasta înseamnă că operațiile ce modifică aceste informații pe unul dintre file descriptori (de ex. lseek) sunt vizibile și pentru celălalt file descriptor (duplicat).

Flag-ul CLOSE_ON_EXEC nu este partajat (acest flag nu este ținut în structura menționată mai sus).

Moștenirea descriptorilor de fișier după operații fork/exec

Descriptorii de fișier ai procesului părinte se moștenesc în procesul copil în urma apelului fork. După un apel exec, descriptorii de fișier sunt păstrați, excepție făcând cei care au flag-ul CLOSE_ON_EXEC setat.

Detalii despre flagul CLOSE_ON_EXEC

Detalii despre flagul CLOSE_ON_EXEC

fcntl

Pentru a seta flag-ul CLOSE_ON_EXEC se folosește funcția fcntl, cu un apel de forma:

fcntl(file_descriptor, F_SETFD, FD_CLOEXEC);

O_CLOEXEC

fcntl poate activa flag-ul FD_CLOEXEC doar pentru descriptori de fișier deja existenți. În aplicații cu mai multe fire de execuție, între crearea unui descriptor de fișier și un apel fcntl se poate interpune un apel exec pe un alt fir de execuție.

/ * THREAD 1 */                   |/ * THREAD 2 */    
fd = op_creare_fd()               |
                                  | exec(...)
fcntl(fd, F_SETFD, FD_CLOEXEC);   |

Cum, implicit, descriptorii de fișiere sunt moșteniți după un apel exec, deși programatorul a dorit ca acesta să nu poată fi accesat după exec, nu poate preveni apariția unui apel exec între creare și fcntl.

Pentru a rezolva această condiție de cursă, s-au introdus în Linux 2.6.27 (2008) versiuni noi ale unor apeluri de sistem:

int dup3(int oldfd, int newfd, int flags);
int pipe2(int pipefd[2], int flags);
int accept4(int sockfd, struct sockaddr *addr, socklen_t *addrlen, int flags);

Aceste variante ale apelurilor de sistem adaugă câmpul flags, prin care se poate specifica O_CLOEXEC, pentru a crea și activa CLOSE_ON_EXEC în mod atomic. Numărul din numele apelului de sistem, specifică numărul de parametri ai apelului.

Apelurile de sistem care creează descriptori de fișiere care primeau deja un parametru flags (e.g. open) au fost doar extinse să accepte și O_CLOEXEC.

Variabile de mediu în Linux

În cadrul unui program se pot accesa variabilele de mediu, prin evidențierea celui de-al treilea parametru (opțional) al funcției main, ca în exemplul următor:

int main(int argc, char **argv, char **environ)

Acesta desemnează un vector de pointeri la șiruri de caractere, ce conțin variabilele de mediu și valorile lor. Șirurile de caractere sunt de forma VARIABILA=VALOARE. Vectorul e terminat cu NULL.

getenv întoarce valoarea variabilei de mediu denumite name, sau NULL dacă nu există o variabilă de mediu denumită astfel:

char* getenv(const char *name);

setenv adaugă în mediu variabila cu numele name (dacă nu există deja) și îi setează valoarea la value. Dacă variabila există și replace e 0, acțiunea de setare a valorii variabilei e ignorată; dacă replace e diferit de 0, valoarea variabilei devine value:

int setenv(const char *name, const char *value, int replace);

unsetenv șterge din mediu variabila denumită name:

int unsetenv(const char *name);

Depanarea unui proces

Informații suplimentare legate de depanarea unui proces se găsesc aici

Exerciții

Pentru rezolvarea laboratorului, va rugam sa clonati repository-ul. daca il aveti deja, va rugam sa rulati git pull.

Pentru a vă ajuta la implementarea exercițiilor din laborator, în directorul utils din arhivă există un fișier utils.h cu funcții utile.

Exercițiul 1 - system (1p)

Intrați în directorul 1-system. Programul my_system.c execută o comandă transmisă ca parametru, folosind funcția de bibliotecă system. Modul de funcționare al system este următorul:

Compilați (folosind make) și rulați programul dând ca parametru o comandă.

Cum procedați pentru a trimite mai mulți parametri unei comenzi? (ex: ls -la)

Pentru a vedea câte apeluri de sistem execve se realizează, rulați:

 strace -e execve,clone -ff -o output ./my_system ls 

Revedeți secțiunea Înlocuirea imaginii unui proces în Linux și pagina de manual pentru execve .

Inlocuiti functia system cu execlp. Functia primeste ca parametru o comanda, lista de argumente (despartite de virgule) si apoi NULL (ultimul parametru, este de fapt marcatorul de sfarsit de lista).

Urmariti linia cu TODO 1.

Exercițiul 2 - parameters (2p)

Intrați în directorul 2-parameters.

Compilati programul parameters.c folosind comanda make. Ce face programul parameters.c?

Rezolvati exercitiul in fisierul program.c.

2a. system (1p)

Folositi functia system pentru a rula programul parameters cu cativa parametrii.

Pentru a rula un program din directorul curent, trecuie sa prefixati executabilul cu ./ (ex: ./parameters)

2b. execl (1p)

Inlocuiti functia system cu execl. De ce nu se afiseaza textul prin printf-ul de dupa execl?

Urmariti liniile cu TODO 2.

Exercițiul 3 - run (4p)

Intrați în directorul 3-run.

3a - fork, exec (1p)

Folsiti functiile fork si execl pentru a rula comanda ls din programul run.c.

Urmariti liniile cu TODO 1.

3b - waitpid (1p)

Asigurati-va ca textul “ls was run” este afisat dupa inchiderea programului programului ls. (Hint: waitpid)

Urmariti liniile cu TODO 2.

3c - exit status (1p)

Mutati si modificati linia cu TODO 3 astfel incat sa se afiseaza codul de iesire (exit code) al programului ls. (Hint: WEXITSTATUS)

3d - waitpid (1p)

Compilati programul exitcode.c folosind comanda make. Rulati programul si afisati codul de iesire. Modificati programul exitcode.c astfel incat sa intoarca alt cod de iesire.

Urmariti liniile cu TODO 4.

Exercițiul 4 - orphan (0.5p)

Intrați în directorul 4-orphan și inspectați sursa orphan.c.

Compilați programul (make) și apoi rulați-l folosind comanda:

​ ./​orphan 

Deschideți alt terminal și rulați comanda:

 watch -d '(ps -al | grep -e orphan -e PID)' 

Observați că pentru procesul indicat de executabilul ​orphan​ (coloana ​CMD​),​ pid-ul procesului părinte (coloana ​PPID​) devine 1, întrucât procesul este adoptat de ​init​ după terminarea procesului său părinte. De ce sunt doua procese orphan?

Exercițiul 5 - zombie (0.5p)

Intrați în directorul 5-zombie și inspectați sursa zombie.c.

Compilați programul (make) și apoi rulați-l folosind comanda:

​ ./zombie 

Deschideți alt terminal și rulați comanda:

 watch -d '(ps -al | grep -e orphan -e PID)' 

Observați că pentru procesul indicat de executabilul zombie​ coloana ​CMD​ devine zombie <defunct>. Ce se intampla de fapt?

Modificati fisierul zombie.c astfel incat procesul sa nu mai devina un zombie (Hint: waitpid).

Urmariti liniile cu TODO 1.

Exercițiul 6 - Tiny-Shell (3p)

Intrați în directorul 6-tiny.

Următoarele subpuncte au ca scop implementarea unui shell minimal, care oferă suport pentru execuția unei singure comenzi externe cu argumente multiple și redirectări. Shell-ul trebuie să ofere suport pentru folosirea și setarea variabilelor de mediu.

Observație: Pentru a ieși din tiny shell folosiți exit sau CTRL+D.

6a. Execuția unei comenzi simple (1p)

Creați un nou proces care să execute o comandă simplă.

Funcția simple_cmd primește ca argument un vector de șiruri ce conține comanda și parametrii acesteia.

Citiți exemplul my_system și urmăriți în cod comentariile cu TODO 1. Pentru testare puteți folosi comenzile:

 ./tiny
> pwd
> ls -al
> exit 

6b. Adăugare suport pentru setarea și expandarea variabilelor de mediu (1p)

Trebuie să completați funcțiile set_var și expand; acestea sunt apelate deja atunci când se face parsarea liniei de comandă. Verificarea erorilor trebuie făcută în aceaste funcții.

6c. Redirectarea ieșirii standard (1p)

Completați funcția do_redirect astfel încât tiny-shell trebuie să suporte redirectarea output-ului unei comenzi (stdout) într-un fișier.

Dacă fișierul indicat de filename nu există, va fi creat. Dacă există, trebuie trunchiat.

Citiți secțiunea Copierea descriptorilor de fișier și urmăriți în cod comentariile cu TODO 3. Pentru testare puteți folosi comenzile:

 ./tiny 
> ls -al > out
> cat out
> pwd > out
> cat out

Soluții

Solutii

Resurse utile