Java: un mediu de executare. Fire de executare*)Horia GeorgescuProgramarea obișnuită
presupune existența unui "dispozitiv" de executare a instrucțiunilor;
instrucțiunile unicului program se execută secvențial, pas cu pas,
de către unicul procesor existent. În viața reală asistăm în permanență
la mai multe acțiuni ce se desfășoară simultan. Unele dintre ele
sunt independente, altele au un grad mai mic sau mai mare de dependență:
încep în același timp, se așteaptă unele pe altele, sunt serializate
etc.
Calculatoarele moderne realizează, atât prin resurse software cât și hardware, desfășurarea concomitentă a mai multor acțiuni. Posibilitatea de a lansa și gestiona mai multe fire de executare este principala noțiune nou introdusă de sistemul de operare Windows 95. Acțiunile ce se desfășoară concomitent pot fi foarte variate: programe "tradiționale", secvențe de animație, sunet etc. În acest capitol vom presupune că acțiunile sunt secvențe de instrucțiuni. Pentru prelucrarea separată a fiecărei acțiuni, va fi lansat un fir de executare, care va prelua sarcina de a executa instrucțiunile respective. Interpretorul Java este capabil (de fapt se lucrează în prezent la această facilitate) să detecteze dacă mașina gazdă dispune de mai multe procesoare, caz în care acestea sunt efectiv folosite pentru executarea concomitentă a mai multor acțiuni. În cazul existenței unui singur procesor sau mai general, atunci când numărul de acțiuni ce trebuie executate depășește numărul de procesoare, executarea concurentă este simulată: unitatea centrală alocă pentru fiecare fir de executare cuante de timp.
Elementele legate de programarea firelor de executare fac parte integrantă din limbajul Java, în acest mod compilatorul Java putând "vedea" aceste fire. În acest mod este justificată afirmația că Java este un mediu de executare. Crearea firelor de executareO primă modalitate de a crea și lansa în executare fire (de executare) este de a folosi clasa Thread din pachetul java.lang. Crearea unui fir de executare se începe prin crearea unui obiect de tipul Thread: Thread fir = new Thread(); După ce obiectul a fost creat, el poate fi configurat și apoi se poate trece la executarea sa. Configurarea cuprinde elemente ca, de exemplu, precizarea unui nume, a unei priorități etc. Când firul este gata de executare, se invocă metoda sa start fără argumente. Ca urmare, firul de executare devine activ; mai precis, este executată metoda sa run. Când metoda run se încheie, firul de executare se termină. Terminarea poate fi realizată și explicit prin invocarea metodei stop. Menționăm că executarea mai poate fi suspendată cu metoda suspend sau manevrată în alte moduri, dintre care unele vor fi descrise în continuare. Stările în care se poate afla un fir de executare sunt următoarele: Un fir (de executare) este gata de executare dacă îndeplinește toate condițiile pentru a se trece la executarea sa, dar nu i s-a alocat încă un procesor; când un procesor liber preia firul, acesta devine activ. Blocarea unui fir de executare poate fi realizată pe baza unei condiții (de exemplu un semafor). Implementarea standard a metodei Thread.run nu prevede vreo acțiune. De aceea este necesar ca programatorul să extindă clasa Thread și să rescrie (suprapună) metoda run. Se impun câteva precizări privind firele de executare. Firul principal de executare folosește ca punct de plecare programul respectiv, cu precizarea clasei principale (ce are același nume ca fișierul corespunzător) și știe că trebuie să execute instrucțiunile din metoda "principală" main. Un fir de executare este lansat de un obiect ce instanțiază o clasă ce extinde clasa Thread. Fie Tip numele clasei și Ob numele obiectului. Lansarea se face prin apelul Ob.start(). Firul folosește (ca și firul principal) programul curent, cu deosebirea că acum "clasa principală" este considerată a fi instanțierea clasei Tip reprezentată prin obiectul Ob. În plus, firul de executare știe că trebuie să execute instrucțiunile din metoda "principală" run a clasei Tip. Să presupunem că în clasa Tip mai apare o metodă met. Trebuie clarificată diferența între apelul Ob.start() și apelul Ob.met(...). Apelul Ob.met(...)are următorul efect: obiectul Ob este "pus" să execute metoda met și nu se trece mai departe decât după ce această activitate a fost încheiată. În schimb, apelul Ob.start() realizează lansarea unui fir de executare pe baza obiectului Ob; deci obiectul Ob este folosit ca model de plecare pentru noul fir de executare și nu este "pus" să execute ceva. În continuare noul fir de executare își desfășoară activitatea concurent cu firul care l-a lansat. Obiectul Ob nu este "ocupat" cu vreo activitate, deci în continuare el poate fi "pus" să execute acțiuni. De exemplu, dacă apoi obiectul este pus să execute o metodă sau să modifice un câmp al clasei a cărei instanțiere este, el poate influența activitatea firului de executare lansat pe baza lui; această posibilitate este folosită frecvent pentru a controla terminarea firelor de executare. Exemplu: Să considerăm următorul program:
class Tip extends Thread { int k=0; boolean continua=true; public void run() { while (continua) System.out.print(" "+k++); System.out.println("Fir"); stop(); } public void terminare() { continua=false; } } class P1 { public static void main(String[] w) throws java.io.IOException { Tip Ob = new Tip(); Ob.start(); while ((char)System.in.read() != '\n'); Ob.terminare(); System.out.println("main"); } } Pe baza obiectului Ob este lansat un fir de executare. Activitatea acestuia constă în a tipări, atâta timp cât variabila booleană continua are valoarea true, șirul numerelor naturale. Firul de executare principal prevede citirea repetată de caractere, până când este introdus <Enter>; în acel moment obiectul Ob execută metoda terminare, al cărei efect este modificarea valorii lui continua în false, ceea ce va determina oprirea firului de executare lansat plecând de la obiectul Ob. Firul de executare principal se termină și el. Să remarcăm că nu este previzibil dacă mesajul "Fir" va fi tipărit înainte sau după mesajul "main". Un prim mod de sincronizareVom prezenta un prim mod de sincronizare, cu largă aplicabilitate în transcrierea în Java a algoritmilor paraleli. Să ne situăm în cazul în care un fir de executare lansează unul sau mai multe alte fire de executare și, prin natura problemei, trebuie să aștepte terminarea activităților acestora înainte de a întreprinde o nouă acțiune (în lipsa unei astfel de măsuri, firul principal își continuă activitatea în paralel cu firele pe care le-a lansat). Pentru rezolvarea problemei poate fi folosită metoda join a clasei Thread. Ea are următoarele două forme: · public final void join() throws InterruptedException · public final synchronized void join(long mili) throws InterruptedException Prima formă face ca firul de executare care a lansat la rândul său un fir de executare să aștepte terminarea acestuia un timp nelimitat, înainte de a trece la următoarea acțiune prevăzută în program. A doua formă limitează așteptarea la un număr specificat de milisecunde. Exemplu: Să considerăm următorul program: import java.util.*; class Tip extends Thread { static int k=1; public void run() { for (int i=0; i<100; i++) System.out.print(k+" "); stop(); } } class P2 { public static void main(String[] w) { Tip Ob = new Tip(); Ob.start(); try {Thread.sleep(100);} catch (InterruptedException e) { } Ob.k=2; try { Ob.join(); } catch(InterruptedException e) { } System.out.println("***"); } } După ce lansează un nou fir de executare pe baza obiectului Ob, firul principal își întrerupe activitatea 100 milisecunde, apoi modifică în 2 valoarea câmpului k al clasei Tip și așteaptă ca acesta să își încheie activitatea, înainte de a tipări 3 asteriscuri și de a-și termina la rândul său executarea. Drept urmare, la ieșire va apare un șir de 1, urmat de un șir de 2.
Firul principal de executare nu este cu nimic deosebit de cele pe care le lansează; în particular, aceasta înseamnă că firul principal, după ce lansează un fir, își poate încheia activitatea, întreaga aplicație încheindu-se atunci când firul nou lansat se termină. Afirmația de mai sus este valabilă pentru orice fir (nu neapărat cel principal) ce lansează alte fire. Este recomandabil ca un fir de executare care lansează alte fire să nu se termine înaintea acestora. Această recomandare face parte din disciplina de programare în Java. Despre programarea paralelăNumeroase aplicații științifice necesită în zilele noastre calculatoare cu putere de calcul ce depășește cu mult un miliard de instrucțiuni pe secundă. Simularea unor fenomene fizice (de exemplu previziunile meteorologice, predicțiile mareelor sau comportările aerodinamice) este limitată atât din punct de vedere al soluțiilor cât și al preciziei acestora de resursele de care se dispune din punct de vedere informatic. Performanța unui calculator se poate exprima fie prin numărul operațiilor în virgulă mobilă pe secundă (Mflops), fie prin numărul de instrucțiunilor pe care este capabil să le execute pe secundă (Mips). Încercarea de creștere a acestor performanțe, prin proiectarea unor circuite cât mai sofisticate, s-a izbit de anumite limitări fizice: disiparea unei cantități mari de energie într-un volum redus, probleme legate de accesul la memorie etc., cât și de costul mult prea ridicat al unor astfel de echipamente. O soluție alternativă, radical diferită, a fost utilizarea unor arhitecturi paralele. Ideea de bază de la care s-a pornit a fost introducerea unui număr mare de procesoare având o putere și un cost relativ scăzut, care cooperează la rezolvarea unei probleme ce necesită un calculator cu o putere mare de calcul. În dezvoltarea acestor arhitecturi, interesul s-a axat nu numai pe performanțele maxime ce pot fi atinse, ci și pe adaptabilitatea acestor echipamente la cerințele diverselor aplicații. Există astăzi un număr mare de calculatoare paralele și o diversitate de arhitecturi, unele fiind încă într-un stadiu experimental sau de prototip. Aceste arhitecturi se disting mai ales prin numărul și complexitatea procesoarelor utilizate: spectrul lor variază de la un număr scăzut de procesoare de putere ridicată, cum ar fi CRAY 2, la mii de procesoare simple cum ar fi Connection Machine. În momentul în care se pune problema proiectării unui sistem paralel trebuie ținut cont de o serie de aspecte: nivelul la care se realizează paralelismul, modul de comunicare între procesoare, topologia lor (modalitatea de interconectare). Fiecare din aceste aspecte poate constitui un criteriu de clasificare. În cele ce urmează vom preciza modelul de calcul paralel cu care vom lucra.
Un prim aspect este cel legat de modul în care procesoarele comunică între ele. În continuare vom folosi modelul P-RAM (Parallel Random Access Machine), care presupune că toate procesoarele au acces la o memorie comună. În acest mod nu se impun restricții ce țin de resursele fizice disponibile (hardware). Se presupune că fiecare procesor comunică direct în ambele sensuri cu memoria comună; dacă procesorul Pi dorește să transmită o valoare procesorului Pj, procesorul Pi va scrie valoarea într-o locație de memorie, iar Pj va citi valoarea din acea locație de memorie. P-RAM este un model cu memorie distribuită: un număr de procesoare lucrează sincron și comunică prin intermediul unei memorii comune dinamice (random access machine). Fiecare procesor poate fi accesat în mod aleator (uniform-cost random access machine) și poate efectua operații și instrucțiuni uzuale.
Un al doilea aspect este legat de activitatea procesoarelor. Vom alege modelul SIMD (Single Instruction, Multiple Data Stream) care presupune că toate procesoarele efectuează aceleași operații, dar pe seturi de date diferite. Menționăm doar că un model destul de folosit este și modelul MIMD (Multiple Instruction, Multiple Data Stream).
Un al treilea aspect se referă la drepturile pe care le au mai multe procesoare la scrierea și citirea concomitentă (paralelă) în/dintr-o locație de memorie. Mai precis, trebuie specificat dacă mai multe procesoare pot sau nu citi concomitent din aceeași locație de memorie, respectiv dacă mai multe procesoare pot scrie concomitent în aceeași locație de memorie. Vom presupune că folosim modelul CREW (Concurrent Read, Exclusive Write), care permite citirea concomitentă din aceeași locație de memorie, dar interzice scrierea concomitentă (concurentă) în aceeași locație de memorie. Forma generală a unei instrucțiuni paralele este:
for toți xÎX in parallel instrucțiune(x)
unde X este o mulțime finită. Semnificația acestei instrucțiuni este următoarea: - fiecărui element x din mulțimea X i se atribuie câte un procesor; - fiecare dintre aceste procesoare execută în paralel cu celelalte instrucțiunea menționată, bineînțeles pentru elementul xÎX respectiv. Executarea instrucțiunii paralele se termină când toate procesoarele și-au terminat propriile calcule. Această sincronizare este cea tipică programării paralele. Ea poate fi rezolvată folosind elementele din paragraful precedent. În paragraful următor vom exemplifica modul de implementare al instrucțiunii paralele pe o metodă generală de programare din calculul paralel. Prezentăm în continuare modul în care sunt atribuite și activate procesoarele la executarea instrucțiunii paralele. Atribuirea (asignarea) procesoarelor se realizează astfel: elementele lui X sunt (sau pot fi codificate ca) numere întregi. Atunci fiecărui element xÎX îi vom asocia procesorul Pcod(x), unde cod(x)Î{1,...,n} este codul corespunzător lui x; n este cardinalul mulțimii X. Se presupune că fiecare procesor Pi va fi "înștiințat" că trebuie să lucreze pentru x=cod-1(i). Ideea conform căreia un algoritm ce rulează pe p procesoare va determina soluția de p ori mai repede, este greșită. În primul rând este bine de știut că există probleme care sunt greu sau chiar imposibil de paralelizat, iar în interiorul unui proces vor exista instrucțiuni care se vor executa secvențial. Timpul de calcul paralel este perioada care s-a scurs de la inițierea primului proces și momentul când toate procesele au fost terminate. El depinde nu numai de complexitatea operațiilor, ci și de complexitatea comunicării și de sincronizare, respectiv de limitele schimbului de date. Performanțele unui program sunt desigur influențate și de numărul procesoarelor utilizate. Implementarea metodei arborelui binar în JavaEste vorba de o metodă generală, cu largă aplicabilitate, din programarea paralelă. Numele ei provine de la faptul că este utilizat un arbore binar complet. Reamintim că un arbore binar complet este un arbore binar cu 2k-1 vârfuri în care: - vârfurile sunt situate pe nivelurile 0,1,...,k-1; - vârfurile situate pe nivelurile 0,1,...,k-2 au exact doi descendenți, iar cele de pe nivelul k-1 sunt vârfuri terminale (frunze); - vârfurile de pe un nivel i oarecare sunt numerotate cu 2i..2i+1-1; descendenții vârfului i sunt vârfurile 2i și 2i+1. Fie vectorul a=(a1,...,an) și o operație asociativă Ä. Se urmărește calculul valorii a1Äa2Ä...Äan. Vom presupune că n este o putere a lui 2 (n=2m). În caz contrar vom completa vectorul la dreapta, până se obține un vector a cărui lungime este o putere a lui 2, cu elementul neutru al operației Ä:
Vom construi un arbore binar complet și vom atașa valori vârfurilor sale. Rădăcina corespunde calculului valorii finale cerută de problemă. Fiecare nod intern corespunde unei valori parțiale; la același nivel (adâncime în arbore) calculele se execută în paralel. Metoda este de tip bottom-up, adică vom parcurge arborele pe niveluri, plecând de la frunze și mergând spre rădăcină.
Într-o primă etapă dublăm dimensiunea vectorului și mutăm elementele date inițial pe pozițiile an,..,a2n-1 :
for i=1,n in parallel do an-1+i Ź ai {1}
În a doua etapă parcurgem arborele de jos în sus, atribuind valori vârfurilor interne. Valoarea fiecărui vârf se calculează în funcție de valorile descendenților săi, folosind operatorul Ä.
for k=m-1,0,-1 do for i=2k,2k+1-1 in parallel {2} ai Ź a2i Ä a2i+1
Să observăm că valoarea ai atașată unui vârf i este "suma" valorilor atașate frunzelor din subarborele de rădăcină i. Prin urmare rezultatul se obține în a1. Se folosesc n procesoare. Timpul este O(m)=O(log n), deoarece ciclul for interior necesită timp constant și se execută de m ori. Ilustrăm cele de mai sus pentru calculul sumei a n numere. Considerăm vectorul a=(3,7,8,3,9,2,3,1) cu n=8=23 și m=3. Operatorul Ä va fi în acest caz operatorul de sumare. Figura următoare ilustrează fiecare pas al algoritmului. Programul de mai jos transcrie în limbajul Java metoda arborelui binar. Clasa "principală" ArbBin conține câmpurile a (vectorul căruia i se aplică metoda), n (numărul elementelor vectorului) și m cu n=2m. Metoda principală începe cu citirea elementelor vectorului, folosind metodele clasei input. Pentru efectuarea calculelor {1}, adică pentru deplasarea elementelor vectorului pe pozițiile n..2n-1, este folosită clasa Tip1 ce extinde clasa Thread. Constructorul acesteia furnizează valorile n și i, iar metoda run prevede executarea calculului {1}. Sunt create n obiecte de tipul Tip1, pe baza cărora sunt lansate n fire de executare. Să observăm că pentru a folosi modelul P-RAM (cu memorie comună) vectorul a a fost declarat cu static (variabilă de clasă), astfel încât să poată fi referit din afară prefixat cu numele clasei ArbBin în care a fost declarat și creat. De asemenea remarcăm utilizarea metodei join pentru așteptarea terminării firelor de executare lansate. Pentru efectuarea prelucrării {2} se procedează în mod similar, folosind de această dată clasa Tip2, ce extinde și ea clasa Thread. import java.util.*; import java.io.*; import input;
class Tip1 extends Thread { protected int i; protected int n; public Tip1(int i, int n) { this.i=i; this.n=n; } public void run() { ArbBin.a[n+i]=ArbBin.a[i]; } } class Tip2 extends Thread { protected int i; public Tip2 (int i) { this.i=i; } public void run(){ ArbBin.a[i]=ArbBin.a[2*i]+ArbBin.a[2*i+1]; } } class ArbBin { static int a[]=new int[32]; static int n; static StreamTokenizer f = new StreamTokenizer(System.in); public static void main(String[] w) { int m=0; int i; System.out.println("m="); m = (int) input.read(f); System.out.println("Numerele: "); int n=1; for (i=0; i<m; i++) n *= 2; for (i=0; i<n; i++) a[i] = (int) input.read(f); //------------------------------------ Tip1[] Ob1 = new Tip1[n]; for (i=0; i<n; i++) Ob1[i] = new Tip1(i,n); for (i=0; i<n; i++) Ob1[i].start(); try {for (i=0; i<n; i++) Ob1[i].join();} catch (InterruptedException e) {; } //------------------------------------ Tip2[] Ob2 = new Tip2[n]; int p = n/2 , u = n; for (int k=m-1; k>=0; k--) { for (i=p; i<u; i++) Ob2[i] = new Tip2(i); for (i=p; i<u; i++) Ob2[i].start(); try {for (i=p; i<u; i++) Ob2[i].join();} catch(InterruptedException e) {;} for (i=p; i<u; i++) System.out.print(a[i]+" "); System.out.println(); p=p/2; u=u/2; } System.out.println("Suma="+a[1]); } } Despre programarea concurentăAm văzut că programarea paralelă pune accentul pe acțiunile ce trebuie executate de către fiecare procesor. Scopul programării paralele este de a elabora algoritmi care să speculeze existența mai multor procesoare pentru a micșora timpul de executare. Problemele sunt cele obișnuite din programarea secvențială, dar metodele de elaborare a programelor paralele vor fi evident diferite de cele din programarea secvențială. Executarea concurentă a mai multor procese (fire de executare) aproape întotdeauna cere o anumită cooperare între ele, deci sunt necesare anumite mecanisme care să asigure comunicarea și sincronizarea între procese. Specificarea cooperării între procese în termenii mecanismelor disponibile, deci scrierea unor programe concurente, constituie obiectul programării concurente. Este nevoie de un alt mod de gândire, de alte "unelte", de altă înțelegere a noțiunii de program corect. Esența programării concurente nu depinde de numărul procesoarelor fizice disponibile. Putem considera în permanență că dispunem de suficiente procesoare fizice pentru a atașa câte unul fiecăruia dintre procesele concurente. De asemenea putem considera că avem la dispoziție un singur procesor, care "alocă" proceselor intervale de timp aleatoare. Mai general, putem considera că este folosit modelul aleator, care constă din repetarea ciclică a următoarelor acțiuni de către fiecare procesor fizic: 1) alege aleator unul dintre procesele cărora nu îi este asociat un procesor fizic; 2) alege un interval de timp aleator și execută instrucțiuni ale sale. Nu se va face nici o presupunere asupra vitezei de lucru a procesoarelor. Fiecare procesor trebuie gândit ca având un ceas logic propriu. Cu alte cuvinte, orice supoziții asupra raportului vitezelor a două procesoare, ca și asupra ritmicității executării instrucțiunilor în cod mașină din cadrul aceluiași proces, sunt neavenite (nu ne ocupăm aici de sistemele concurente în timp real). În conformitate cu cele de mai sus, trebuie subliniat că programarea concurentă rezervă o serie de surprize informaticianului obișnuit cu programarea secvențială. Prezentăm în continuare câteva dintre ele. - În programarea secvențială, următoarea secvență de instrucțiuni: i=1; i=2; este echivalentă cu a doua instrucțiune; prezența ei într-un program trezește îndoieli asupra profesionalismului autorului programului. În programarea concurentă secvența de mai sus nu are nimic redundant. Într-adevăr, în intervalul de timp dintre executarea celor două instrucțiuni (în cadrul aceluiași proces) este posibil ca celelalte procese să execute diferite instrucțiuni care să folosească efectiv faptul că un anumit interval de timp (asupra lungimii acestuia nu putem face nici o ipoteză) valoarea lui i este egală cu 1.
- Efectul instrucțiunii: if (i==1) j=j+i în cazul în care valoarea lui i era 1 la începutul executării acestei instrucțiuni condiționale, nu constă neapărat în mărirea cu o unitate a valorii lui j, deoarece între momentul efectuării comparației și momentul efectuării atribuirii, valoarea lui i poate fi eventual modificată de un alt proces. Analog, dacă într-un proces apare secvența de instrucțiuni: i=1; if (i==1) instr; atunci, pe de o parte nu este neapărat adevărat că este îndeplinită condiția i=1 din instrucțiunea if, iar pe de altă parte nu este neapărat adevărat că în momentul executării instrucțiunii instr valoarea lui i este egală cu 1; în schimb afirmația "valoarea lui i a fost egală cu 1 la un moment de timp anterior" este adevărată și poate fi utilă (de exemplu în a demonstra că procesul are o anumită evoluție). Este ușor de observat că aceste comportări "anormale" din punctul de vedere al programării secvențiale își au cauza în utilizarea de către mai multe procese a unei arii (comune) de memorie.
Programarea concurentă se mai deosebește de programarea secvențială și prin faptul că programele pot avea comportare nedeterministă: "la executări diferite pentru același set de date de intrare, rezultatele nu vor fi neapărat aceleași". Astfel, dacă unul dintre procese execută instrucțiunile: i=1; i=i+3; iar un altul execută instrucțiunile: i=5; i=i-2; atunci printre valorile ce pot rezulta în final pentru i se numără de exemplu 3,4,6.
Programarea concurentă este menită să rezolve interacțiunile dintre procese. Atenția principală este îndreptată asupra modalităților în care pot fi realizate interacțiunile dorite și mai puțin asupra a ceea ce realizează programele secvențiale ce constituie fiecare proces în parte. Natura problemelor este diferită de cea întâlnită în programarea secvențială. Sunt tratate în special probleme de simulare a unor situații concurente reale și nu probleme care urmăresc obținerea unui rezultat numeric. Iată două exemple tipice de probleme întâlnite în programarea concurentă: 1) Problema rezervării biletelor: Fiecare terminal al unei rețele de calculatoare este plasat într-un punct de vânzare a biletelor pentru un anumit spectacol. Se cere să se găsească o modalitate de a evita vânzarea a două bilete pentru același loc. 2) Problema Producător - Consumator: Producătorul fabrică articole de un anumit tip și le plasează pe o bandă. Consumatorul ia câte un obiect de pe bandă și îl "consumă". Se cere să se simuleze aceste activități, evitându-se încercările producătorului de a plasa un articol pe bandă când aceasta este plină, precum și încercările consumatorului de a lua un obiect de pe bandă când aceasta este goală (dificultatea constă deci în realizarea unei sincronizări).
Vom numi secțiune critică o secvență de instrucțiuni a căror executare trebuie să se supună următoarei reguli: în momentul în care un proces P începe executarea primei instrucțiuni din secvență, nici un alt proces nu poate executa instrucțiuni din secvență înainte de terminarea executării ultimei instrucțiuni din secvență de către procesul P. În aceste condiții spunem că are loc o excludere reciprocă (cel mult un proces se poate afla într-o secțiune critică). Importanța și rolul secțiunilor critice vor fi puse în evidență pe un nou exemplu. Problema grădinilor ornamentale (atribuirii multiple). Intrarea în grădinile ornamentale ale unui oraș oriental se face prin mai multe porți. Se pune problema ținerii evidenței numărului total de persoane care au intrat în grădini.
O încercare de rezolvare este următoarea: - este introdusă variabila globală total, a cărei valoare curentă reprezintă numărul persoanelor ce au intrat în grădini; - fiecărei porți i se asociază în mod natural un proces, ce prevede ca în mod repetat valoarea variabilei total să fie mărită cu o unitate, corespunzător intrării unei persoane pe poarta respectivă.
Încercarea de mai sus nu este corectă: în final total poate avea o valoare diferită de cea așteptată; mai mult, la executări diferite ale programului ce transpune această încercare de rezolvare, se pot obține rezultate diferite. Explicația constă în primul rând în faptul că instrucțiunea total=total+1 este divizibilă (nu este o acțiune "atomică"), iar în al doilea rând în modul de executare al programelor concurente. Într-adevăr, instrucțiunea total=total+1 este în realitate o secvență formată din trei instrucțiuni în cod mașină:
LOAD total,r { încarcă total în registrul r al CPU } INC r { incrementează valoarea lui r cu 1 } STORE r,total { salvează valoarea registrului în total }
Pentru exemplificare, să considerăm cazul a două porți. Fie r1 și r2 registrele unității centrale folosite de cele două procese corespunzătoare. În cazul în care pe fiecare poartă intră trei persoane, este posibilă (printre multe altele) următoarea succesiune de acțiuni:
urmată de o succesiune analoagă în care se interschimbă p1 cu p2. Rezultatul final va fi 2, mai mic chiar decât numărul persoanelor ce intră prin fiecare poartă! Să observăm că dacă instrucțiunea total=total+1 (cele trei instrucțiuni mașină în care se descompune) ar forma o secțiune critică, rezultatul ar fi totdeauna corect. Rezolvarea excluderii reciproceVom rezolva în acest paragraf problema grădinilor ornamentale. Reamintim enunțul ei: Se cere să se țină evidența numărului de persoane ce intră pe cele n porți de acces în grădinile ornamentale. Programul de mai jos transpune ideea de rezolvare de mai sus, adică lansăm n fire de executare care prevăd fiecare mărirea de un număr dat de ori a unei variabile comune total. Pentru a simula modul în care se realizează efectiv incrementarea lui total cu o unitate (este utilizat un registru), este folosită variabila suplimentară temp. În plus, pentru a accentua caracterul nedeterminist, este folosită metoda yield cu zero argumente a clasei Thread. Efectul apelării lui yield din cadrul unui fir de executare este următorul: se dă o șansă unui alt fir de executare (care poate fi și cel curent) de a deveni activ.
import java.util.*; class Tip extends Thread { static int total=0; int i; Tip(int i) { this.i=i; } public void run() { int temp; for (int j=1; j<=10; j++) { System.out.print(" " + i); yield(); temp=total; yield(); temp++; yield(); total=temp; } } } class Gradini { public static void main(String[] w) { Tip[] Ob = new Tip[5]; for(int i=0;i<5;i++) Ob[i]=new Tip(i); for(int i=0;i<5;i++) Ob[i].start(); try{for(int i=4;i>=0;i--) Ob[i].join();} catch (InterruptedException e) { } System.out.println("Total="+Tip.total); } }
În urma executării programului de mai sus, doar în mod excepțional apare la ieșire valoarea 50 preconizată. Mai mult, valoarea tipărită la ieșire poate să difere de la o executare la alta. Explicația constă în faptul că total este o variabilă accesată simultan de mai multe fire de executare și în neatomicitatea operațiilor prevăzute de metoda run a clasei Tip. Pentru a realiza excluderea reciprocă asupra operației de incrementare a variabilei comune total, vom folosi facilitatea din limbajul Java de a declara o metodă cu modificatorul synchronized . Regula este următoarea: dacă mai multe fire de executare apelează o metodă declarată cu modificatorul synchronized prin intermediul aceluiași obiect, având ca tip clasa ce conține metoda respectivă, atunci la fiecare moment de timp cel mult un fir de executare este în curs de a executa acea metodă. Dacă un fir de executare este în curs de a executa metoda, el o va executa până la capăt; orice alt fir de executare ce invocă metoda, va fi blocat. Când un fir de executare termină metoda, atunci unul dintre firele de executare blocate va fi deblocat și va deveni el cel care deține "controlul" (exclusiv) asupra metodei. În programul de mai jos firele de executare apelează metoda numara, declarată cu synchronized, a clasei Contor prin intermediul aceluiași obiect C de tipul clasei Contor.
import java.util.*;
class Contor { static int total=0; synchronized void numara() { int temp; Thread.yield(); temp=total; Thread.yield(); temp++; Thread.yield(); total=temp; } }
class Tip extends Thread { int i; Contor C; Tip(int i, Contor C) { this.i=i; this.C=C; } public void run() { int temp; for (int j=0; j<10; j++) { C.numara(); System.out.print(i+"\t"); } } }
class Gradini1 { public static void main(String[] w) { Contor C = new Contor(); Tip[] Ob = new Tip[5]; for (int i=0; i<5; i++) Ob[i] = new Tip(i,C); for (int i=0; i<5; i++) Ob[i].start(); try {for (int i=4; i>=0; i--) Ob[i].join();} catch (InterruptedException e) { } System.out.println("Total = "+C.total); } } Interfața RunnableInterfața Runnable apare în pachetul java.lang (deci nu necesită o specificare explicită prin import). Această interfață permite executarea unui cod ca parte a unui fir de executare activ. Ea cuprinde o singură metodă și anume: public void run() care este executată când firul este activ. Metoda run nu prevede nici o acțiune și trebuie extinsă în clasa ce implementează Runnable. Menționăm că printre diferitele clase care implementează Runnable se află și clasa Thread. Utilitatea interfeței Runnable constă în primul rând în însuși faptul că este o interfață: o clasă oarecare poate implementa Runnable și extinde o altă clasă (pe când o clasă ce extinde Thread nu mai poate extinde vreo altă clasă). Modul tipic de utilizare este următorul:
class Execut implements Runnable { ... public void run { ... } ... }
class Clasa { ... Execut Ob=new Execut(...); Thread Fir=new Runnable(Ob); // * Fir.start(); // ** ... } Astfel, este creat (*) și apoi este lansat (**) firul de executare Fir. Pentru ilustrare, reluăm exemplul 1 din acest capitol, utilizând interfața Runnable în loc de clasa Thread.
class Execut implements Runnable { int k=0; boolean continua=true; public void run() { while (continua) System.out.println(" "+k++); System.out.println("Fir"); } public void terminare() { continua=false; } }
class RunFir { public static void main(String[] arg) throws java.io.IOException { Execut ob = new Execut(); Thread fir = new Thread((Runnable) ob); fir.start(); while((char) System.in.read() != '\n'); ob.terminare(); System.out.println("main"); } } O soluție pentru problema Producător-ConsumatorTrecem în continuare la un alt mod de sincronizare, ce are ca model problema Producător - Consumator. Reamintim enunțul ei: Un producător și un consumator își desfășoară în același timp activitatea, folosind în comun o bandă de lungime lung. Producătorul produce obiecte și le plasează la un capăt al benzii. Consumatorul ia câte un obiect de la celălalt capăt al benzii și îl consumă.
Dificultatea problemei constă în faptul că producătorul și consumatorul pun/iau obiecte de pe bandă în ritmuri imprevizibile, ceea ce poate conduce la următoarele situații limită: - producătorul încearcă să pună un obiect în banda plină; - consumatorul încearcă să ia un obiect din banda vidă.
Clasa Time furnizează metoda delay care întoarce un număr real cuprins între 0 și 100. Ea va fi folosită pentru a accentua nedeterminismul, operațiile de punere și luare pe/de pe bandă fiind întârziate un timp aleator. Clasa Banda ține evidența cozii corespunzătoare benzii: vectorul coada are lungimea lung, primul său element este pe poziția p, ultimul pe poziția u, iar numărul elementelor de pe bandă este n. Metoda ia realizează extragerea unui element de pe bandă, iar metoda pune corespunde introducerii unui nou element pe bandă; elementele sunt caractere. Metodele ia și pune sunt declarate cu modificatorul synchronized . Obiectul P de tipul Prod, clasă ce implementează interfața Runnable, lansează un fir de executare corespunzător producătorului; sunt produse literele mici ale alfabetului și se încearcă plasarea lor pe bandă prin invocarea metodei pune. Obiectul C de tipul Cons, clasă ce implementează și ea interfața Runnable, lansează un fir de executare corespunzător consumatorului; prin invocarea metodei ia se încearcă preluarea de caractere de pe bandă, până când s-a luat 'z'. Cele două fire de executare folosesc același obiect b de tipul Banda pentru invocarea metodelor ia și pune. Sincronizarea este asigurată prin folosirea metodelor: - wait(), care face ca firul de executare curent să fie blocat; în același timp este ridicată excluderea reciprocă; - notify(), care cedează controlul unui alt fir de executare ce este blocat; în același timp este ridicată excluderea reciprocă. Firul curent este blocat. Dacă nici un alt fir de executare nu așteaptă (nu este blocat), efectul este nul.
import java.util.*;
class Time extends Random { float delay() { return 100.0f*nextFloat(); } }
class Banda { static int lung=3, n=0, p=0, u=lung-1; static char[] coada = new char[lung]; static String out = ""; static Time ObT = new Time(); synchronized char ia() { char c; while (n == 0) { try { wait(); } catch (InterruptedException e) {} } c = coada[p]; p = (p+1)%lung; n--; out += " I" + c; notify(); return c; }
synchronized void pune(char ch) { while (n == lung) { try { wait(); } catch (InterruptedException e) {} } u=(u+1)%lung; coada[u]=ch; n++; out+=" P"+ch; notify(); } }
class Prod implements Runnable { static Banda b; Prod(Banda bb) { b = bb; } public void run() { float d; char ch; for (ch='a'; ch<='z'; ch++) { b.pune(ch); try {d=Banda.ObT.delay(); sleep((int)d); } catch (InterruptedException e) {} } } }
class Cons implements Runnable { static Banda b; Cons(Banda bb) {b=bb;} public void run(){ float d; char ch; do { ch=b.ia(); try{ d=Banda.ObT.delay(); sleep((int)d); } catch (InterruptedException e) {} } while (ch != 'z'); } }
class PC { public static void main(String[] w) { Banda b=new Banda(); Prod P=new Prod(b); Cons C=new Cons(b); Thread FirP=new Thread(P); FirP.start(); Thread FirC=new Thread(C); FirC.start(); try { FirP.join(); FirC.join(); } catch (InterruptedException e) {} System.out.println(Banda.out); } }
Este de asemenea utilizat un șir de caractere out în care sunt adăugate semnalizările corespunzătoare punerii unui element pe bandă, respectiv preluării unui element de pe bandă. Clișeul standard de folosire a metodei wait este următorul: while (! condiție) wait(); și a fost folosit ca atare în programul de mai sus deși, prin natura problemei, puteam înlocui while cu if. După cum se vede, metodele wait și notify pot lansa excepția cu numele: InterruptedException. Mai menționăm că metoda wait admite și varianta cu un argument, ce este un număr de tip long, reprezentând durata maximă de așteptare.
Prof. dr. Horia Georgescu este cadru didactic la Universitatea București; poate fi contactat prin e-mail la adresa: hg@colegiu.cs.unibuc.ro [cuprins] |