Sari la conținutul principal

Paralelism, concurenta si fire de executie

De-a lungul progresului tehnologic al calculatoarelor a fost necesar ca acestea sa proceseze diverse date mai rapid sau sa proceseze diferite actiuni declansate de utilizatori in acelasi timp intr-un timp rezonabil. Din acest motiv atat procesoarele cat si programele au fost gandite sa suporte procesare paralela atunci cand nu mai era posibila imbunatatirea performantelor prin imbunatatirea performantelor fizice ale procesoarelor sau prin descoperirea de algoritmi mai rapizi.

Astfel, au aparut procesoare multi-core prin care mai multe intructiuni puteau fi executate in mod paralel iar multe limbaje vin cu biblioteci standard care sa profite de acest lucru implementand API-uri care sa usureze munca programatorilor de a reprezenta programatic lucrul paralel.

Vom intra in cateva clase folosite pentru programarea paralala si concurenta, nu este o introducere in calculul paralel, pentru acest lucru urmariti un curs in acest sens, vom arata doar cum se poate lucra obiectual cu fire de executie si task-uri.

Paralelism si concurenta

Trebuie facuta distinctia intre paralelism si concurenta, in ambele cazuri mai multe instructiuni sunt executate la un moment data intr-o ordine nedeterminata cu o eventuala astepatare pentru terminarea executarii tututor instructiunilor. Diferenta este ca daca intructiunile se executa pe diferite core-uri ale procesoarelor in acelasi timp vorbim de paralelism, daca instructiunile se executa in mod paralel pe mai multe core-uri sau alternativ in mod secvential pe un core intr-o ordine oarecare vorbim de concurenta, practic concurenta este un concept care inglobeaza si este mai general ca paralelismul.

In ce priveste suportul pentru programarea paralela in C# exista clasa Thread care abstractizeaza un fir de executie (thread). Un fir de executie in cadrul unui proces este definit printr-un stack pointer si un instruction pointer propriu, adica unde se afla pe stiva programului firul de executie si la ce intructiune se afla, este exact acelasi lucru ca un proces in cadrul sistemului de operare dar fata de procese, firele de executie au la comun in cadrul aceluiasi proces spatiul virtual de adrese.

📄️ Clasa Thread

Cea mai simpla metoda de a crea un thread este sa folosim clasa Thread. Aceasta ia ca argument in constructor o functie fara parametri sau o functie cu un parametru object? si care nu returneaza nimic. Dupa crearea unei instante pentru Thread trebuie apelate doua metode, prima fiind Start() care acesta porneste efectiv firul de executie si apeleaza functia din acel fir in paralel cu executia firului curent de executie. Dupa lansarea thread-ului, fluxul de instructiuni paralel trebuie unificat inapoi in cel principal folosind metoda Join() din thread-ul care l-a creat, thread-ul v-a astepta terminarea celuilalt thread si il va inchide. Astfel putem crea thread-uri ca in exemplul urmator, folositi functii lamda ca sa transmiteti date si sa returnati date prin obiecte.

📄️ Clasa Task

In general se prefera sa nu partajati date decat daca e absolut necesar, pentru majoritatea aplicatiilor nu se foloseste modelul de date partajate si in general se prefera un stil de programare asincron si functional unde fiecare unitate de lucru (task) se realizeaza doar cu date proprii in cadrul unui thread. In multe aplicatii datele care sunt partajate in mare parte provin din alte aplicatii care sunt optimizate pentru procesarea paralela si concurenta cum sunt de altfel si bazele de date care se asigura de consistenta (chiar si in cele din urma) a datelor. Din acest motiv, multe aplicatii multi-threaded sunt gandite sa folosesca cat mai putin modelul de date partajate in special pentru a simplifica logica aplicatiei, a marii calitatea codului si reduce probabilitatea aparitiei erorilor.

📄️ Bonus - producator-consumator

Ca si problema clasica ce se poate discuta legat de paralelism este problema producator-consumator. Problema se pune in felul urmator, avem doua tipuri de thread-uri, producatori si consumatori care comunica printr-un buffer cu o limita de elemente, producatorii pun in buffer valori iar consumatorii le scot. Cand se face accesul pe buffer vrem ca bufferul sa fie accesat in mod exclusiv ca stuctura acestuia sa fie integra, producatorii sa atepte cand buffer-ul e umplut si nu mai pot produce iar consumatorii sa astepte cand nu e nimic in buffer de consumat.