Fire de Executie. Aspecte Privind Interfata Grafica Swing
Cuvânt înainte
Limbajul de programare Java s-a născut din nevoia de a avea un limbaj adecvat unei noi ere în lumea informatică și anume Internet-ul. Frima Sun în cadrul căreia s-a dezvoltat proiectul Java(inițial Oak), este o firmă foarte cunsocută în lumea server-elor de mare putere și a soluțiilor oferite în domeniul rețelelor. Viziunea firmei Sun este aceea că rețeua este calculatorul. Ea promovează continuu această viziune prin proicteele de cercetare pe care le dezvoltă. Calculatorul viitorului nu va mai fi decât un terminal conectat la un server foarte puternic printr-o conexiune de mare viteză. În acest fel calculatorul de rețea este foarte ieftin însă existența acelui server de mare putere este un aspect costisitor și deocamdată destul de rar de găsit în realitate.
Din această viziune a firmei Sun s-a născut și limbajul Java. Inițial acesta a fost proiectat pentru a programa aparatură electro-casnică conectată în rețea. Apoi, datorită potențialului limabjului și a dezvoltării Internet-ului începând din anul 1995, Sun a lansat prima versiune de Java exact în acest an. De atunci și până astăzi s-au scos patru versiuni majore fiecare din ele reprezentând un pas foarte mare. Astăzi limabjul Java este unul foarte dinamic. Tehnologia Java se află încă în creuzet și se investește foarte mult în ea căci se potrivește mănușă conceptului de calculator de rețea. Astăzi tehnologia Java pune la dispoziție un limbaj foarte puternic și concepte bine structurate. Marea calitate a limbajului este portabilitatea. Programatorul este eliberat de coșmarul portării pe alte platforme sau chiar în cadrul aceleași platforme. Java este primul limbaj care oferă un solid și demn de încredere mecanism de colectare automată a rezidurilor(memoriei rămasă inutizabilă). Este pentru prima dată când în cadrul aceluiași limbaj s-au regăsit îmbinate armonios atât de multe aspecte necesare unei programări din zilele noastre: programare paralelă și concurentă, programare distribuită(invocare la distanță), programare de rețea, programarea interfețelor grafice și multe alte aspecte specializate. Arhitectura limbajului este foarte flexibilă și permite dezvoltarea ușoară a unor noi module ducând la o extensibilitate sporită. Java este un limbaj vizionar și inovator.
Revenind scopul acestei lucrări(prezentarea limbajului ca mediu pentru programarea concurentă și paralelă), se poate afirma fără tăgadă că în Java se programează mult mai ușor aplicații multithreaded. Însă aceasta nu reduce dificultatea programării paralele sau concurente. În această lucrare, structurată pe trei capitole, se încearcă prezentarea într-un mod coerent a principalelor aspecte legate de limbajul Java ca mediu de programare concurentă și paralelă.
Astfel în primul capitol se expune modul în care mașina virtuală implementează firele de execuție, modul în care acestea interacționează cu memoria și care sunt mecansimele de sincronizare. În al doilea capitol sunt schițate o serie de tehnici de programare în ceea ce privește firele de execuție, iar în ultimul capitol se discută un aspect mai specializat al firelor de execuție legat de interfața grafică Swing.
1. FIRE DE EXECUȚIE. MONITOARE.
MECANISME DE SINCRONIZARE
În timp ce multe limbaje de progamare oferă suport pentru programarea paralelă și concurentă doar prin includerea unor librării speciale care oferă suport pentru acest tip de programare, limbajul Java asigură această facilitate direct prin construcția sa sau, cum se spune în limba engleză, suportul pentru programare paralelă și concurentă (multithreading) este builtin.
În Java unitatea de programare este firul de execuție, Mașina Virtuală Java(Java Virtual Machine-JVM) suportând mai multe fire de execuție la un moment dat, fiecare fir de execuție având stiva proprie a apelurilor de funcții. Firele de execuție sunt independente unul de celălalt și fiecare execută cod Java folosind aceeași zonă de memorie în care stochează valori și obiecte. Din punct de vedere al hardware-ului, firele de execuție pot fi implementate fie prin existența mai multor procesoare, fie prin divizarea timpului procesor în cuante de timp, procesorul urmând să simuleze execuția în paralel prin distribuirea acestor cuante fiecărui fir de execuție la un moment dat.
Deși Java suportă fire de execuție al căror cod se execută independent, ceea ce poate duce la accesul concurent asupra resurselor, exista mecanisme de sincronizare care asigură programelor care utilizează mai multe fire de execuție, un comportament determinist și consistent relativ la utilizarea memoriei comune tuturor firelor de execuție. Pentru a sincroniza firele de execuție, Java folosește ceea ce noi numim monitoare, acestea fiind un mecanism de nivel înalt folosit pentru a permite execuția unei porțiuni de cod protejată de un monitor, doar de către un singur fir de execuție la un moment dat. Este bine cunoscut faptul că orice clasă în Java are că supraclasă clasa Object. Clasa Object este clasa părinte a tuturor claselor și orice clasă nouă scrisă are automat ca supraclasă clasa Object. Folosind acest aspect de design, proiectanții limbajului au asociat fiecărui obiect un monitor. Sincronizarea firelor de execuție se va face utilizând acest monitor.
Există în limabjul Java un cuvânt cheie numit synchronized. El reprezintă o primitivă al cărei sens este legat numai de firele de execuție. Ea realizează două acțiuni importante relativ la execuția unui bloc de cod sincronizat:
înainte de execuția blocului sincronizat, se obține o referință la obiectul pe care se face sincronizarea și închide monitorul asociat acestui obiect
apoi, după terminarea execuției blocului sincronizat, fie în mod normal sau prin generarea unei excepții, se deschide același monitor închis anterior
O metodă a unei clase poate fi declarată ca fiind sincronizată prin următoarea declarație:
<modificator>synchronized <modificator>
<nume_metodă> (<parametrii formali>)
<throws><excepții generate>
Această nu este decât o convenție căci ea este echivalentă cu a sincroniza corpul metodei folosind referința this. Vom vedea însă că există cazuri când se dorește evitarea sincronizării pe referința this, deoarece pot apărea blocări ale firelor de execuție, așa numitele deadlock.
În clasa Object există trei metode care sunt cruciale pentru consistența programării concurente și anume:
wait, notify, notifyAll
Ele ofera un suport eficient de transfer al controlului de la un fir de execuție la altul. Decât să se folosească o comutare de genul închidere-deschidere a unui monitor al unui obiect, ceea ce ar duce la consum de timp procesor, un fir de execuție se poate suspenda pe sine insuși folosind metoda wait până când un alt fir de execuție îl va readuce în starea gata de execuție prin metoda notify sau notifyAll. Acest din urma mecanism este în special adecvat în situațiile în care firele de execuție se raportează unele la celelalte printr-o relație de tip producător-consumator(cooperarea lor pentru îndeplinirea unui scop comun). Este mai puțin adecvat pentru o relație de excludere reciprocă(atunci când ele trebuie să folosească memoria comună într-un mod consistent). Pentru această din urmă situație am văzut că este potrivită primitiva synchronized.
În timpul execuției codului său, un fir de execuție realizează o serie de acțiuni. Un fir de execuție poate folosi valoarea unei variabile sau poate să îi atribuie o nouă valoare. Alte acțiuni sunt operațiile aritmetice, testele condiționale, invocare de metode, dar menționăm că aceste acțiuni nu implică în mod direct variabilele. Dacă două sau mai multe fire de execuție accesează în mod concurent o variabilă, există posibilitatea ca rezultatul să depindă de timpul la care s-a făcut accesul. Această dependență de timp este specifică și inerentă programării concurente fiind unul dintre puținele aspecte nedeterministe ale limbajului Java.
Fiecare fir de execuție are propria sa memorie de lucru, în care păstrează copii ale valorilor variabilelor din memoria principală la care au acces toate firele de execuție de pe mașina virtuală. Pentru a accesa o variabilă, un fir mai intai va obține un monitor și va vida memoria sa de lucru. Astfel se garantează că valoarea variabilei din memoria comună va fi într-adevar scrisă în memoria de lucru a firului. Când firul deschide monitorul se garantează că el va scrie valoarea din memoria sa de lucru înapoi în memoria comună.
Scopul acestui capitol este de a explica interacțiunea firelor de execuție cu memoria principală și, în consecință, interacțiunea dintre ele, prin intermediul unor acțiuni/operații de nivel jos. Există o serie de reguli cu privire la ordinea în care aceste acțiuni pot să apară. Aceste reguli sunt garantate de către orice implementare a limbajului Java și programatorul poate să aibă încredere în respectarea acestor reguli. Aceste reguli lasă un anumit grad de libertate celor care implementează limbajul pentru a permite acestora să facă o serie de optimizări relativ la eficiența codului executat concurent.
Cele mai importante consecințe ale respectării regulilor sunt:
utilizarea construcțiilor sincronizate asigură transmiterea corectă a valorii variabilelor de la un fir de execuție la altul prin intermediul memoriei comune
când un fir folosește valoarea unei variabile, valoarea aestei variabile este o valoare stocată de către un alt fir de execuție; aceasta este adevarat chiar și în cazul absenței explicite a sincronizării; de exemplu, dacă două fire de execuție stochează în aceeași variabilă, referințe diferite, atunci variabila va conține cu siguranță valoarea uneia dintre referințe, și nu va conține o referința la un alt obiect sau o referință coruptă(există o excepție relativ la valorile de tip long și double)
în absența sincronizării explicite, o implementare a limbajului Java este liberă să reactualizeze memoria comună într-o ordine nu neapărat așteptată de către programator; de aceea pentru a preveni consecințe nedorite este recomandată utilizarea sincronizării explicite
1.1 Terminologia și cadrul de lucru
O variabilă este o locație de memorie în care un program Java poate să stocheze o valoare. Deci o variabilă nu include numai variabile care reprezintă referințe obiecte instanță a unor clase sau tipuri primitive ci și tablouri(arrays). Variabilele sunt păstrate în memoria principală la care au acces toate fire de execuție. Deoarece un fir nu poate accesa parametrii sau variabilele locale ale altui fir, nu contează dacă gândim acești parametrii și aceste variabile ca fiind stocate în memoria comună sau în memoria de lucru a firului respectiv.
Fiecare fir de execuție are o memorie de lucru în care păstrează propria sa copie de lucru a unei variabile pe care fie o va folosi sau fie îi va atribui o valoare. Memoria principală conține copia principală a fiecarei variabile. Există reguli care regelmentează modul în care un fir transferă conținutul memoriei de lucru în memoria principală și viceversa.
Memoria principală mai conține și monitoare; pentru fiecare obiect există un monitor asociat. Firele pot concura pentru obținerea unui monitor.
În continuare vom folosi verbele use, assign, load, store, lock și unlock pentru a desemna acțiunile pe care un fir le poate face. Verbele reade, write, lock și unlock denumesc acțiuni pe care memoria principală le execută. Fiecare din aceste acțiuni este atomică sau indivizibilă.
O acțiune use sau assign este o interacțiune strânsa între motorul firului de execuție și memoria de lucru a firului. O acțiune lock sau unlock este o interacțiune strânsa între motorul firului de execuție și memoria principală. Însă transferul de date între memoria de lucru și cea principală reprezintă o acțiune slab corelată. Când datele din memoria principală sunt copiate în memoria de lucru, două acțiuni trebuie să apară: acțiunea read executată de memoria principală și acțiunea load executată de memoria de lucru la un moment ulterior. Când datele din memoriastrate în memoria principală la care au acces toate fire de execuție. Deoarece un fir nu poate accesa parametrii sau variabilele locale ale altui fir, nu contează dacă gândim acești parametrii și aceste variabile ca fiind stocate în memoria comună sau în memoria de lucru a firului respectiv.
Fiecare fir de execuție are o memorie de lucru în care păstrează propria sa copie de lucru a unei variabile pe care fie o va folosi sau fie îi va atribui o valoare. Memoria principală conține copia principală a fiecarei variabile. Există reguli care regelmentează modul în care un fir transferă conținutul memoriei de lucru în memoria principală și viceversa.
Memoria principală mai conține și monitoare; pentru fiecare obiect există un monitor asociat. Firele pot concura pentru obținerea unui monitor.
În continuare vom folosi verbele use, assign, load, store, lock și unlock pentru a desemna acțiunile pe care un fir le poate face. Verbele reade, write, lock și unlock denumesc acțiuni pe care memoria principală le execută. Fiecare din aceste acțiuni este atomică sau indivizibilă.
O acțiune use sau assign este o interacțiune strânsa între motorul firului de execuție și memoria de lucru a firului. O acțiune lock sau unlock este o interacțiune strânsa între motorul firului de execuție și memoria principală. Însă transferul de date între memoria de lucru și cea principală reprezintă o acțiune slab corelată. Când datele din memoria principală sunt copiate în memoria de lucru, două acțiuni trebuie să apară: acțiunea read executată de memoria principală și acțiunea load executată de memoria de lucru la un moment ulterior. Când datele din memoria de lucru sunt copiate în memoria principală, două acțiuni trebuie să apară: acțiunea store executată de memoria de lucru și acțiunea write executată de memoria principală la un moment ulterior. Astfel poate exista un anumit timp între cele două perechi de acțiuni, iar acest timp poate diferi de la o situație la alta. Astfel acțiunile realizate de un fir asupra unor variabile pot apărea într-o altă ordine în alt fir de execuție. Însă, pentru fiecare variabilă, acțiunile pe care le execută memoria principală ca urmare a acțiunilor executate de un anumit fir, apar în aceeași ordine oricăror altor fire.
Un singur fir de execuție initiază tipurile de acțiuni use, assign, lock și unlock după cum dictează semantica programului executat. Implementarea Java este obligată să asigure executarea acțiunilor corespunzătoare load, store, read și write astfel încât să fie resepectate o serie de reguli expuse pe larg mai târziu. Dacă implementarea respectă aceste reguli și programatorul respectă anumite uzanțe atunci datele pot fi transferate între fire în mod consistent prin intermediul variabilelor comune. Regulile sunt făcute astfel încât să ducă la realizarea acestui deziderat, dar sunt suficient de relaxate astfel încât să permită celui care implemntează limbajul să realizeze o serie de optimizări prin mecanisme ca regiștrii, cozi și cache-uri.
Dăm mai jos definițiile precise ale acțiunilor menționate anterior:
o acțiune use(executată de către un fir) transferă conținutul unei variabile din memoria de lucru a firului motorului acestuia; aceasta acțiune este executată ori de câte ori un fir execută o instrucțiune a mașinii virtuale care utilizează valoarea unei variabile.
o acțiune assign(executată de către un fir) transferă o valoare de la motorul firului într-o variabilă din memoria de lucru; această acțiune este executată ori de câte ori un fir execută o instrucțiune a mașinii virtuale care atribuie o valoare unei variabile.
o acțiune read(executată de memoria principală) transmite conținutul unei variabile din memoria principală, memoriei de lucru a unui fir pentru a fi folosită de către o acțiune load ulterioară.
o acțiune store(executată de către un fir) transmite conținutul unei variabile din memoria de lucru, memoriei principale pentru a fi folosită de către o acțiune write ulterioară
o acțiune write(executată de memoria principală) pune o valoare transimsa de către memoria de lucru a unui fir printr-o acțiune store în memoria principală
o acțiune lock(executată de către un fir în strânsă corelare cu memoria principală) determină un anumit fir să ceară posesiunea unui anumit monitor
o acțiune unlock(executată de către un fir în strânsă corelare cu memoria principală) determină un anumit fir să cedeze posesiunea unui anumit monitor
Astfel interacțiunea unui fir cu o variabilă, pe parcursul execuției unui program consistă dintr-o secvență de acțiuni use, assign, load și store. Memoria principală execută o acțiune read pentru fiecare acțiune load și o acțiune write pentru fiecare acțiune store. Interacțiunea unui fir cu un monitor consistă într-o secvență de acțiuni lock și unlock. Tot comportamentul vizibil al firelor de execuție include și este consecință a acțiunilor pe care ele le fac asupra variabilelor și monitoarelor.
Ordinea de execuție
Regulile de execuție se referă la modul în care sunt constrânse apariția unor acțiuni. Există patru astfel de constrângeri generale:
acțiunile executate de orice fir sunt total ordonate; astfel pentru oricare două acțiuni executate de către fir, una din ele o precede pe cealaltă;
acțiunile executate de către memoria principală relativ la orice variabilă sunt total ordonate; astfel pentru oricare două acțiuni executate de către memoria principală asupra aceleași variabile, una din ele o precede pe cealaltă;
acțiunile executate de către memoria principală relativ la orice monitor sunt total ordonate; astfel pentru oricare două acțiuni executate de către memoria principală asupra aceluiași monitor, una din ele o precede pe cealaltă;
nu este permis nici unei acțiuni să urmeze ei însăși
Ultima regulă poate părea trivială însă prezența ei este necesară pentru asigurarea completitudinii. Fără ea, ar fi posibilă propunerea unei mulțimi de acțiuni executate de către două sau mai multe fire de execuție astfel încât toate regulile de mai sus ar fi îndeplinite mai puțin ultima și să fie posibilă apariția unei acțiuni după ea însăși.
Firele de execuție nu interacționează direct; ele comunică numai și numai prin memoria principală la care au acces toate firele de execuție. Relațiile dintre acțiunile executate de către un fir și acțiunile executate de către memoria principală sunt constrânse în trei moduri:
fiecare acțiune de tip lock sau unlock este executată de către un fir anume și memoria principală în strânsă corelare
fiecarei acțiuni de tip load executată de către un fir, îi corespunde o unică acțiune read executată de către memoria principală, astfel încât acțiunea load urmează acțiunii read
fiecărei acțiuni de tip store executată de către un fir, îi corespunde o unică acțiune write executată de către memoria principală, astfel încât acțiunea write urmează acțiunii store
Majoritatea regulilor prezentate în următoarele secțiuni se referă la anumite constrângeri referitoare la ordinea în care pot apărea anumite acțiuni. În general, o regulă poate impune ca o anumită acțiune să preceadă o altă acțiune diferită de prima, cum am văzut. Această duce la apariția unei relații de ordine tranzitivă. Astfel, dacă acțiunea A trebuie să preceadă acțiunea B și acțiunea B trebuie să preceadă acțiunea C, atunci acțiunea A trebuie să precedă acțiunea C în mod necesar. Dacă nici o regulă sau compunere de reguli nu impune ca o acțiune A să preceadă o altă acțiune B, atunci implementarea este liberă să execute acțiunea B înaintea acțiunii A sau concurent cu acțiunea A. Acestă libertate este o sursă ce poate fi exploatată pentru obținerea de performanță. O implementare este liberă să speculeze această libertate, dar nu este obligată.
În cele ce urmeză, prin fraza “B trebuie să intervină între A și C” vom înțelege că acțiunea B trebuie să urmeze acțiunii A și să preceadă acțiunea C.
Reguli referitoare la variabile
Fie T un fir de execuție și V o variabilă. Există o serie de constrângeri relative la acțiunile pe care T poate să le execute relativ la V:
o acțiune use sau assign executată de către T asupra lui V este permisa dacă și numai dacă este cerută de execuția firului T în conformitate cu modelul standard de execuție al limabjului Java; de exemplu, apariția lui V ca operand al operatorului, +, impune apariția unei singure acțiuni use asupra lui V; apariția lui V ca operand în partea stângă a operatorului de atribuire, =, impune apariția unei singure acțiuni assign; toate acțiunile use și assign executate de un fir anume, trebuie să apară exact în ordinea specificată de programul executat de către fir; dacă următoarle reguli interzic lui T să inițieze o acțiune de tip use sau assign atunci poate fi necesară mai întâi o acțiune load executată de către T
o acțiune store executată de către T asupra lui V trebuie să intervină între o acțiune assign a lui T asupra lui V și proxima acțiune load a lui T asupra lui V. (Mai puțin formal: unui fir de execuție nu îi este permis să ignore ultima acțiune assign.)
o acțiune assign executată de către T asupra lui V trebuie să intervină între o acțiune load sau store a lui T asupra lui V și proxima acțiune store a lui T asupra lui V. (Mai puțin formal: unui fir de execuție nu îi este permis să transfere date din memoria sa de lucru în memoria principală fără un motiv întemeiat.)
după ce un fir este creat, trebuie ca el să execute mai întâi acțiunea assign sau load asupra unei variabile înainte să execute o acțiune use sau store asupra aceleași variabile.(Mai puțin formal: un fir de execuție nou creat începe execuția programului său cu o memorie de lucru goală.)
după ce o variabiă este creată, fiecare fir trebuie să execute o acțiune de tip assign sau load asupra acestei variabile înaintea oricărei acțiuni de tipul use sau store asupra acestei variabile.(Mai puțin formal: o variabilă nouă este creată doar în memoria principală și inițial nu există în memoria de lucru a oricărui fir.)
Dincolo de constrângerile pe care le impun regulile anterioare și cele care vor urma, o acțiune de tip load sau store poate fi inițiată de către orice fir la orice moment de timp, acest lucru depinzând de implementare.
Există de asemenea și o serie de constrângeri relative la acțiunile de tip read și write executate de către memoria principală:
fiecărei acțiuni load execuate de către un fir T asupra unei variabile V din memoria de de lucru, trebuie să îi corespundă o acțiune read executată de memoria principală asupra variabilei V, înaintea acțiunii load, iar acțiunea load trebuie să pună în memoria de lucru a lui T valoarea transmisă de acțiunea read de care a fost precedată.
fiecărei acțiuni store execuate de către un fir T asupra unei variabile V din memoria de lucru, trebuie să îi corespundă o acțiune write executată de memoria principală asupra variabilei V, după acțiunea store, iar acțiunea write trebuie să pună în memoria principală valoarea transmisă de acțiunea store de care a fost precedată.
Fie A o acțiune de tip load sau store executată de către T asupra variabilei V, și fie P acțiunea de tip read sau write executată de către memoria principală corespunzătoare lui A; similar, fie B o acțiune de tip load sau store executată de către T asupra variabilei V, și fie Q acțiunea de tip read sau write executată de către memoria principală corespunzătoare lui B; atunci dacă A precede pe B atunci în mod necesar P precede lui Q.(Mai puțin formal: acțiunile executate de către memoria principală asupra unei varibile și care sunt consecințe ale acțiunilor executate de către un fir asupra aceleași variabile din memoria sa de lucru, sunt executate în aceeași ordine în care au fost executate de către fir.)
Ultima regulă se referă doar la acțiunile unui fir asupra aceleași varibaile. Totuși , există o regulă mai puternică relativ la variabilele de tip volatile.
Tratamentul non-atomic al variabilelor de tip double și long
Dacă o variabilă de tip long sau de tip double nu este declarată cu atributul volatile(variabilele declarate cu acest atribut se numesc variabile volatile), atunci ele sunt tratate de către acțiunile load, store, read, și write ca și cum ar fi două variabile de câte 32 de biți fiecare.(Menționăm că în Java tipurile long și double se reprezintă pe 64 de biți.) Dacă o acțiune de tipul celor enumerate este inițiată asupra unei variabile de acest fel atunci sunt inițiate de fapt câte două acțiuni: una pentru fiecare jumătate de câte 32 de biți. Maniera în care cei 64 de biți se codează în două grupe de câte 32 de biți este dependentă de implementare.
Acest lucru ne interesează deoarece cele două acțiuni de același tip se execută separat și pot fi despărțite de apariția altor acțiuni. În consecință, dacă două fire de execuție atribuie concurent două valori aceleași variabile, care nu este volatilă și este de tip long sau double, este posibil ca la o folosire ulterioară a variabilei să se constate că valoarea sa nu este nici una din cele două atribuite, ci o mixtură dependentă de implementare.
Deci variabilele de tip long și double sunt tratate non-atomic. O implementare anume este liberă să implementeze acțiunile load, store, read și write ca fiind atomice relativ la variabilele de tip long și double. De fapt acest lucru este recomandat, în caz contrar programatorul trebuind să fie atent la utilizarea acestor tipuri de variabile în sensul că ele trebuie declarate a fi volatile. Acest comportament relativ la aceste tipuri de variabile este o concesie la adresa procesoarelor de astăzi care nu au suport pentru tratamentul atomic al adresării memoriei pe 64 de biți. Evident că lipsa acestei concesii face implementarea mai ușoara însă, cu timpul, ea va fi eliminată datorită eliminării procesoarelor învechite cu cele care suportă adresare pe 64 de biți eficientă.
Reguli referitoare la monitoare
Fie T un fir și L un monitor. Avem o serie de constrângeri relative la acțiunile pe care T le poate execută asupra lui L:
o acțiune lock executată de către T asupra lui L poate să apară dacă și numai dacă pentru oricare alt fir S, altul decât T, numărul acțiunilor anterioare de tip unlock ale lui S asupra lui L este egal cu numărul acțiunilor anterioare de tip lock ale lui S asupra lui L.(Mai puțin formal: doar un singur fir poate să ceară posesiunea unui monitor, și mai mult un fir anume poate să execute consecutiv mai ulte acțiuni lock asupra aceluiași monitor, iar acest monitor va fi eliberat numai după ce firul va executa același număr de acțiuni unlock.)
o acțiune unlock a lui T asupra lui L poate să apară dacă și numai dacă numărul de acțiuni unlock ale lui T asupra lui L este strict mai mic decât numărul acțiunilor lock ale lui T asupra lui L.(Mai puțin formal: un fir nu poate să elibereze un monitor a cărui posesiune nu o are)
Relativ la un anumit monitor, acțiunile de tip lock și unlock executate de către toate firele de execuție se înscriu într-o relație de ordine totală. Această ordine totală trebuie să fie consistentă cu ordinea totală a acțiunilor fiecărui fir în parte.
Reguli referitoare la interacțiunea monitoarelor și a variabilelor
Fie T un fir oarecare, fie V o variabilă oarecare, și fie L un monitor oarecare. Avem o serie de constrângeri relative la acțiunile executate de către T asupra lui V și L:
între o acțiune assign a lui T asupra lui V și o acțiune ulterioară de tip unlock a lui T asupra lui L, este în mod necesar să apară o acțiune de tip store a lui T asupra lui V; mai mult, acțiunea write corespunzătoare acțiunii store trebuie să preceadă acțiunea unlock relativ la memoria principală;(Mai puțin formal: dacă un fir execută o acțiune unlock asupra oricărui monitor, mai întâi trebuie să transfere conținutul memoriei sale de lucru în memoria principală.)
între o acțiune lock a lui T asupra lui L și o acțiune ulterioară de tip use sau store a lui T asupra lui V, este în mod necesar să apară o acțiune de tip load sau assign a lui T asupra lui V; mai mult, dacă acțiunea care intervine este de tip load, atunci acțiunea read corespunzătoare acțiunii load trebuie să urmeze acțiunii lock relativ la memoria principală;(Mai puțin formal: dacă un fir execută o acțiune lock asupra oricărui monitor, mai întâi trebuie să reactualizeze conținutul memoriei sale de lucru cu valorile respective din memoria principală.)
Reguli referitoare la variabilele volatile
Dacă o variabilă este declarată volatilă(prin atributul volatile) atunci o serie de constrângeri apar în plus. Fie T un fir și fie V și W două variabile volatile. Avem următoarele reguli:
o acțiune de tip use a lui T asupra lui V este permisă dacă și numai dacă acțiunea anterioară a lui T asupra lui V a fost o acțiune de tip load; o acțiune de tip load a lui T asupra lui V este permisă dacă și numai dacă acțiunea următoare a lui T asupra lui V va fi o acțiune de tip use; astfel se spune că acțiunea use este asociată cu acțiunea read corespunzătoare acțiunii load.
o acțiune de tip store a lui T asupra lui V este permisă dacă și numai dacă acțiunea anterioară a lui T asupra lui V a fost o acțiune de tip assign; o acțiune de tip assign a lui T asupra lui V este permisă dacă și numai dacă acțiunea următoare a lui T asupra lui V va fi o acțiune de tip store; astfel se spune că acțiunea assign este asociată cu acțiunea write corespunzătoare acțiunii store.
fie A o acțiune de tip use sau assign a lui T asupra lui V, fie F acțiunea de tip load sau store asociată lui A, și fie P acțiunea de tip read sau write corespunzătoare lui F; similar, fie B o acțiune de tip use sau assign a lui T asupra lui W, fie G acțiunea de tip load sau store asociată lui B, și fie Q acțiunea de tip read sau write corespunzătoare lui G; atunci dacă A precede B, în mod necesar P trebuie să preceadă Q.(Mai puțin formal: acțiunile executate de memoria principală asupra variabilelor volatile și care sunt consecințe ale acțiunilor executate de către un fir sunt executate exact în ordinea în care firul execută acțiunile asupra variabilelor volatile din memoria sa de lucru.)
Acțiuni store anticipative
Dacă o variabilă nu este declarată ca fiind volatilă, atunci regulile din secțiunea anterioară pot ușor slăbite astfel încât să permită apariția acțiunilor store mai devreme decât s-ar permite în alte cazuri. Scopul acestei relaxări a regulilor este acela de a permite compilatoarelor să realizeze o serie de rearanjări ale codului care conservă semantica construcțiilor sincronizate într-un mod propriu, dar care pot genera acțiuni neprevăzute în cazul în care acestea nu sunt sincronizate într-un mod propriu.
Să presupunem că o acțiune store a lui T asupra lui V ar urma unei acțiuni assign a lui T asupra lui V, fără că între cele două acțiuni să apară o alta. Atunci conform regulilor anterioare acțiunea store ar trimite memoriei principale valoarea pe care acțiunea assign a pus-o în memoria de lucru a firului. Regula specială care permite apariția unei acțiuni store înaintea unei acțiuni assign funcționează în cazul în care sunt îndeplinite următoarele constrângeri:
dacă acțiunea store apare atunci acțiunea assign este constrânsă să apară
nici o acțiune de tip lock nu poate să intervină între acțiunea store și acțiunea assign
nici o acțiune de tip load a lui T asupra lui V nu poate să intervină între acțiunea store și acțiunea assign
nici o altă acțiune de tip store a lui T asupra lui V nu poate să intervină între acțiunea store și acțiunea assign
acțiunea store transimte memoriei principale valoarea pe care acțiunea assign o va pune în memoria de lucru a lui T
Această ultimă regulă determină și epitetul de anticipativ atribuit acțiunii store: într-un mod anume, trebuie să se știe dinainte(să se anticipeze) ce valoare va fi pusa de către acțiunea assign în memoria de lucru a firului de execuție. În practică aceste valori vor fi calculate de către codul compilat și optimizat înainte(dacă acest lucru este posibil ca în cazul în care calculul nu are efecte colaterale sau poate genera excepții), vor fi stocate înainte(de intrarea într-un ciclu, de exemplu) și păstrate în regiștrii de lucru pentru folosirea lor mai târziu în interiorul ciclului.
Discuție
Orice asociație între variabile și zavoare este pur conventională. Închizand orice monitor, toate variabilele corespunzătoare din memoria principală sunt copiate în memoria de lucru a firului(flush). Eliberând orice monitor, firul este forțat să copieze conținutul memoriei sale de lucru, în memoria principală. Asocierea unui monitor cu un obiect particular sau o clasa este o convenție. În anumite aplicații poate fi necesar ca monitorul unui obiect să fie închis, atunci când se dorește accesul la una din instanțele sale. Această se poate realiza de exemplu prin metodele sincronizate. În alte aplicații poate fi suficient un singur monitor pentru a sincroniza accesul la o colecție mare de obiecte.
Dacă un fir folosește o anumită variabilă comună, doar după închiderea unui anumit monitor și înainte de eliberarea acestuia, acest fir va citi variabila comună din memoria principală după acțiunea lock, și, înainte de a executa acțiunea unlock corespunzătoare, va copia variabila respectivă înapoi memoria principală. Acest fapt împreună cu regulile de excludere reciprocă privind monitoarele, garantează că valorile sunt corect transmise de la un fir la altul prin variabile comune.
Regulile referitoare la variabilele volatile explicit cer ca pentru fiecare acțiune de tip use sau assign a unui fir asupra unei variabile volatile, memoria principală să fie accesată o singură dată și să fie accesată exact în ordinea semantică dictată de codul executat de către firul de execuție. Totuși asemenea acțiuni nu sunt obligatorii relativ la variabilele care nu sunt volatile.
Exemplu: O posibilă interschimbare
Se consideră o clasă care are ca variabile pe a și b, iar ca metode, metodele hither și yon:
class Sample {
int a = 1, b = 2;
void hither()
{
a = b;
}
void yon()
{
b = a;
}
}
Acum să presupunem că două fire sunt create și unul din fire apelează metoda hither în timp ce al doilea apelează metoda yon. Care este setul de acțiuni pe care le execută fiecare fir și care sunt restricțiile ?
Să considerăm mai întâi firul care apelează metoda hither. În acord cu regulile, acest fir trebuie să execute mai întâi o acțiune use asupra lui b urmată de o acțiune assign asupra lui a. Acestea sunt acțiunile necesare execuției metodei hither.
Acum, prima acțiune asupra variabilei b nu poate fi use. Dar poate fi assign sau load. O acțiune assign asupra lui b nu poate să apară căci semantica programului nu impune acest lucru, deci o acțiune de tip load asupra lui b este necesară. Această acțiune de tip load determină în consecință o acțiune de tip read care să se execute înaintea acțiunii load.
Firul poate în mod opțional să execute o acțiune assign asupra lui a după ce acțiunea de tip assign a apărut. Dacă face acest lucru atunci drept consecință va apărea o acțiune de tip write executată de către memoria principală.
Situația pentru firul care apelează metoda yon este similară cu cea celui de mai sus însă cu rolul lui a și b interschimbat.
Totalul acțiunilor apărute poate fi schițat astfel:
În această imagine o săgeată de la acțiunea A către acțiunea B indică faptul că A trebuie să preceadă pe B.
În ce ordine pot apărea acțiunile relative la memoria principală ? Singura constrângere este aceea că nu este posibil ca acțiunea write asupra lui a să preceadă acțiunea read asupra lui a, și analog, acțiunea write asupra lui b să preceadă acțiunea read asupra lui b. Aceste două lucruri nu sunt posibile pentru că săgețile din diagramă ar forma o buclă ceea ce ar duce la apariția unei acțiuni după ea însăși ceea ce am vazut că nu este posibil. Presupunând că acțiunile opționale store și write apar avem posibile trei oridini diferite relativ la modul în care memoria principală execută acțiunile.
Fie ha și hb copiile de lucru ale lui a și lui b pentru firul de execuție care apelează metoda hither , și fie ya și yb copiile de lucru ale lui a și lui b pentru firul de execuție care apelează metoda yon. Fie ma și mb copiile lui a și a lui b din memoria principală.
Inițial avem ma = 1 și mb = 2. Avem trei posibilități în ordinea de apariție a acțiunilor și cele trei rezultate sunt următoarele:
(write a, read a), (read b, write b)(apoi ha = 2, hb = 2,ma = 2,mb = 2, ya = 2, yb = 2)
(read a,write a), (write b, read b)(apoi ha = 1, hb = 1,ma = 1,mb = 1, ya = 1, yb = 1)
(read a, write a), (read b, write b)(apoi ha = 2, hb = 2,ma = 2,mb = 1, ya = 1, yb = 1)
După cum se poate vedea, în memoria principală, pot avea loc trei situații: fie b este copiat în a, fie a este copiat în b, fie constinutul lui a este interschimbat cu continutul lui b. Mai mult, copiile de lucru ale variabilelor pot sau nu pot să corespundă. Dintre cele trei situatii de mai sus nici una nu poate aparea cu o probabilitate mai mare decât pot aparea celelalte. Aici este un loc în care un program Java are o execuție dependentă de timp.
Este evident că o anumită implementare ar fi putut să nu considere apariția acțiunilor store și write, sau ar fi putut să considere doar una din cele două acțiuni, ceea ce ar fi dus la alte rezultate posibile.
Acum să modificăm clasa noastră și să declarăm cele două metode ca fiind sincronizate:
class SynchSample {
int a = 1, b = 2;
syncronized void hither()
{
a = b;
}
syncronized void yon()
{
b = a;
}
}
Să analizăm în continuare cazul firului care apelează metoda hither . În conformitate cu regulile anterioare, acest fir trebuie să execute o acțiune de tip lock asupra monitorului asociat obiectului instanță a clasei SynchSample , înainte de a se execută corpul metodei hither . Această acțiune este urmată de către o acțiune de tip use asupra lui b și de către o acțiune assign asupra lui a. În final, trebuie executată o acțiune de tip unlock asupra monitorului închis de către precedenta acțiune lock. Astfel se termină execuția corpului metodei hither . Acesta este setul de acțiuni executat de către firul ce apelează metoda hither .
Ca și mai înainte, o acțiune de tip load asupra lui b este ceruta, deci este nevoie în prealabil de către o acțiune de tip read asupra lui b executată de către memoria principală. Deoarece acțiunea load urmeaza acțiunii lock acțiunea read corespunzătoare trebuie și ea să urmeze acțiunii lock.
Deoarece o acțiune de tip unlock apare după acțiunea assign asupra lui a, o acțiune de tip store asupra lui a este necesară, ceea ce duce la apariția unei acțiuni de tip write asupra lui a executată de către memoria principală. Acțiunea write trebuie să apară înaintea acțiunii unlock.
Analiza asupra firului care apelează metoda yon este similară cu cea de mai sus însă cu rolul lui a interschimbat cu rolul lui b.
Totalul acțiunilor apărute poate fi schițat astfel:
În această imagine o săgeata de la acțiunea A către acțiunea B indică faptul că A trebuie să preceada pe B.
Acțiunile lock și unlock duc la apariția unor noi constrângeri relative la ordinea de apariție a acțiunilor executate de către memoria principală. Astfel acțiunea de tip lock a unui fir nu poate să apară înaintea unei acțiuni de tip lock sau unlock a altui fir. Mai mult acțiunea unlock cere explicit ca acțiunile de tip store și write să apară.
Deci sunt posibile următoarele secvențe de apariție ale acțiunilor:
(write a, read a), (read b, write b)(apoi ha = 2, hb = 2,ma = 2,mb = 2, ya = 2, yb = 2)
(read a,write a), (write b, read b)(apoi ha = 1, hb = 1,ma = 1,mb = 1, ya = 1, yb = 1)
Rezultatul este în continuare dependent de timp în sensul că ambele situații de mai sus au probabilități de apariție egale. Însă datorită sincronizarii proprii cele două fire de execuție vor cădea de acord în ceea ce privește valorile lui a respectiv b.
Exemplu: Atribuire improprie
Exemplul din acest paragraf este asemănător cu cel anterior, singura diferența fiind aceea că o metodă execută două atribuiri a două variabile distincte, iar cealaltă metodă execută două citiri ale acelorași variabile.
Se consideră o clasă care are ca variabile pe a și b, iar ca metode, metodele hither și yon:
class Simple {
int a = 1, b = 2;
void to()
{
a = 3;
b = 4;
}
void from()
{
System.out.println( “a = “ + a + “, b = “ + b );
}
}
Să presupunem acum că sunt create două fire de execuție: unul va executa metoda to, iar celălalt metoda from. Vom denumi cele două fire ca fiind firul to, respectiv firul from . Care sunt mulțimile de acțiuni care pot să apară și căror constrângeri sunt ele supuse ?
Considerăm cazul firului to. În conformitate cu regulile, acest fir trebuie să execute o acțiune de tip assign asupra lui a, urmată de o acțiune de același tip asupra lui b. Acestea este minimul de acțiuni care trebuie să apară pentru a se executa corpul metodei to . Deoarece nu există nici o sincronizare, apariția acțiunilor store pentru a pune noile valori în memoria principală rămâne o opțiune a implementării. Astfel, firul care execută metoda from , poate să obțină pentru a atât valorile 1 și 3, cât și pentru b valorile 2 și 4.
Să considerăm acum cazul în care metoda to este sincronizată în sensul că ea este declarata cu atributul synchronized, dar metoda from nu este sincronizată:
class SynchSimple {
int a = 1, b = 2;
synchronized void to()
{
a = 3;
b = 4;
}
void from()
{
System.out.println( “a = “ + a + “, b = “ + b );
}
}
În acest caz metoda to va fi obligată să execute două acțiuni de tip store asupra variabilelor atribuite înainte de apariția acțiunii unlock, acțiune care va apare la sfârșitul execuției metodei. Metoda from trebuie să folosească variabilele a și b, deci va trebui să execute două acțiuni de tip load pentru a prelua din memoria principală valorile lor.
Totalul acțiunilor apărute poate fi schițat astfel:
În aceasta imagine o săgeata de la acțiunea A către acțiunea B indică faptul că A trebuie să preceadă pe B.
Să analizăm în ce ordine pot apărea acum acțiunile executate de către memoria principală. Să observăm că nici o regulă nu impune ca acțiunea write asupra lui a să apară înaintea acțiunii write a lui b. De asemenea, nici o regulă nu impune ca acțiunea read asupra lui a să apară înaintea acțiunii read asupra lui b. Chiar dacă metoda to este sincronizată, metoda from nu este sincronizată, deci nu există nici o constrângere care să previna apariția acțiunilor de tip read între acțiunea lock și acțiunea unlock. Concluzia care se impune este că declararea unei singure metode cu atributul synchronized nu duce la execuția corpului acestei metode în regim atomic(indivizibil).
Deci metoda from poate în continuare să obțină ca valori pentru a, atat 1 și 3, iar pentru b, atat 2 și 4. În particular metoda from poate să obțină pentru a valoarea 1 iar pentru b valoarea 4. Deci, chiar dacă metoda to execută o acțiune assign asupra lui a și apoi o acțiune assign asupra lui b, acțiunile de tip write executate de către memoria principală apar în ordine inversă firului care execută metoda from.
În final, să considerăm cazul în care ambele metode to și from sunt sincronizate:
class SynchSimple {
int a = 1, b = 2;
synchronized void to()
{
a = 3;
b = 4;
}
synchronized void from()
{
System.out.println( “a = “ + a + “, b = “ + b );
}
}
În acest ultim caz este evident că acțiunile executate de către metoda to și de către metoda from nu pot fi intrepătrunse. Ele se excută în regim de excludere reciprocă. Deci metoda from va afișa fie ”a = 1, b = 2” , fie ”a = 3, b = 4” .
Fire de execuție
Firele de execuție sunt create și gestionate de către două clase din pachetul nucleu al limbajului Java, java.lang , și anume clasele Thread respectiv ThreadGroup . Crearea unui fir de execuție se face prin crearea unui noi instanțe a clasei Thread și este singura metodă de creere a unui fir de execuție. După ce un fir este creat, el nu este încă activ. Pentru a deveni activ va trebui apelată metoda start .
Fiecare fir de execuție are o prioritate. Când există o situație de concurență în obținerea accesului la o resursă, firelor cu prioritate mai mare decât a celorlalte li se dă întâietate. Totuși aceasta nu este o constrângere, căci nu se garantează că un fir cu prioritate mare va fi mereu activ. De aceea mecanismul prioritaților nu poate fi folosit în scopul asigurării unei excluderi reciproce.
Monitoarele și acțiunea de sincronizare
După cum am mai menționat în prima parte, există un monitor asociat fiecărui obiect. Limbajul Java nu prevede un mijloc de a executa acțiuni de tip lock și unlock în mod separat. Aceste acțiuni sunt realizate prin intermediul unor construcții de nivel înalt, care asigură apariția unei acțiuni unlock pentru fiecare acțiune lock executată asupra aceluiași monitor. Facem observația că mașina virtuală Java prevede două instrucțiuni pentru implementarea acțiunilor lock respectiv unlock și anume intrucțiunile monitorenter și monitorexit. Este evident că programatorul nu are acces la aceste instrucțiuni și nu le poate folosi separat.
Atributul synchronized calculează o referință la un obiect. Apoi încearcă să execute o acțiune unlock asupra lui. Menționăm că am spus că acțiunea unlock se execută asupra obiectului. De fapt ea se execută asupra monitorului asociat obiectului respectiv. Dar cum fiecare obiect are un monitor asociat, putem folosi prima formă de exprimare pentru simplitate, chiar dacă nu este forma în totalitate riguroasa. Execuția nu înaintează până când nu se obține închiderea monitorului și acțiunea lock nu s-a încheiat cu succes. O acțiune de tip lock poate fi întarziată datorită constrângerilor date de reguli. De exemplu memoria principală poate refuza să participe la o acțiune lock căci nu poate din cauză că un alt fir trebuie să execute mai multe acțiuni unlock.
După ce acțiunea lock s-a executat cu succes, se trece la execuția codului aflat în blocul sincronizat. După ce se termină execuția codului, se execută automat o acțiune unlock asupra aceluiași monitor pe care s-a executat lock, indiferent dacă execuția codului s-a terminat cu succes sau, brusc, prin generarea unei excepții.
O metodă declarată cu modificatorul synchronized execută automat o acțiune lock înainte de executarea corpului sau. Această execuție nu începe până când acțiunea lock nu se încheie cu succes. Dacă metoda respectivă este o metodă nestatică, atunci acțiunea lock se execută asupra instanței obiectului din care s-a apelat metoda.(Acest obiect este accesibil corpului metodei prin referința this, care se știe este referința la obiectul curent). Dacă metoda este statică, atunci acțiunile lock și unlock se execută asupra obiectului Class care reprezintă clasa în care este definită metoda. Dacă execuția metodei se încheie atunci o acțiune unlock se execută asupra aceluiași obiect, indiferent dacă execuția s-a terminat cu succes sau, brusc, prin generarea unei excepții.
Cea mai bună metodă este următoarea: dacă asupra unei variabile se va executa o acțiune assign dîntr-un fir de execuție, și apoi asupra aceleași variabile se va executa o acțiune use din alt fir de execuție, atunci toate construcțiile sintactice care implică variabila respectivă ar trebui să fie incluse în blocuri sincronizate sau în metode sincronizate.
Limabjul Java nu previne și nici nu detectează așa numitele situații deadlock. Programele care utilizează acțiuni de tip lock sau unlock asupra mai multor obiecte, ar trebui să folosească tehnici convenționale pentru evitarea situațiilor deadlock. Aceste tehnici se referă la construcții de sincronizare de nivel înalt.
Mulțimile wait și notificări
Deoarece pentru fiecare obiect există un monitor asociat, atunci fiecărui obiect i se asociază o mulțime numită mulțime wait. O mulțime wait este o mulțime de fire de execuție. Cand un obiect este creat atunci mulțimea să wait este vidă.
Mulțimile wait sunt utilizate de către metodele wait, notify, și notifyAll ale clasei Object. Aceste metode au rol și în funcționarea mecanismului se planificare a firelor de execuție.
Metoda wait ar trebui să fie apelată pentru un obiect, numai și numai atunci când firul curent(fie acesta T) a executat deja acțiunea lock asupra obiectului. Să presupunem că firul T a executat N acțiuni lock și că nu au apărut exact N acțiuni unlock. Atunci metoda wait adaugă firul curent(T) în mulțimea wait a obiectului, retrage firul curent de la participarea la competiția pentru obținerea procesorului, și execută N acțiuni unlock pentru a elibera monitorul. În acest caz firul T intră într-o stare de inactivitate pâna când unul din următoarele evenimente va apărea:
un alt fir de execuție va invoca metoda notify pentru obiectul respectiv și firul T se întamplă să fie cel ales pentru notificare și este notificat.
un alt fir de execuție invocă metoda notifyAll pentru obiectul respectiv.
dacă apelul metodei wait pentru firul T are un timp limitat, atunci când acest interval de timp expiră.
După apariția unuia din evenimentele de mai sus, firul T este șters din mulțimea wait a obiectului și devine apt pentru a participa la competiția pentru obținerea procesorului. Apoi din nou execută o acțiune lock asupra obiectului(ceea ce presupune o posibilă competiție cu alte fire). Odată ce a preluat controlul asupra monitorului obiectului, execută exact N –1 acțiuni lock asupra obiectului și astfel se incheie execuția metodei wait. Astfel la ieșirea din metoda wait, starea monitorului obiectului este exact aceeași cu cea dinaintea execuției metodei wait.
Metoda notify trebuie să fie apelată pentru un obiect doar atunci când firul curent are deja controlul asupra monitorului obiectului. Dacă mulțimea wait a obiectului nu este vidă, atunci se alege în mod arbitrar un fir și replanificat pentru execuție. Acest din urmă fir nu va deveni activ până când firul curent nu eliberează monitorul obiectului.
Metoda notifyAll trebuie să fie apelată pentru un obiect doar atunci când firul curent are deja controlul asupra monitorului obiectului. Fiecare fir din mulțimea wait este șters din mulțime și replanificat pentru execuție. Aceste din urmă fire nu vor deveni active până când firul curent nu eliberează monitorul obiectului.
Concluzii
Acest prim capitol expune detaliat regulile pe care orice implementare Java trebuie să le respecte cu privire la execuția programelor într-un mediu concurent. Aceste reguli sunt foarte utile pentru că ele ne oferă o disecție a modului în care mașina virtuală Java lucreaza cu firele de execuție. Programarea folosind mai multe fire de execuție este mult mai dificilă decât cea într-unul singur, forma tradițională de programare. Însă ea este absolut necesară anumitor tipuri de aplicații. Este neîndoios că acest tip de programare este o sursă de erori specifice. Multe dintre erori sunt subtile și nu sunt vizibile imediat. Datorită nedeterminismului dat de prezența mai multor fire de execuție, este posibil ca o eroare să apară chiar mult după ce programul a fost dezvoltat și considerat terminat. Regulile de mai sus sunt un bun companion atunci când avem întrebări relativ la modul în care mașina virtuală execută un cod multithreading.
Acest capitol nu se referă la cum pot fi folosite firele de execuție într-o aplicație practică. Acest aspect este abordat de următorul capitol, în care se vor prezenta atât aspecte ale clasei Thread,care implementează noțiunea de fir de execuție în limbajul Java, cât și o serie de tehnici practice atunci când firele de execuție sunt necesare într-o aplicație.
FIRE DE EXECUȚIE.
TEHNICI DE PROGRAMARE
După cum afirmam în concluzia primului capitol, programarea concurentă poate conține erori(bug-uri)subtile. Menționăm din start că, ori de câte ori este posibil ca o aplicație să iși atingă toate scopurile folosind un singur fir de execuție, să utilizeze doar acest singur fir de execuție. Nu are rost să se apeleze la alte fire de execuție decât atunci când acest lucru nu este absolut necesar.
Mai multe fire de execuție nu înseamnă în mod necesar mai multă performanță. De multe ori nu se are la dispozitie decât un singur procesor. La începuturile limbajului Java, mașina virtuală, deși ‘știa’ să lucreze cu fire de execuție, toate aceste fire se executau în cadrul unui singur fir de execuție nativ. Ceea ce evident nu ducea la o performanță computațională mai mare, dar putea ușura programarea. Această situatie a fost remediată și, este cert că ultima versiune scoasă de firma Sun(firma care este promotorul oficial al tehnologiei Java) recunoaște firele de execuție native și este capabilă să le folosească într-un mod transparent pentru programator. Acest lucru nu duce la modificarea portabilitații limbajului Java. Amintim aici că limbajul Java traiește prin și pentru sintagma “Write once, run anywhere” care presupune la nivel de limbaj o portabilitate absoluta a aplicațiilor.
Revenind la firele de excuție, este bine de făcut următoarea observație: chiar dacă o aplicație Java nu declară și folosește în mod explicit mai multe fire de execuție, aplicația respectivă se execută chiar într-un fir de execuție care nu are nici o particularitate anume pentru mașina virtula. Mai mult orice, aplicație Java rulează într-un mediu concurent în care mai multe fire de execuție sunt active. Afirmația este lesne de probat: un fir este însăși aplicația, iar un al doilea fir este colectorul de reziduri cunscut mult mai bine sub denumirea originala de “garbage collector”. Aplicațiile tipice care cer în mod necesar desfășurarea lor într-un mediu multithreading sunt aplicații de tip server (care trebuie să mențină câte un fir de execuție pentru fiecare conexiune deschisă) sau sunt aplicații multimedia(care trebuie să permită interpretarea simultană a mai multor fluxuri de date, imagini, sunete) sau aplicații care execută sarcini consumatoare de timp și care sunt executate într-un fir, în fundal(background) pentru a nu bloca aplicația.
Crearea firelor de execuție
Un fir de execuție este alcătuit din două componente logice. Una este cea care reprezintă mecansimul propriuzis de execuție al firului. Această componentă logică pune la dispozitie, prin clasa Thread metode de control asupra firului de execuție: start, interrupt, sleep și altele. A doua componentă logică a firului este codul executat de către el. Este necesară existența unei modalități prin care mediului de execuție, în speță mașina virtuala, să i se comunice faptul că un anumit obiect este un fir de execuție și care este codul pe care acest fir trebuie să îl execute. Deci pentru un fir de execuție este necesară o metodă principală în care să se afle tot codul propus spre executare. Această metodă principală seamănă din punct de vedere conceptual cu metoda main a unei aplicații. De fapt metoda main a unei aplicatii este punctul de intrare în execuția unei aplicații. Pentru orice fir de execuție, clasa Thread prevede metoda run. În această metodă se plasează tot codul(apeluri de metode, calcule) care trebuie rulat. Însăși numele metodei sugereaza rolul ei. Metoda run nu este apelată explicit de către program, ea fiind apelată la momentul potrivit de către mașina virtuala.
Clasa Thread este cea care oferă posibilitatea creării unui fir de execuție. Însă metoda run pe care ea o prevede are corpul vid. Deci instanțierea unui obiect de tip Thread reprezintă un fir de execuție care nu face nimic. Deci este necesară rescrierea acestei metode, rescriere care se face prin utilizarea moștenirii. Astfel, dacă se dorește crearea unui fir care să execute un cod dat, mai întâi se crează o subclasă a clasei Thread în care metoda run este rescrisă astfel încât ea să conțina codul dorit. O altă metodă de creare a unui fir este aceea de a folosi mecanismul interfețelor. În Java există interfața Runnable care definește metoda run. Prin implementarea acestei interfețe, o clasă își ia angajamentul de a implementa metoda run. Prin însăși denumirea interfeței, Runnable, clasa resectivă a devenit un potențial fir de execuție.
Interfața Runnable are următoarea structură:
public interface Runnable {
public void run();
}
Pentru a crea un fir de execuție a cărui metodă run să fie una a unei clase care implementează interfața Runnable, se folosește constructorul clasei Thread care are ca argument un obiect care implementează interfața cu pricina:
Thread( Runnable target )
Deci pentru crearea unui fir de execuție vom adopta una dintre cele două metode de mai sus. În fiecare dintre cazuri va trebui să definim o nouă clasă.
1. Cazul metodei ce foloseăte moătenirea:
public class NewThread extends Thread {
…
public void run()
{
…
}
…
}
Instanțierea unui fir de execuție se face simplu prin declarația unui obiect nou de tipul de mai sus:
NewThread thread = new NewThread();
2. Cazul metodei în care se implementează interfața Runnable:
public class NewClass implements Runnable {
…
//metoda interfetei Runnable
public void run()
{
…
}
…
}
Instanțierea unui fir de execuție se face în acest caz prin declararea unui obiect din clasa Thread astfel:
NewClass newClass = new NewClass ();
Thread thread = new Thread( mewClass );
După ce un fir de execuție este creat prin una din cele două metode el există, are memorie alocată însă mașina virtuală nu știe încă că este un fir de execuție a cărui metodă run trebuie să o execute. În acest stadiu pot să fie utilizate alte metode ale firului de execuție care nu au legatură cu componenta logică de control a firului.
Pentru că un fir de execuție nou creat să fie recunoscut de mașina virtuală ca atare, trebuie să se execute metoda start a obiectului(firului):
thread.start();
Efectul execuției metodei start este acela că statutul de fir de execuție al obiectului thread devine legitim și astfel firul este planificat pentru o ulterioră execuție. Invocarea metodei start nu garantează faptul că execuția metodei run a firului respectiv va începe imediat. Dacă firul de execuție este creat prin prima metodă și dacă se dorește ca odată cu creerea sa, el să fie deja cunoscut mașinii virtuale ca fir de execuție metoda start se poate apela în constructorul firului respectiv. Astfel în clasa NewThread de mai sus se poate modifica constructorul său constructorii astfel încât ei să conțină ca ultimă metodă apelată metoda start:
public NewThread()
{
super();
//initializari
…
start();
}
Între cele două moduri de a crea un fir de execuție există o deosebire importantă mai ales din punct de vedere al programatorului: dintr-un un obiect care a implementat interfața Runnable, deci care nu este moștenit din clasa Thread, nu este posibilă utilizarea directă a metodelor de control ale unui fir de execuție(sleep, setPriority, și altele). Problema se poate remedia prin folosirea unei metode statice din clasa Thread și anume metoda currentThread, care întoarce o referință la firul de execuție curent(din care s-a apelat metoda currentThread). Folosind această referință, către firul curent, se pot apela metodele sale de control. Prezentăm mai jos un șablon:
public void run()
{
// metoda statica a clasei Thread
Thread t = Thread.currentThread();
…
// metode de control
…
}
Firele de execuție au și un anumit tip de organizare. Ele se grupeaza în grupuri de fire de execuție. Într-un program un fir nou, implicit face parte din grupul firelor de care aparține firul care l-a creat. La rândul lor, grupurile de fire de execuție pot conține la rândul lor alte grupuri. Gruparea firelor de execuție este utilă mai ales atunci când se dorește un control uniform asupra lor. Exemplul de mai jos o demonstrează:
ThreadGroup group = new ThreadGroup( "Group name" )
…
if ( 0 != g.activeCount() ) {
…
g.stop();
}
Se creează un grup de fire de execuție și după ce se realizează o serie de prelucrări asupra lor, acestea sunt oprite.
Tot ca metode de control ale unui fir de execuție, se încadrează și metoda join. Această metodă are ca efect blocarea firului de execuție din care a fost apelată până când firul de execuție a cărui metodă join a fost apelată își termină execuția. Este o metodă utilă mai ales în programarea paralelă. În clasa Thread mai există și metoda isAlive care întoarce true dacă firul pentru care s-a apelat metoda, este în stare gata de execuție, în stare de execuție sau suspendat, și întoarce false dacă firul este în starea creat sau în starea terminat.
Utilizarea priorităților
Dacă există mai multe fire de execuție care se execută pe un același procesor, trebuie să existe un criteriu prin care un anumit fir de execuție să se execute la un moment dat. Această alegere se poate face pe baza priorității sale, căci fiecare fir de execuție are o prioritate atribuită. Prioritatea unui fir de execuție este o valoare întreagă cuprinsă între 1 și 10, valori specificate în clasa Thread prin constantele statice: Thread.MIN_PRIORITY respectiv Thread.MAX_PRIORITY. Orice cod Java se execută într-un fir de execuție. Un fir poate să fie activat implicit, de către mașina virtuală(cazul metodei main), sau explicit, în program prin apelul metodei start. Atunci când este creat, un fir are prioritatea egală cu cea a firului din care a fost creat. Dacă există mai multe fire de execuție cu o aceeași prioritate, atunci ele se vor executa într-o manieră dependentă de sistemul de operare. După cum un sistem de operare execută firele de execuție, fie folosind divizarea timpului fie nu, firele de execuție cu prioritate egală sunt executate concurent sau secvențial. În ambele cazuri execuția este preemptivă ceea ce înseamnă că sistemul de operare iși rezervă dreptul de a întrerupe un fir de execuție pentru a începe execuția altuia.
În sistemele cu divizarea timpului, un fir de execuție rulează pentru un anumit interval de timp indivizibil, numit cuantă, după care se alege un alt fir de execuție pentru a-i fi executate un număr de instrucțiuni. Deci firul care se execută acum, la expirarea cuantei de timp, pierde controlul procesorului. Controlul procesorului se mai poate pierde dacă se execută una din metodele blocante(wait sau sleep) sau dacă se execută un apel explicit de cedare prin metoda yield.
În sistemele fară divizarea timpului, un fir de execuție va ceda controlul procesorului dacă o face explicit(prin yield), dacă execută metode blocante(wait sau sleep), dacă se termină sau dacă este preemptat de către un fir de execuție cu o prioritate mai mare. Într-un sistem de acest tip, există posibilitatea ca un anumit fir să preia controlul procesorului și să nu il mai piardă, să se execute fără întrerupere chiar dacă mai sunt și alte fire de execuție cu aceeași prioritate. Într-un sistem cu divizarea timpului un asemenea efect este posibil dacă un fir are prioritate mai mare decât celelalte.
Dacă un fir de execuție dorește să evite monopolizarea procesorului atunci el trebuie să execute metoda yield care va permite și altor fire să intre în competiția pentru câștigarea procesorului.
Să considerăm următorul exemplu în care se va testa politica sistemului de operare cu privire la execuția firelor de execuție.
Exemplul definește clasa NewThread ca mai jos:
public class NewThread extends Thread {
private String message;
public NewThread( String message )
{
this.message = message;
start();
}
public void run()
{
while ( true ) {
System.out.println( message );
}
}
}
public class Driver {
public static void main( String[] args )
{
new NewString( "1" );
new NewString( "2" );
}
}
Metoda run afișează indefinit mesajul respectiv. Se crează două fire de execuție, unul care va afisa mesajul “1”, iar altul mesajul “2”. Dacă se afișează doar una din valori atunci sistemul este fără divizarea timpului, în caz contrar fiind.
Pentru a da șanse egale ambelor fire, indiferent de sistemul pe care se execută, atunci executăm metoda yield în ciclul while. Metoda run modificată este mai jos:
public void run()
{
while ( true ) {
System.out.println( message );
yield();
}
}
Fire de execuție de tip daemon
Un fir de execuție rulează până când una din situații apare:
se termină execuția metodei run.
în timpul execuției metodei run se generează o excepție
Dacă nici unul din evenimentele de mai sus nu apar, atunci firul iși continuă execuția și după ce firul care l-a creat și-a terminat execuția. Aceasta poate fi nedorit și poate reprezenta o greșeala logică de programare. Firul creat ar trebui să își înceteze execuția înainte ca firul care l-a creat să își termine execuția. Dacă se dorește să se specifice în mod explicit ca un anumit fir să își continue execuția după terminarea execuției celui care l-a creat, poate fi setat ca fiind daemon prin metoda setDaemon( boolean isDaemon) a clasei Thread. Această metodă trebuie să fie apelată înainte de apelarea metodei start.
Mașina virtuală Java își termină execuția atunci când singurele fire de execuție rămase active sunt cele cu atributul isDaemon true. Metoda run a unui fir deamon este un ciclu infinit în care se execută în general activități executate periodic sau executate atunci când apar anumite evenimente. De exemplu firul de execuție numit GC(garbage collector) este un fir de tip daemon.
Exemplul de mai jos arată cum se construiește un fir daemon:
public class DaemonThread extends Thread {
public DaemonThread()
{
setDaemon( true );
start();
}
public void run()
{
…
}
}
Stări de activitate posibile pentru un fir de execuție
Un fir de execuție se poate afla într-una din stările de mai jos:
creat: un fir de execuție se creaza asa cum am explicat anterior; în esență el se crează apelând operatorul new; obiectul creat este în acest moment doar un simplu obiect, statutul de fir de execuție fiind încă necunoscut mașinii virtuale; singura metoda de control a unui fir de execuție care are sens să fie aplicată, este metoda start;
gata de execuție: după ce metoda start este apelată firul de execuție trece în starea gata de execuție; acum el participă la competiția privind câștigarea controlului procesorului; metoda start nu garantează că firul iși va începe execuția imediat;
suspendat: dacă un fir de execuție a apelat metoda sleep sau metoda wait, atunci el cedează controlul procesorului și va reveni în starea gata de execuție datorită unui eveniment extern; trecerea în starea gata de execuție se face în funcție de modul în care s-a făcut trecerea în starea suspendat; am văzut în primul capitol că perechea metodei wait fără nici un argument, este fie metoda notify, fie metoda notifyAll; dacă s-a apelat metoda wait cu un argument atunci firul va trece în starea gata de execuție după ce se scurge un interval de timp egal cu valoarea pasată metodei wait;
în curs de executare: în această stare firul execută codul din metoda run;
terminat: în această stare un fir de execuție ajunge dacă s-a terminat de executat metoda run(fie normal, fie prin generarea unei excepții);
Stările posibile ale unui fir de execuție pot fi reprezentate schematic ca mai jos:
În clasa Thread există o serie de metode de control care pot modifica starea firului de execuție:
stop: execută o oprire bruscă a firului și îl trece în starea terminat
suspend: trece firul în starea suspendat
resume: trece firul în starea gata de execuție; este perechea metodei suspend
Aceste metode au fost inițial implementate, dar s-a constat că sunt nesigure, în sensul că prin apelarea lor, firul de execuție, care poate tocmai execută o serie de acțiuni critice asupra unor resurse, pierde controlul acestor resurse în mod necondiționat, ceea ce poate duce la lăsarea lor într-o stare inconsistentă. De aceea ele au fost declarate ca învechite. În paragraful următor vom explica pe îndelete cauzele nesiguranței lor.
Cauzele nesiguranței unor metode de control din clasa Thread
Metoda stop din clasa Thread este nesigură pentru că executarea ei determină asupra firului din care se execută, să elibereze toate monitoarele a caror posesiune acest fir o avea. Monitoarele sunt eliberate pe măsura ce excepția ThreadDeath se propagă în susul stivei.) Dacă un obiect anume protejat de unul din aceste monitoare era într-o stare inconsistentă, alte fire de execuție au acum acces la el. Despre asemenea obiecte se spune că sunt avariate. Când firele de execuție lucrează cu obiecte avariate, execuția lor poate deveni imprevizibilă. În contrast cu alte excepții netratate, excepția ThreadDeath duce la terminarea firului de execuție într-o manieră discretă. Astfel cel care folosește programul nu ia act de faptul că acesta este într-o stare inconsistentă. Acestă stare se poate manifesta după ore sau chiar zile în viitor.
Se poate pune problema prinderii excepției ThreadDeath și de a repara obiectul avariat. Teoretic vorbind, acest lucru este posibil, însă ar fi foarte complicat de scris programe care să trateze acest tip de excepție. Și aceasta din două motive:
un fir de execuție poate genera o excepție de tip ThreadDeath aproape oriunde; toate metodele și blocurile sincronizate ar trebui să fie studiate detaliat cu privire la acest lucru
un fir poate genera o a doua excepție ThreadDeath în timp ce tratează prima excepție de același gen(în blocul catch sau finally); astfel acțiunea de prindere și tratare a excepției ar trebui să se repete până se va încheia cu succes; codul care să asigure aceasta ar fi suficient de complex ca o asemenea soluție să devina practică;
În total această soluție, deși teoretic posibilă, nu este practică.
Metoda stop(Throwable) nu este nici ea sigură. Ea poate să genereze excepții pe care firul curent nu este pregătit să le trateze, inclusiv și acele excepții pe care firul nu are cum să le arunce. Următoarea metodă:
static void sneakyThrow( Throwable t )
{
Thread.currentThread().stop( t )
}
are același efect ca operatorul throw din limbajul Java, cu excepția faptului că evită încercarea compilatorului de a garanta ca metoda chemată a declarat în clauza throws toate excepțiile pe care le poate genera.
Dacă utilizarea metodei stop este o acțiune periculoasă, atunci se pune întrebarea, naturală de altfel, cum oprim un fir de execuție atunci când dorim aceasta. Răspunsul este acela că majoritatea utilizărilor lui stop pot fi înlocuite de către porțiuni de cod în care folosirea unor variabile bine alese indică faptul că firul trebuie să își înceteze execuția. Firul ar trebui să verifice periodic starea acestor variabile și să procedeze în consecință. Pentru a asigura corectitudinea valorii acestor variabile, ele ar trebui să fie volatile sau accesul la ele să fie sincronizat.
De exemplu, să presupunem următorul cod în care este folosită metoda stop:
private Thread blinker;
public void start()
{
blinker = new Thread(this);
blinker.start();
}
public void stop()
{
//apel periculos
blinker.stop();
}
public void run()
{
Thread thisThread = Thread.currentThread();
while ( true ) {
try {
thisThread.sleep( interval );
} catch ( InterruptedException e ) { }
repaint();
}
}
Folosirea metodei stop în codul de mai sus poate fi ușor evitată după cum o demonstrează codul următor:
private volatile Thread blinker;
public void stop()
{
blinker = null;
}
public void run()
{
Thread thisThread = Thread.currentThread();
while ( blinker == thisThread ) {
try {
thisThread.sleep( interval );
} catch (InterruptedException e){ }
repaint();
}
}
Este lesne de observat că în momentul în care referința blinker devine null ciclul while din metoda run se termină.
De multe ori se pune problema opririi unui fir de execuție care se afla într-o stare de așteptare prelungită ca în cazul așteptării unor intrări de date. Pentru un asemenea scop există metoda interrupt Aceeași tehnică de mai sus este reutilizabilă, dar după aceea se poate apela metoda interrupt pentru a întrerupe acțiunea de așteptare:
public void stop()
{
Thread moribund = waiter;
waiter = null;
moribund.interrupt();
}
Pentru ca această tehnică să funcționeze, este absolut necesar ca orice metodă care primește o execepție InterruptedException și nu este pregatită să o trateze, să o confirme. Spunem ‘să o confirme’ și nu să o arunce pentru că nu este întotdeauna posibil ca o excepție să fie aruncată. Dacă metoda care prinde o excepție InterruptedException, nu declară în clauza sa throws că aruncă acest tip de excepție, atunci ar trebui să se întrerupă pe ea însăși prin următoarul apel:
Thread.currentThread().interrupt();
Acest apel garantează faptul că firul va genera din nou o excepție InterruptedException cât mai devreme posibil.
Există posibilitatea ca un fir de execuție să nu răspundă la apelul metodei interrupt. De exemplu dacă firul este blocat pe un socket deschis și așteptă noi date din conexiune. În acest caz metoda interrupt nu este de nici un folos. Singurele soluții sunt așa numitele artificii dependente de specificul situației. Astfel, în cazul socketului, acesta poate fi închis pentru că astfel se ve genera o excepție și firul de execuție se va termina imediat. Din păcate chiar nu există o regulă care să funcționeze în toate situațiile posibile.
Metodele suspend și resume sunt și ele generatoare de probleme. Metoda suspend este generatoare de situații deadlock. Dacă firul curent avea posesiunea unui monitor protejând o resursă importantă, în momentul în care este suspendat(prin apelul suspend) atunci nici un alt fir nu poate accesa această resursă până când firul suspendat nu este repus în starea gata de execuție printr-un apel resume executat de către un alt fir de execuție. Dacă firul care ar executa metoda resume ar încerca să obțină posesiunea monitorului respectiv, atunci este evident că ar apare o situație de blocare, așa numitul deadlock.
Dacă apelul metodelor suspend și resume este nerecomandabil, atunci se pune întrebarea ce este potrivit în locul lor. Ca și în cazul metodei stop, soluția constă în a avea o serie de variabile de control care să indice firului curent că trebuie să intre în starea suspendat. Când variabilele indică o stare de suspendare, atunci firul curent trebuie să execute metoda wait a clasei Object. Firul respectiv este repus în starea gata de execuție prin metoda notify sau notifyAll, după ce în prealabil variabilele de control au fost resetate pentru a nu mai indica o stare de suspendare.
De exmplu să presupunem că avem un applet a cărui animație trebuie să comute între starea suspendat și starea normală în momentul în care un click de mouse este recepționat pe suprafața sa:
private boolean threadSuspended;
public void mousePressed( MouseEvent e )
{
e.consume();
if ( threadSuspended ) {
blinker.resume();
} else {
// apel periculos
blinker.suspend();
}
threadSuspended = !threadSuspended;
}
Apelul metodei suspend(ca de altfel și al metodei resume) poate fi evitat ca mai jos:
public synchronized void mousePressed( MouseEvent e )
{
e.consume();
threadSuspended = ! threadSuspended;
if ( ! threadSuspended ) {
notify();
}
}
Pentru ca totul să fie complet în metoda run trebuie adăugat blocul sincronizat:
synchronized( this )
{
while ( threadSuspended ) {
wait();
}
}
Metoda wait generează o excepție InterruptedException, de aceea ea trebuie să fie în interiorul unui bloc try – catch. Metoda run completă este:
public void run()
{
while ( true ) {
try {
Thread.currentThread().sleep( interval );
synchronized( this ) {
while ( threadSuspended ) {
wait();
}
}
} catch ( InterruptedException e ) { }
repaint();
}
}
Observăm că apelul metodelor wait și notify se află în interiorul unor blocuri sincronizate(confrom regulilor), sincronizare făcându-se pe referința this. În ceea ce privește metoda run de mai sus, observăm că în blocul sincronizat se intră de fiecare dată, chiar dacă condițiile de suspendare nu sunt îndeplinite. Deși, cu timpul, costul sincronizării scade, totuși acesta nu va fi niciodată nul. De aceea este recomandabilă testarea condițiilor de suspendare înainte de a intra în blocul sincronizat, ca mai jos:
if ( threadSuspended ) {
synchronized( this ) {
while ( threadSuspended ) {
wait();
}
}
}
Cu această modificare, metoda run devine:
public void run()
{
while ( true ) {
try {
Thread.currentThread().sleep( interval );
if ( threadSuspended ) {
synchronized( this ) {
while ( threadSuspended ) {
wait();
}
}
}
} catch ( InterruptedException e ) { }
repaint();
}
}
În absența sincronizării explicite, varibila threadSuspended trebuie declarată ca fiind volatilă pentru a ne asigura că firul de execuție va răspunde imediat la cererea de suspendare.
Cele două metode de mai sus pot fi combinate într-un mod natural pentru a opri sau a suspenda în mod corect un fir de execuție, însă cu atenție. Astfel, dacă se dorește oprirea firului, iar acesta este suspendat, trebuie ca mai întai să fie trecut în starea gata de execuție prîntr-un apel notify. Codul este mai jos:
public void run()
{
Thread thisThread = Thread.currentThread();
while ( blinker == thisThread ) {
try {
thisThread.sleep( interval );
if ( threadSuspended && blinker == thisThread ) {
synchronized( this ) {
while ( threadSuspended && blinker == thisThread ) {
wait();
}
}
}
} catch ( InterruptedException e ) { }
repaint();
}
}
public synchronized void stop()
{
blinker = null;
notify();
}
Dacă în metoda stop am fi omis apelul metodei notify, atunci dacă firul ar fi fost suspendat în momentul când se apelează metoda stop, el ar rămâne suspendat și la o repornire a applet-ului noul fir ar fi într-o situație periculoasă căci monitorul este deja ocupat. Sincronizarea metodei stop este necesară pentru că firul să răspundă prompt la cererea de oprire.
În ceea ce privește controlul firelor de execuție prin intermediul unor variabile de control, se poate aplica următorul șablon:
fie V1, …, Vn, n varibile booleene și fie B o expresie booleană consistentă peste alfabetul format din aceste variabile și operatorii booleani uzuali: conjuncția, disjuncția, negația
în cadrul metodei run sau în cadrul unei metode apelate din run pentru a suspenda firul de execuție se adaugă codul :
synchronized ( ob ) {
if ( B ) {
while ( B ) {
ob.wait();
}
}
}
pentru a aduce din nou firul în starea gata de execuție, se poate executa metoda notify sau metoda notifyAll dintr-o metodă sicronizată:
public synchronized void resume()
{
//modificare Vi astfel încât B să fie falsă
notify(); //sau notifyAll();
}
Metoda notifyAll se apelează în cazul în care sunt mai multe fire de execuție care sunt controlate de aceleași variabile de control și au fost suspendate folosind același monitor.
În momentul în care sunt toate notificate se revine la testarea condiției din ciclul while urmând ca unele dintre fie să fie din nou suspendate.
În clasa Thread există și metoda destroy însă aeastă metodă nu a fost niciodată implementată căci ea este evident nesigură. Practic ea este echivalentă cu metoda suspend, dar fără posibilitatea de a executa resume. Ea nu este declarată ca invechită căci s-a făcut observația că există cazuri în care este de dorit o blocare pentru un anumit fir.
Se știe că în Java memoria ocupată de obiectele care nu mai sunt accesibile este automat eliberată de către colectorul de reziduri. Acest proces de colectare a memoriei este unul complex și implică mai multe aspecte. Unul din ele este apelul metodei finalize care este un fel de destructor pentru obiectele din Java. Această metodă este apelată de către colectorul de reziduri. În clasa Runtime din pachetul java.lang există o metodă statică, runFinalizersOnExit, ce primește ca argument o valoare booleană, și care specifică dacă, la ieșirea din mediul de execuție este nevoie de apelul finalizatorilor obiectelor. Acest apel este riscant căci este posibil ca apelul metodei finalize să se execute pe obiecte accesibile ceea ce ar duce la un comportament neașteptat al firelor de execuție care manipulau aceste obiecte.
Despre programarea paralelă
Anumite aplicații stiințifice(mai ales cele din domenii de cercetare) necesită calculatoare cu putere de calcul ce depășește cu mult un miliard de instrucțiuni pe secundă.
Performanța unui calculator se poate exprima fie prin numărul de operații în virgulă mobilă pe secundă (Mflops), fie prin numărul de instrucțiuni 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., ca și de costul mult prea ridicat al unor astfel de echipamente.
O soluție alternativă, radical diferită, a fost aceea a utilizării unor arhitecturi paralele. Ideea de bază de la care s-a pornit a fost aceea a unui număr mare de procesoare având o putere și un cost relativ scăzute, care cooperează la rezolvarea unei probleme ce necesită un calculator cu o putere mare de calcul.
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 mai multe 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.
Precizăm în continuare 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, atunci procesorul Pi va scrie valoarea într-o locație a memoriei comune, 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 citi sau nu 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 citire 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 xX în parallel
instrucțiune(x)
endfor
unde X este o mulțime finită.
Semnificația acestei instrucțiunii 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ă, binențeles pentru elementul xX 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.
Ideea conform căreia un algoritm ce rulează pe p procesoare va determina soluția de p ori mai repede este eronată, în primul rând pentru 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, de sincronizare, de limitele schimbului de date etc. Performanțele unui program sunt desigur influențate și de numărul de procesoare utilizate.
O metodă de calcul paralel
În acest paragraf prezentăm o metodă de implementare a intrucțiunii paralele,
metodă cunoscută sub denumirea de ‘metoda arborelui binar’. Este o metodă cu un potențial de aplicabilitate sporit.
Proveniența numelui ei vine de la faptul că este utilizat un arbore binar complet, adică un arbore binar cu 2k-1 vrfuri n care:
vrfurile sunt situate pe nivelurile 0, 1, …, k – 1
vrfurile situate pe nivelurile 0, 1, …, k – 2 au exact doi descendenți, iar cele de pe nivelul k – 1 sunt vrfuri terminale (frunze)
vrfurile de pe un nivel i oarecare sunt numerotate cu 2i … 2i+1-1; descendenții vrfului i sunt vrfurile 2i și 2i + 1
Fie vectorul a = ( a0, … ,an-1 ) și o operație asociativă . Se urmărește calculul valorii a0 a1 … an-1.
Vom presupune că n este o putere a lui 2 ( n=2m ). n caz contrar vom completa vectorul la dreapta la o lungime egală cu o putere a lui 2, cu elementul neutru al operației :
0: dacă este operatorul de adunare
1: dacă este operatorul de nmulțire
+∞: dacă este operatorul de minim
-∞: dacă este operatorul de maxim
F: dacă este operatorul de disjuncție logică
T: dacă este operatorul de conjuncție logică.
Vom construi un arbore binar complet și vom atașa valori vrfurilor sale. În rădăcină va fi calculată valoarea finală cerută de problemă. Fiecărui nod intern i se atașează o valoare parțială și anume "suma" corespunzătoare (sub)arborelui de rădăcină i; pe fiecare nivel (adâncime în arbore) calculele se execută în paralel. Metoda este de tip bottom-up, adic vom parcurge arborele pe niveluri, plecnd de la frunze și mergnd spre rdcină. Mai întâi se dublează dimensiunea vectorului și se mută elementele vectorului pe pozițiile an, … ,a2n-1 :
for i = 0,n – 1 în parallel do
an+i ai (*)
endfor
Apoi parcurgem arborele de jos n sus, atribuind valori vrfurilor interne. Valoarea fiecărui vrf se calculează pe baza valorilor descendenților săi, folosind operatorul .
for k=m-1,0,-1 do
for i=2k,2k+1-1 în parallel (**)
ai a2i a2i+1
endfor
endfor
Valoarea ai atașată unui vrf i este rezultatul aplicării operației asupra valorilor atașate frunzelor din subarborele de rădăcină i. Prin urmare rezultatul se obține în a1. Pentru implementare se folosesc n procesoare, aici sensul de procesor putând să însemne și fir de execuție. Complexitatea este O(m) = O(log n), deoarece ciclul for interior necesită timp constant și se execută de m ori. În continuare vom exemplifica metoda arborelui binar alegând ca operație , adunarea, care, evident, este asociativă. Fie vectorul a = ( 1, 2, 3, 4, 5, 6, 7, 8 ) cu n = 8 = 23 și m = 3. În imaginea de mai jos se schițeză pașii algoritmului:
Mai jos urmează o implementare în Java a metodei arborelui binar.
Clasa BinaryTree conține câmpurile a (vectorul căruia i se aplică metoda), size (numărul elementelor vectorului) și levels cu size=2levels. Metoda principală începe cu citirea elementelor vectorului, folosind metodele clasei StandardIO.
Pentru efectuarea calculelor (*), adică pentru deplasarea elementelor vectorului pe pozițiile n..2n-1, este folosită clasa Shift care extinde clasa Thread. Constructorul acesteia furnizează valoarea i, iar valoarea lui size este transmisă folosind câmpul static al clasei. Metoda run prevede executarea calculului (*). Sunt create n obiecte de tipul Shift, pe baza cărora sunt lansate n fire de execuție. Să observăm că pentru a folosi modelul P-RAM (cu memorie comună) vectorul a a fost declarat cu static, astfel încât să poată fi referit din afara clasei. De asemenea utilizăm metoda join pentru așteptarea terminării firelor de execuție lansate, caracteristică specifică programării paralele.
Pentru efectuarea prelucrării (**)se procedează în mod similar, folosind de această dată clasa Computer, care extinde și ea clasa Thread. Programul este următorul:
public class BinaryTree {
static int[] a = new int[ 32 ];
static int size;
public static void main( String[] args )
{
int levels = 0,
i = 0,
size = 1;
StandardIO.write( "Levels number = " );
levels = StandardIO.read();
for ( i = 0; i < levels; i ++ ) {
size *= 2;
}
StandardIO.write( "Input numbers: " );
for ( i = 0; i < size; i ++ ) {
a[ i ] = StandardIO.read();
}
Shift[] shift = new Shift[ size ];
Shift.size = size;
for ( i = 0; i < size; i ++ ) {
shift[ i ] = new Shift( i );
}
try {
for ( i = 0; i < size; i ++ ) {
shift[ i ].join();}
} catch ( InterruptedException e ) { }
Computer[] computers = new Computer[ size ];
int first = size / 2, last = size;
for ( int l = levels – 1; l >= 0; l – ) {
for ( i = first; i < last; i ++ ) {
computers[ i ] = new Computer( i );
}
try {
for ( i = first; i < last; i ++ ) {
computers[ i ].join();
}
} catch( InterruptedException e ) { }
StandardIO.write( "Value on level #" + l + ": " );
for ( i = first; i < last; i ++ ) {
StandardIO.write( a[ i ] + " " );
}
System.out.println();
first = first / 2; last = last / 2;
}
StandardIO.writeln( "Total = " + a[ 1 ] );
}
}
class Shift extends Thread {
static int size;
int i;
public Shift( int i )
{
this.i = i;
start();
}
public void run()
{
BinaryTree.a[ size + i ] = BinaryTree.a[ i ];
}
}
class Computer extends Thread {
int i;
public Computer( int i )
{
this.i = i;
start();
}
public void run()
{
BinaryTree.a[ i ] = BinaryTree.a[ 2 * i ] +
BinaryTree.a[ 2 * i + 1 ];
}
}
Pentru completitudine prezentăm și clasa StandardIO, o clasă utilitară pentru citirea unor date de la intrarea standard sau afișarea lor la ieșirea standard:
import java.io.*;
public class StandardIO {
public static void write( String string )
{
System.out.print( string );
}
public static void writeln( String string )
{
System.out.println( string );
}
public static void writeln()
{
System.out.println(
System.getProperty( "line.separator" ) );
}
public static int read()
{
int i = 0;
try {
byte[] b = new byte[ 64 ];
int hm = System.în.read( b );
String s = new String( b, 0, hm – 2 );
i = Integer.parseInt( s );
} catch ( IOException e ) {
e.printStackTrace();
} catch ( NumberFormatException e ) {
System.out.println( "An integer was expected" );
}
return i;
}
}
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 diferite de cele din programarea secvențială.
Aproape întotdeauna, executarea concurentă a mai multor procese (fire de execuție) 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.
În programarea concurentă nu contează 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:
alege aleator unul dintre procesele cărora nu îi este asociat un procesor fizic;
execută, un interval de timp aleator, 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.
În programarea concurentă o serie de aspecte, care în programarea secvențială ar putea fi considerate tautologii(în sensul că ele sunt perfect valabile indiferent de context), nu mai sunt implicit valabile. Câteva dintre astfel de exemple le menționăm mai jos:
în programarea secvențială, secvența de instrucțiuni ‘i = 1; i = 2;’ este echivalentă cu a doua instrucțiune; î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 este posibil ca celelalte procese să execute diferite instrucțiuni care să folosească efectiv faptul că pe parcursul unui interval de timp (a cărui lungime nu poate fi estimată) valoarea lui i este egală cu 1
efectul instrucțiunii ‘i = 1; if ( i == 1 ) j = j + i;’ nu constă neapărat în incrementarea cu o unitate a lui j; pe de o parte condiția din if nu este neapărat verificată, deoarece (între momentul efectuării atribuirii și momentul efectuării comparației) valoarea lui i poate fi eventual modificată de un alt proces; pe de altă parte, chiar presupunând că valoarea lui i este 1 în momentul comparației, ea se poate schimba înainte de efectuarea adunării. î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 își au cauza în utilizarea de către mai multe procese a unei memorii comune.
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 2,3,4,6. De aceea trebuie studiate toate scenariile posibile pentru a avea siguranța că vom obține rezultatul dorit.
Programarea concurentă are că scop rezolvarea interacțiunilor dintre procese. Deci 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. În paragraful următor vom prezenta o problemă tipică de programare concurentă, ea nefiind doar o simplă problemă ci o situație generală în care se înscriu foarte multe probleme de programare concurentă. Este vorba de bine cunoscuta problemă Producător-Consumator.
Problema Producător-Consumator
Enunțul general al problemei este următorul: să se simuleze activitatățile unui producător și consumator care folosesc în comun o bandă (producătorul produce articole și le pune pe bandă, consumatorul ia de pe bandă un articol și îl consumă) astfel încât să se evite următoarele situații:
producătorul încercă să pună un articiol pe banda plină
consumatorul încearcă să ia un articol de pe banda goală
În esență problema constă în a gestiona banda în așa fel încât producătorul să fie împiedicat să pună un articol pe bandă dacă aceasta este plină, iar consumatorul să fie împiedicat să ia un articol de pe bandă dacă aceasta este goală. Implementarea în Java este facilitată de existența mecanismelor de sincronizare. Prezentăm mai jos o implementare:
import java.util.*;
public class ProducerConsumer {
public static void main( String[] args )
{
LaneTrack track = new LaneTrack();
Producer producer = new Producer( track );
Consumer consumer = new Consumer( track );
}
}
class Time extends Random {
int delay()
{
return ( int ) ( 100.0f * nextFloat() );
}
}
class LaneTrack {
static Time delayAmount = new Time();
int capacity = 3, available,
first, last = capacity – 1;
char[] queue = new char[ capacity ];
synchronized char consume()
{
char ch;
while ( available == 0 ) {
try {
wait();
} catch ( InterruptedException e ) { }
}
ch = queue[ first ];
first = ( first + 1 ) % capacity; available –;
System.out.println( "S-a consumat " + ch );
notify();
return ch;
}
synchronized void produce( char ch )
{
while ( available == capacity ) {
try {
wait();
} catch ( InterruptedException e ) { }
}
last = ( last + 1 ) % capacity;
queue[ last ] = ch;
available ++;
System.out.println( "S-a produs " + ch );
notify();
}
}
class Producer extends Thread {
private LaneTrack laneTrack;
public Producer( LaneTrack track )
{
laneTrack = track;
start();
}
public void run()
{
char ch;
for ( ch = 'a'; ch <= 'z'; ch ++ ) {
laneTrack.produce( ch );
try {
Thread.sleep( LaneTrack.delayAmount.delay() );
} catch ( InterruptedException e ) { }
}
}
}
class Consumer extends Thread {
private LaneTrack laneTrack;
Consumer( LaneTrack track )
{
laneTrack = track;
start();
}
public void run()
{
char ch;
do {
ch = laneTrack.consume();
try {
Thread.sleep( LaneTrack.delayAmount.delay() );
} catch ( InterruptedException e ) { }
} while ( ch != 'z' );
}
}
Explicăm pe scurt implementarea. Clasa Time este utilizată pentru a spori gradul de naturalețe al simulării. Astfel atât producătorul cât și consumatorul își execută activitățile într-un ritm variabil. Clasa Producer implementează conceptul de producator, așa cum clasa Consumer implementează conceptul de consumator. Ambele clase sunt derivate din clasa Thread, deci sunt fire de execuție. Producătorul produce litere ale alfabetului de la ‘a’ la ‘z’ pe care le dispune pe bandă. Consumatorul consumă aceste litere până când ajunge la ultima literă, ‘z’. Se observă că în cele două clase nu există nici un mecanism de sincronizare. Tot ‘nucleul’ problemei este clasa LainTrack care abstractizează noțiunea de bandă. Ca structură de date această bandă este implementată sub forma unei cozi. Deci are o disciplină de ordine FIFO. Există două metode aici:
metoda produce testează mai întâi dacă pe bandă este un loc liber; dacă nu atunci execută un apel wait, iar dacă da produce un caracter și îl așează pe bandă, la sfârșitul acesteia
metoda consume testează mai întâi dacă pe bandă există un articol disponibil; dacă nu atunci execută un apel wait, iar dacă da ia primul caracter de pe bandă și îl consumă
Cele două metode sunt sincronizate pentru că apelul metodei wait presupune că firul de execuție care execută acest apel să aibă controlul monitorului obiectului a cărui metodă wait este apelată și pentru că o resursă coumnă. și anume banda, este accesată în mod concurent de către producător și consumator.
Multe probleme din realitate sunt în esență expresia problemei producător –consumator. De aceea această problemă nu este doar o simplă problemă ci un model. Este poate cel mai important model de programare concurentă.
Implementarea noțiunii de semafor în Java
Noțiunea de semafor a fost introdusă de către Dijkstra cu scopul de a facilita rezolvarea problemelor de programare concurentă.
Semaforul este un tip de date, caracterizat deci prin valori posibile și operații permise. O variabilă de tip semafor poate lua c valori numai numere naturale. Operațiile permise în lucrul cu semafoare sunt prezentate în continuare, unde s este o variabilă de tip semafor:
inițializarea valorii lui s cu un număr natural
wait(s):if s > 0 then s s – 1
else procesul curent este blocat
– signal(s):if există procese blocate then este deblocat unul dintre ele
else s s+1
Definiția semafoarelor specifică faptul că operațiile care sunt definite asupra lor se execută în regim de excludere reciprocă(sunt atomice). Cele trei operații de mai sus sunt singurele autorizate în lucrul cu semafoare. O variabilă de tip semafor nu se poate folosi ca operand în alte expresii. Deși semaforul este un tip de date, pentru ușurința exprimării, vom spune semafor și unei variabile de tip semafor.
Având în vedere operațiile definite pe semafoare, putem da acum o implementare a acestui tip de date în Java:
public class Semaphore {
private int value = 0;
public Semaphore( int initialValue )
{
value = initialValue;
}
public synchronized void waitOn()
{
value –;
if ( value < 0 ) {
try {
wait();
} catch( InterruptedException e ) { }
}
}
public synchronized void signalOn()
{
if ( value < 0 ) {
notify();
}
value ++;
}
}
După cum se observă, constructorul clasei primește ca argument valoarea inițială a semaforului. Metoda waitOn implementează operația wait asupra semafoarelor, iar metoda signalOn implementează operația signal asupra semafoarelor.
Apelul metodei waitOn poate duce la blocarea firului apelant. Un fir se blochează doar dacă value este la intrarea în metoda, 0. Numărul firelor blocate în apelul metodei waitOn a aceluiași semafor este egal cu –value. Prin apelul metodei signalOn firul curent își continuă execuția. Dacă la intrarea în metoda signalOn, value este negativă atunci se execută metoda notify ceea ce va duce la activarea unui fir blocat de către acest semafor. După cum specifică definiția semaforului, metodele waitOn și signalOn sunt sincronizate ceea ce asigură execuția lor în regim de excludere reciprocă. În metoda signalOn, dacă se activează vreun fir de execuție atunci acesta va deveni efectiv activ după ieșirea din metoda signalOn aceasta fiind sincronizată.
Prezentăm în continuare rezolvarea unei probleme prin intermediul semafoarelor și anume problema folozofilor chinezi.
Enunțul acesteia este: la o masă rotundă stau n filozofi chinezi; principala lor activitate este cea de a gândi, dar evident din când în când trebuie să mănânce, folosind pentru aceasta câte două bețișoare; știind că între oricare doi filozofi se află un bețișor și că un filozof poate mânca doar dacă a ridicat de pe masă atât bețișorul din stânga să, cât și bețișorul din dreapta să, se cere să se simuleze activitățile filozofilor așezați la masă.
Singura restricție care se impune este aceea că la nici un moment de timp să nu mănânce doi filozofi vecini, căci ei ar trebui să folosească bețișorul dintre ei. Vom numerota filozofi cu numere de ordine de la 0 la n – 1, unde n este numărul total al filozofilor, și convenim ca la stânga filozofului i ,se află bețișorul i , iar la dreapta sa bețișorul i + 1 . Fiecărui filozof îi asociem un fir de execuție, iar fiecărui bețișor i îi asociem un semafor bi; încercarea de ridicare a unui bețișor corespunde interogării semaforului asociat bețișorului.
O primă variantă de rezolvare a problemei propune ca încercarea de ridicare a unui bețișor să fie precedată de o operație wait asupra semaforului asociat, iar punerea bețișorului pe masă să fie urmată de o operație signal asupra semaforului asociat. Această revine la următoarele acțiuni pentru filozoful cu numărul i:
do {
{ filozoful i gândește }
wait( b[ i ] );
wait( b[( i mod nf ) + 1 ] );
{ filozoful i mănâncă }
signal( b[ i ] );
signal( b[ ( i mod nf ) + 1 ] );
} while true
Această variantă nu este corectă, căci poate conduce la o situație de tip deadlock. Acest lucru este posibil atunci când toți filozofii ridică simultan bețișorul aflat la stânga lor: în continuare nici un filozof nu va putea ridica bețișorul din dreapta sa.
Evitarea situației deadlock se poate obține prin a considera că în permanență va exista cel puțin un filozof altruist, care renunță momentan să mănânce(ceea ce este echivalent cu a considera că scaunul său este liber); de aici va rezulta că cel puțin un filozof va avea posibilitatea de a ridica bețișoarele aflate în stânga și dreapta sa, deci va putea să mănânce.
Pentru a modela această soluție, vom utiliza un semafor freeCell, care va memora în permanență numărul de filozofi care renunță momentan să mănânce. Inițializarea sa se poate face cu orice valoare întreagă din intervalul [1, nf – 1], dar cu cât alegem o valoare mai mare, cu atât gradul de concurență va fi mai ridicat.
Nu contează care care sunt filozofii altruiști, ci doar numărul lor. Operațiile
wait( ScaunLiber ) și signal( ScaunLiber ) trebuie plasate înainte de încercarea de ridicare a primului bețișor și respectiv după semnalarea repunerii pe masă a ultimului dintre cele două bețișoare pe care filozoful le-a folosit pentru a mânca. Într-adevăr, cât timp filozoful gândește, nu are importanță dacă există sau nu filozofi altruiști.
Programul în Java este următorul:
public class Philosophers {
static int i;
public static void main( String[] str )
{
StandardIO.write( "How many philosophers ? " );
int howMany = ( int ) StandardIO.read();
new Shared( howMany );
Philosopher[] philosophers = new Philosopher[ howMany ];
for ( i = 0; i < howMany; i ++ ) {
philosophers[ i ] = new Philosopher( i );
}
}
}
class Shared {
static Semaphore[] semaphors;
static Semaphore freeCell;
static int howMany;
Shared( int howMany )
{
this.howMany = howMany;
freeCell = new Semaphore( howMany – 1 );
semaphors = new Semaphore[ howMany ];
for ( int i = 0; i < howMany; i ++ ) {
semaphors[ i ] = new Semaphore( 1 );
}
}
}
class Philosopher extends Thread {
private int id;
Philosopher( int id )
{
this.id = id;
start();
}
public void run()
{
for ( int i = 0; i < 10; i ++ ) {
try {
sleep( ( int )( 100 * Math.random() ) );
} catch( InterruptedException e ) { }
Shared.freeCell.waitOn();
Shared.semaphors[ id ].waitOn();
Shared.semaphors[ ( id + 1 )
% Shared.howMany ].waitOn();
StandardIO.write( " Start" + id );
try {
sleep( ( int )( 100 * Math.random() ) );
} catch( InterruptedException e ) { }
StandardIO.write( " Ready" + id );
Shared.semaphors[ id ].signalOn();
Shared.semaphors[ ( id + 1 ) %
Shared.howMany ].signalOn();
Shared.freeCell.signalOn();
}
}
}
Concluzii
Cu acest capitol am trecut în revistă aspecte generale ale programării multithreading în limbajul Java. Clasa Thread oferă un suport solid pentru o programare paralelă și concurentă de bună calitate. Există o serie de tehnici general valabile, însă în mare, programarea concurentă și paralelă este suficient de dificilă și imaginativă. Suportul multithreading asigurat de limbajul Java este suficient pentru marea majoritate a aplicațiilor, mai puțin cele care sunt sensibile la diferențe foarte mici de timp. Este vorba de aplicațiile în timp real. Pentru acestea există în curs de dezvoltare pachetul javax.realtime care definește clasa RealThred capabilă să execute sarcini în timp real.
Firele de execuție au o aplicabilitate enormă și greu se poate imagina o aplicație complexă, care să modeleze situații reale și care să nu folosească mai multe fire de execuție. Un exemplu îl reprezintă interfețele grafice moderne. Fără suport multithreading ele nu ar fi atât de flexibile. În următorul capitol vom prezenta o serie de aspecte ale interfeței grafice Swing legate de firele de execuție.
Fire de execuție.
Aspecte privind interfața grafică Swing
Interfețele grafice moderne au o serie de facilități care le fac extrem de ușor de folosit de către utilizatori. Suportul multithreading este din plin folosit de către acestea. În limbajul Java există un pachet care pune la dispoziție o interfață grafică bine construită folosind ca șablon de design MVC(Model – View – Controller) și cunoscută sub numele de Swing. Este o interfața grafică care, deși are o vârstă de câțiva ani și este dezvoltată într-un cadru relativ restrâns de către o echipă nu foarte mare dar de foarte bună calitate, pune la dispoziție extrem de multe facilități. Este solid construită pe baze care suportă extinderi de amploare. Foarte mult timp s-a investit în construirea infrastructurii ei și în modul în care va fi implementată. Ca în orice soft de mare amploare există o serie de compromisuri care se fac între memorie și timp de calcul, totul pentru atingerea unei performanțe optime. Swing-ul are încorporate toate controalele necesare construirii unei interfețe grafice expresive care să răspundă la cerințele utilizatorilor. Presupune o serie de mecanisme care îl fac usor de folosit și de extins. Față de multe alte interfețe grafice, Swing-ul este foarte simplu la suprafață și folosirea sa pentru construirea unei interfețe grafice este ușoară. Evident, dacă se doresc subtilități, trebuie înțelese mecanismele interne, ceea ce nu este ușor, căci așa cum am spus este un soft de o complexitate sporită și a presupus un efort de concepție sporit. O mare calitate a Swing-ului este aceea că asigură independența de platformă a interfețelor grafice. Indiferent de contextul grafic al sistemului de operare gazdă, interfața grafică își păstrează același aspect. Este o mare ușurare pentru programatori faptul că știu că ceea ce au făcut este un bun făcut pentru orice platformă.
Construirea Swing-ul a presupus multe decizii de luat. Deoarece Java este un mediu multithreading în mod implicit era nevoie de modelarea comportamentului Swing-ului în raport cu un astfel de mediu. Trebuia în principiu să se ia o decizie de genul: trebuie ca Swing-ul să fie thread-safe, adică să presupună siguranță la accesul componentelor sale din mai multe fire de execuție, sau nu. În final decizia a fost ca Swing-ul să nu fie thread-safe. Evident această decizie are consecințe importante și generează aspecte pe care programatorii trebuie să le cunoască și să le folosească în mod corect. De acest aspect ne vom lega în continuare.
Pentru cea mai mare parte a sa, Swing-ul nu este thread-safe. Astfel componentele sale nu pot fi accesate decât dîntr-un singur fir de execuție. Mai întâi vom explica de ce Swing-ul nu este thread-safe, iar apoi vom discuta care sunt consecințele acestui fapt.
În primul rând trebuie să recunoaștem că a dezvolta aplicații folosind mai multe fire de execuție nu este ușor, chiar și în Java unde suportul pentru multithreading este solid și pe cât posibil simplu. A construi un pachet grafic thread-safe este un lucru dificil. De exemplu este greu a se determina cum trebuie sincronizat accesul la o clasă anume. Alt aspect îl reprezintă extinderea claselor thread-safe. Este chiar un fel de artă în a extinde clase thread-safe pe care o cunosc doar programatorii experți în programare concurentă și paralelă, ceea ce nu este cazul majorității programatorilor. Deci unul din principalele motive pentru care Swing-ul nu este thread-safe este acela de a extinde ușor componentele sale.
Un alt motiv este faptul că sincronizarea presupune, după cum am văzut, obținerea controlului monitoarelor și apoi eliberarea lor. Această presupune consum de timp ceea ce duce la reducerea performanței. Toate aplicațiile care folosesc un pachet grafic thread-safe, chiar dacă ele sunt sau nu programate pe mai multe fire de execuție, trebuie să suporte aceast cost al sincronizării.
Utilizarea firelor de execuție sporește dificultatea depanării programelor, a întreținerii și extinderii acestora. De exemplu testarea și întreținerea programelor, care sunt cerințe importante în ingineria software-ului, devin suficient de dificile(uneori aproape imposibile) într-un program multithreaded.
Totuși câteva componente din Swing suportă acces concurent în privința unor aspecte. Astfel metodele repaint, revalidate și invalidate din clasa JComponent plasează cererile lor în coada firului de execuție care se ocupă cu gestionarea și generarea evenimentelor din interfața grafică. Deci metodele anterioare pot fi apelate din orice fir de execuție. De asemenea clasa din Swing care se ocupă cu gestionarea listener-ilor suportă acces din orice fir de execuție. Unele clase au metode sincronizate(de exemplu metoda setState() din clasa JCheckBoxMenuItem.
Consecințe privind privind nesiguranța Swing-ului la acces concurent
Principala consecință a faptului că Swing-ul nu este thread-safe este următoarea:
comonentele Swing(în marea lor majoritate) nu pot fi accesate din mai multe fire de execuție odată ce ele sunt vizibile pe ecran. Deci există un singur fir de execuție autorizat să apeleze metode ale componentelor Swing. Acest fir se numește Event Dispatch Thread și în traducere înseamnă firul de execuție care se ocupă cu distribuirea evenimentelor. Din cauza conciziei denumirii în limba engleză, vom prefera denumirea în această limbă.
Event Dispatch Thread este firul care apelează metode ca paint și update(care se ocupă cu păstrarea integrității aspectului grafic al interfeței) sau metodele care se ocupă cu prelucrarea evenimentelor, metode definite în interfețele receptorilor evenimentelor corespunzătoare. De exemplu clasele care implementează interfețe ca ActionListener sau PropertyListener au metodele actionPerformed respectiv propertyChange apelate din Event Dispatch Thread.
Tehnic vorbind, componentele Swing pot fi apelate din diferite fire de execuție înainte ca ele să fie vizibile pe ecran. De exemplu interfața grafică a unui applet poate fi construită în metoda init a să, atât timp cât ele nu sunt vizibile.
Metodele invokeLater și invokeAndWait
Deoarece Swing-ul este un pachet grafic care se lucrează cu evenimente, este natural ca prelucrarea interfeței să se facă prin apelul unor metode executate în event dispatch thread. De exemplu, dacă o listă de date trebuie să fie reactualizată atunci când un buton este apăsat, reactualizarea aceasta este implementată în metoda actionPerformed a unui listener atașat butonului respectiv.
Însă există situații când se dorește reactulizarea interfeței dintr-un fir de execuție diferit de event dispatch thread. Astfel, dacă lista de mai sus trebuie reactualizată cu elemente obținute dintr-o bază de date sau dintr-o conexiune oarecare, și dacă reactualizarea se implementează în același mod ca mai sus, poate exista un decalaj vizibil între momentul în care butonul a fost apăsat și momentul în care reactualizarea s-a încheiat. Pe tot parcursul operației butonul va ramâne desenat în starea apăsat și, mai mult decât atât, nici un alt eveniment nu va mai fi distribuit de către event dispatch thread care este ocupat cu executarea metodei actionPerformed a listener-ului atașat butonului. În general, operațiile care prin însăși natura lor sunt consumatoare de timp nu trebuie executate în event dispatch thread căci interfața grafică devine blocată până la consumarea operației respective.
Soluția constă în a executa operațiile lungi în fire de execuție separate(altele decât event dispatch thread). Astfel interfața nu ramâne blocată, dar se pune problema următoare: cum modificăm elementele de interfață(deja vizibile) în mod sigur din alt fir de execuție. Swing-ul prevede două mecanisme pentru acest scop.
Soluțiile oferite constau în existența a două metode în clasa SwingUtilities și anume: invokeLater și invokeAndWait. Aceste metode plasează în coada de evenimente gestionată de event dispatch thread un obiect care implementează interfața Runnable. Când acest obiect urmează a fi prelucrat, metoda run este apelată de către event dispatch thread. Deci, în acest mod, se poate plasa un bloc de cod arbitrar în coada de evenimente a interfeței grafice și, la momentul potrivit acest bloc este executat în event dispatch thread. Pe mai departe vom explica diferențele dintre cele două metode și momentul în care este bine să fie utilizate.
Metoda invokeLater
Înainte de a discuta această metodă să dăm un exemplu de applet care conține un button și o bară de progres. Când butonul este apăsat se începe simularea unei operații consumatoare de timp. Pe măsură ce se primește informața de la această operație(este vorba despre o valoare întreagă) bara de progres este reactualizată.
În imaginea din stânga sus se află applet-ul în starea sa inițială., iar în cea din dreapta jos se află applet-ul după ce butonul start a fost apăsat, informația a fost primită și bara de progres actualizată.
Applet-ul înregistrează un listener pentru butonul start. În acest listener se creează un fir de execuție care simulează o operație lungă și apoi transimte barei de progres o valoare aleatoare între 0 și 100. Primirea informației este simulată prin metoda sleep. Firul de execuție accesează bara de progres printr-o metodă a applet-ului care întoarce o referință la bară. După ce butonul este apăsat și listener-ul respectiv pornește firul de execuție, butonul este dezactivat. Această este o operație validă căci metoda actionPerformed este invocată în event dispatch thread. Dar valoarea barei de progres este reactualizată din alt fir de execuție ceea ce nu este corect.
Mai jos dăm sursa completă a applet-ului discutat:
import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
public class Test extends JApplet {
JProgressBar pb = new JProgressBar();
public void init()
{
Container contentPane = getContentPane();
final JButton startButton = new JButton( "start" );
contentPane.setLayout( new FlowLayout() );
contentPane.add( startButton );
contentPane.add( pb );
startButton.addActionListener( new ActionListener() {
public void actionPerformed( ActionEvent e ) {
GetInfoThread t = new GetInfoThread( Test.this );
t.start();
// operatie executată în cadru legal
// în event dispatch thread
startButton.setEnabled( false );
}
} );
}
public JProgressBar getProgressBar()
{
return pb;
}
}
class GetInfoThread extends Thread {
Test applet;
public GetInfoThread( Test applet )
{
this.applet = applet;
}
public void run()
{
while( true ) {
try {
// simularea primirii informatiei
Thread.currentThread().sleep( 500 );
System.out.println( "." );
// operatie executată în cadru gresit caci
// nu este apelată în event dispatch thread
applet.getProgressBar().setValue(
( int )Math.random() * 100 );
} catch( InterruptedException e ) {
e.printStackTrace();
}
}
}
}
Modul corect de a reactualiza valoarea barei de progres este demonstrat mai jos prin folosirea metodei invokeLater a clasei SwingUtilities. În constructorul firului de execuție se creează un obiect de tip Runnable care obține o referință către bara de progres și ii reactualizează valoarea. Apoi în metoda run a firului se apelează metoda invokeLater având ca argument obiectul de tip Runnable.
Sursa corectă și completă a applet-ului se află mai jos:
import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
public class Test extends JApplet {
JProgressBar pb = new JProgressBar();
public void init()
{
Container contentPane = getContentPane();
final JButton startButton = new JButton( "start" );
contentPane.setLayout( new FlowLayout() );
contentPane.add( startButton );
contentPane.add( pb );
startButton.addActionListener( new ActionListener() {
public void actionPerformed( ActionEvent e ) {
GetInfoThread t = new GetInfoThread( Test.this );
t.start();
// operatie executată în cadru legal
// în event dispatch thread
startButton.setEnabled( false );
}
} );
}
public JProgressBar getProgressBar()
{
return pb;
}
}
class GetInfoThread extends Thread {
Runnable runnable;
int value;
public GetInfoThread( final Test applet )
{
runnable = new Runnable() {
public void run()
{
JProgressBar pb = applet.getProgressBar();
pb.setValue( value );
}
};
}
public void run()
{
while( true ) {
try {
// simularea primirii informatiei
Thread.currentThread().sleep( 500 );
System.out.println( "." );
// acum operatia este executată în cadru corect
// caci este apelată în event dispatch thread
value = ( int )( Math.random() * 100 )
SwingUtilities.invokeLater( runnable );
} catch( InterruptedException e ) {
e.printStackTrace();
}
}
}
}
Metoda invokeAndWait
Metoda invokeAndWait execută, în esență aceeași acțiune ca și metoda invokeLater, însă, după cum sugerează și denumirea să, firul de execuție din care se apelează așteaptă până când metoda run a obiectului de tip Runnable se termină. În cazul metodei invokeLater se revine imdeiat din apelul său neașteptându-se terminarea metodei run din obiectul de tip Runnable. Metoda invokeAndWait este utilă atunci când trebuie să se obțină o informație de la o comopnentă Swing.
În applet-ul de mai sus valoarea barei de progres se reactualizează indiferent dacă noua valoare este egală cu cea veche, ceea ce poate reprezenta o redundanță. Ar fi indicat ca înainte de reactualizare să se obțina mai întâi valoarea barei de progres și apoi să se compare cu valoarea ce a nouă. Dacă diferă atunci reactualizăm bara de progres.
Firul de execuție este modificat astfel încât să mai definească un obiect de tip Runnable în care obține valoarea barei de progres. În metoda run a firului se va apela mai întâi metoda invokeAndWait , iar apoi dacă este cazul metoda invokeLater. Apelul metodei invokeAndWait nu se încheie până când nu se termină metoda getValue a barei de progres. SwingUtilities.invokeAndWait() poate genera două execpții: sau InterruptedException sau InvocationTargetException. Trebuie ca aceste excepții să fie prinse în metoda din care se face apelul sau metoda din care se face apelul trebuie să declare în clauza throw a sa că poate genera respectivele excepții.
Mai jos listăm sursa apletului care implementează cele discutate mai sus:
import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
import java.lang.reflect.*;
public class Test extends JApplet {
private JProgressBar pb = new JProgressBar();
public void init()
{
Container contentPane = getContentPane();
final JButton startButton = new JButton( "start" );
contentPane.setLayout( new FlowLayout() );
contentPane.add( startButton );
contentPane.add( pb );
startButton.addActionListener( new ActionListener() {
public void actionPerformed( ActionEvent e ) {
GetInfoThread t = new GetInfoThread( Test.this );
t.start();
// operatie executată în cadru legal
// în event dispatch thread
startButton.setEnabled( false );
}
} );
}
public JProgressBar getProgressBar()
{
return pb;
}
}
class GetInfoThread extends Thread {
Runnable getValue, setValue;
int value, currentValue;
public GetInfoThread( final Test applet )
{
getValue = new Runnable() {
public void run() {
JProgressBar pb = applet.getProgressBar();
currentValue = pb.getValue();
}
};
setValue = new Runnable() {
public void run() {
JProgressBar pb = applet.getProgressBar();
pb.setValue( value );
}
};
}
public void run()
{
while( true ) {
try {
Thread.currentThread().sleep( 500 );
value = ( int )( Math.random() * 100 );
// operatia este executată în cadru corect
// caci este apelată în event dispatch thread
try {
SwingUtilities.invokeAndWait( getValue );
}
catch( InvocationTargetException ite ) {
ite.printStackTrace();
}
catch( InterruptedException ie ) {
ie.printStackTrace();
}
if( currentValue != value ) {
SwingUtilities.invokeLater( setValue );
}
}
catch( InterruptedException e ) {
e.printStackTrace();
}
}
}
}
Între cele două metode există o diferență pe care este necesar să o semnalăm. După cum am spus, metoda invokeLater nu blochează firul din care este apelată până când se termină execuția metodei run a obiectlui de tip Runnable cu care se apelează metoda. În schimb metoda invokeAndWait o face. Deci dacă metoda invokeAndWait este apelată din event dispatch thread atunci va apărea o situație deadlock căci se așteaptă ca evenimentele să fie distribuite însă nu se poate căci evenimentele nu pot fi distribuite până când nu se termină apelul metodei. În fapt event dispatch thread așteaptă propria sa terminare.
Există situații în care se dorește să se afle dacă, la momentul rulării unei metode, aceasta este rulată în event dispatch thread. Pentru aceasta există metoda isEventDispatchThread din clasa SwingUtilities. Ea întoarce true dacă și numai dacă apelul ei de face din event dispatch thread. Este o metodă utilă pentru că ne ajută să a eliminăm eventualele redundanțe care ar putea să rezulte din faptul că nu știm dacă ne aflăm sau nu în event dispatch thread. Mai jos prezentăm o clasă abstractă care ușurează plasarea unui obiect de tip Runnable în coada de evenimente:
import java.lang.reflect.InvocationTargetException;
import javax.swing.SwingUtilities;
public abstract class SwingUpdate {
public SwingUpdate() {}
public void perform()
{
if ( !SwingUtilities.isEventDispatchThread() ) {
SwingUtilities.invokeLater( new Runnable() {
public void run()
{
toBeInvoked();
}
} );
}
else {
toBeInvoked();
}
}
public void performRightNow()
{
if ( !SwingUtilities.isEventDispatchThread() ) {
try {
SwingUtilities.invokeAndWait( new Runnable() {
public void run()
{
toBeInvoked();
}
} );
} catch ( InvocationTargetException e )
{
e.printStackTrace();
} catch ( InterruptedException e ) {
e.printStackTrace();
}
}
else {
toBeInvoked();
}
}
abstract public void toBeInvoked();
}
Clasa se ocupă în mod transparent dacă într-adevăr este nevoie de apelul uneia din metodele invokeLater sau invokeAndWait. Pentru a o folosi este suficient a o subclasa și a rescrie metoda toBeInvoked în care să se scrie codul dorit. Clasa prevede metodele performe care nu blochează firul apelant și metoda performaRightNow care blochează firul apelant până la terminarea metodei.
Concluzii
Concluziile care se trag din cele discutate anterior sunt ca o interfața grafică sincronizată este neperformantă. Pentru performanță s-a optat pentru varianta nesicnronizată, însă cu anumite particularități. Aspectele prezentate sunt suficiente pentru a construi o interfață grafică care să se comporte coerent într-un mediu concurent.
Bibliografie
[ 1 ] James Gosling, Bill Joy, Guy Steele : The Java Language Specification,
Addison-Wesley, 1996
[ 2 ] Horia Georgescu : Programare concurentă, Editura Tehnică, București, 1996
[ 3 ] Horia Georgescu : Introducere în universul Java, în curs de apariție la
Editura Tehnică
[ 4 ] Irina Athanasiu, Bogdan Costinescu, Octavian Andrei Drăgoi, Florentina Irina
Popovici : Limbajul Java – o perspectivă pragmatică, Computer Libris Agora,
București, 2000
[ 5 ] David M. Geary : Graphic JAVA – Mastering the JFC, volume 2,
The Sun Microsystem Press(A Prentice Hall Title)
Resurse Internet
http://java.sun.com/j2se/1.4/docs/ – adresa la care se află documentația oficială și la zi
pentru limbajul Java
http://java.sun.com/docs/books/tutorial/ – adresa tutorialului de început pentru limbajul Java
http://java.sun.com/products/tsc/jfc – pagina oficială a proiectului Swing
http://java.sun.com/ – site-ul oficial al limbajului Java
Copyright Notice
© Licențiada.org respectă drepturile de proprietate intelectuală și așteaptă ca toți utilizatorii să facă același lucru. Dacă consideri că un conținut de pe site încalcă drepturile tale de autor, te rugăm să trimiți o notificare DMCA.
Acest articol: Fire de Executie. Aspecte Privind Interfata Grafica Swing (ID: 149094)
Dacă considerați că acest conținut vă încalcă drepturile de autor, vă rugăm să depuneți o cerere pe pagina noastră Copyright Takedown.
