Elementele de bază ale paralelismului: blocări și monitoare de obiecte (secțiunile 1, 2) (traducerea articolului)
Acest articol face parte din cursul nostru de bază în paralelism în Java.
În acest curs, vă scufundați în magia paralelismului. Veți învăța elementele de bază ale paralelismului și codul paralel, să vă familiarizați cu astfel de concepte precum atomicitatea, sincronizarea, securitatea firului. Uită-te la el aici!
Când dezvoltați aplicații care utilizează concurrency pentru a-ți atinge obiectivele, este posibil să întâlniți situații în care diferite fire pot să se blocheze reciproc. Dacă în această situație aplicația se execută mai încet decât se aștepta, am spune că nu funcționează la timp așa cum era de așteptat. În această secțiune, vom cunoaște mai atent problemele care pot amenința supraviețuirea unei aplicații multi-filetate.
Termenul impas este bine cunoscut dezvoltatorilor de software și chiar și cei mai obișnuiți utilizatori îl folosesc din când în când, deși nu întotdeauna în sensul potrivit. În mod strict vorbind, acest termen înseamnă că fiecare dintre cele două fire (sau mai multe) așteaptă de la un alt fir, astfel încât să elibereze resursele blocate de el, în timp ce acesta din urmă a blocat resursa, accesată de a doua:
Pentru o mai bună înțelegere a problemei, să aruncăm o privire la următorul cod:
După cum puteți vedea din codul de mai sus, începeți două fire și încercați să blocați două resurse statice. Dar pentru impas, avem nevoie de o secvență diferită pentru ambele catene, așa că vom folosi o instanță de aleatorie, pentru a selecta ce resursă un fir dorește să blocheze prima. Dacă variabila logică b este adevărată, atunci resource1 este blocată mai întâi și după ce firul încearcă să obțină o blocare pentru resource2. Dacă b este falsă, firul blochează resurse2 și apoi încearcă să apuce resurse1. Acest program nu durează mult pentru a realiza primul impas, adică programul se va închide pentru totdeauna dacă nu îl întrerupem:
În această pornire a benzii de rulare-1 de blocare resource2 compensate și așteaptă resource1 de blocare, în timp ce banda de rulare-2 blocat resource1 și așteaptă resource2.
Dacă am seta valoarea variabilei logice b din codul de mai sus la același adevăr, nu am fi putut observa niciun blocaj, deoarece secvența în care se solicită blocarea trenului-1 și a filetului-2 ar fi întotdeauna aceeași. În această situație, unul dintre cele două fire ar fi blocat mai întâi și apoi va cere un al doilea, care este încă disponibil, deoarece celălalt fir este în așteptarea primei blocări.
În general, putem identifica următoarele condiții necesare pentru apariția blocajului:
- Executarea comună: Există o resursă care poate fi accesată numai printr-un fir în orice moment.
- Restabilirea resurselor: În timpul capturii unei resurse, firul încearcă să obțină o altă blocare pe o anumită resursă unică.
- Nu există întreruperi de serviciu prioritare: nu există niciun mecanism care să elibereze resursa, dacă un fir deține blocarea pentru o anumită perioadă de timp.
- așteptați circular: În timpul rulării, există un set de fire, în care cele două (sau mai multe) fire asteapta reciproc pentru o resursă care a fost blocat.
Deși lista de condiții pare a fi lungi, adesea bine reglate, aplicațiile multi-filetate au probleme de blocare. Dar puteți să le împiedicați dacă puteți elimina una dintre condițiile de mai sus:
- Executarea comună: această condiție poate fi deseori eliminată atunci când resursa va fi utilizată de o singură persoană. Dar acest lucru nu trebuie să fie motivul. Atunci când se utilizează sisteme DBMS soluție fezabilă, în loc de a folosi blocare pesimistă pe un rând al tabelului, care ar trebui să fie actualizată, este posibil să se utilizeze o tehnică numită de blocare optimistă.
- Modul de a evita păstrarea resurselor în timp ce de așteptare pentru o altă resursă exclusivă este de a bloca toate resursele necesare la începutul algoritmului și, de asemenea, pentru a elibera toate, dacă nu le pot bloca din nou. Desigur, acest lucru nu este întotdeauna posibil, pot exista resurse care necesită blocare în prealabil necunoscut sau această abordare va duce pur și simplu la o risipă de resurse.
- Dacă blocarea nu poate fi recepționată imediat, calea de a ocoli posibila blocare este să introduceți intervalul de timp. De exemplu, clasa ReentrantLock din SDK oferă posibilitatea de a seta data de expirare a blocării.
- Așa cum am văzut din exemplul de mai sus, blocarea nu are loc dacă secvența cererilor nu diferă pentru diferitele fire. Acest lucru este ușor de controlat dacă puteți pune tot codul de blocare într-o singură metodă, prin care trebuie să treacă toate firele.
În aplicațiile mai avansate, puteți chiar să vă gândiți la implementarea unui sistem de detectare a blocajelor. Aici va trebui să implementați un fel de monitorizare a firului, în care fiecare fir va raporta achiziția cu succes a dreptului de blocare și încercarea de a obține o blocare. Dacă firul și blocare modelat ca un grafic direcționat, puteți detecta când două fire diferite, care dețin resurse în timp ce încerca să obțină acces la alte resurse blocate. Dacă apoi puteți face firele de blocare să elibereze resursele necesare, puteți rezolva automat situația de blocare.
Planificatorul decide care dintre firele este în starea RUNNABLE. el trebuie să facă următoarele. Decizia se bazează pe prioritatea firului; astfel încât firele cu o prioritate mai mică primesc mai puțin timp CPU, comparativ cu cele cu o prioritate mai mare. Ceea ce arată ca o soluție sensibilă poate, de asemenea, să provoace probleme în abuz. În cazul în care cele mai multe ori firele sunt executate cu cea mai mare prioritate, firul de prioritate scăzută părea să înceapă să „moară de foame“, pentru că ei nu primesc suficient timp pentru a face treaba corect. Prin urmare, se recomandă ca prioritatea firului să fie stabilită numai atunci când există motive serioase pentru acest lucru.
Un exemplu non-evident al postului de fier este dat, de exemplu, prin metoda finalize (). Acesta oferă în limba Java abilitatea de a executa codul înainte ca obiectul să fie șters de către colectorul de gunoi. Dar dacă priviți la prioritatea firului de finalizare, veți observa că nu pornește de la cea mai înaltă prioritate. Prin urmare, există condiții prealabile pentru postul threaded, când metodele de finalizare () ale obiectului petrec prea mult timp în comparație cu restul codului.
O altă problemă cu timpul de execuție apare din faptul că nu se determină în ce ordine firele trec prin blocul sincronizat. Atunci când multe fire paralele parcurg un cod care este încadrat într-un bloc sincronizat, se poate întâmpla ca firul să aștepte mai mult decât celălalt înainte de a intra în bloc. Teoretic, ei nu pot ajunge niciodată acolo.
Soluția la această problemă este blocarea așa-numită "corectă". Încuietorile corecte țin cont de timpul de așteptare al firelor, atunci când determină cine să săriți următor. Un exemplu de implementare a unei blocări valide este în Java SDK: java.util.concurrent.locks.ReentrantLock. Dacă utilizați un constructor cu un steguleț logic setat la adevărat, atunci ReentrantLock vă oferă acces la un fir care așteaptă mai mult decât ceilalți. Acest lucru asigură absența foamei, dar, în același timp, conduce la problema ignorării priorităților. Din acest motiv, procesele cu o mai mică prioritate, care se așteaptă adesea pe această barieră, pot fi efectuate mai des. În cele din urmă, important, clasa ReentrantLock poate lua în considerare numai fire care așteaptă o blocare, i. E. Fire care au fost difuzate destul de des și au ajuns la barieră. Dacă prioritatea firului este prea mică, acest lucru nu se va întâmpla frecvent pentru acesta și, prin urmare, firele cu prioritate ridicată vor fi blocate mai des.
În computerele cu mai multe fire, situația obișnuită este prezența unor fire de lucru care se așteaptă ca producătorul lor să creeze un fel de lucru pentru ei. Dar, așa cum am învățat, activul de așteptare într-o buclă cu verificarea unei anumite valori nu este o opțiune bună din punct de vedere al timpului procesorului. Utilizarea metodei Thread.sleep () în această situație nu este, de asemenea, potrivită dacă dorim să începem activitatea imediat după primire.
Pentru aceasta, limbajul de programare Java are o structură diferită care poate fi folosită în această schemă: wait () și notify (). Metoda wait (), moștenită de toate obiectele din clasa java.lang.Object, poate fi folosită pentru a întrerupe firul curent și a aștepta până când un alt thread ne va trezi folosind metoda notify (). Pentru a funcționa corect, firul care apelează metoda wait () trebuie să dețină blocarea pe care a primit-o anterior utilizând cuvântul cheie sincronizat. Când se apelează wait (), blocarea este eliberată și firul așteaptă până când un alt fir care a acumulat blocarea va apela notify () pentru aceeași instanță a obiectului.
Într-o aplicație multi-filetată, poate fi în mod natural mai mult de un fir care așteaptă să fie notificat pe un obiect. Prin urmare, există două metode diferite pentru trecerea firelor: notify () și notifyAll (). În timp ce prima metodă trezește unul dintre firele de așteptare, metoda notifyAll () le trezește pe toate. Dar rețineți că, la fel ca în cazul cuvântului cheie sincronizat, nu există nicio regulă care să determine care fir va fi trezit de următorul text atunci când sunați la notificare (). Într-un exemplu simplu cu producătorul și consumatorul, nu contează, deoarece nu contează ce fir se trezește.
Codul de mai jos arată modul în care mecanismul de așteptare () și notifică () pot fi folosite pentru organizarea de așteptare a firului consumatori pentru un nou loc de muncă, care se adaugă la producătorul firului coadă:
Metoda principală () lansează cinci fire de consum și un fir de producție și apoi așteaptă sfârșitul lucrului. După ce firul furnizorului adaugă o nouă valoare coadajului și notifică toate firele de așteptare la care sa întâmplat ceva. Consumatorii primesc o blocare în coada de așteptare (notează un consumator arbitrar) și apoi adoarme pentru a fi luate mai târziu când coada este complet plină. Când producătorul își încheie activitatea, el îi anunță pe toți consumatorii să se trezească. Dacă nu am făcut ultimul pas, firele de consum ar aștepta mereu următoarea notificare, pentru că nu am stabilit un timp de așteptare pentru a aștepta. În schimb, putem folosi metoda de așteptare (long timeout) pentru a fi trezită, cel puțin după un timp.
Așa cum sa spus în secțiunea anterioară, apelul de așteptare () pentru monitorul obiect elimină numai blocarea de pe acest monitor. Alte blocări care au fost ținute de același fir nu sunt eliberate. Așa cum este ușor de înțeles, în munca de zi cu zi se poate întâmpla ca firul care așteaptă să aștepte () să țină mai multă încuietori. Dacă alte fire, de asemenea, se așteaptă ca aceste încuietori, atunci o situație de blocare poate să apară. Să examinăm blocarea în următorul exemplu:
După cum am învățat până acum. Adăugarea sincronizată la semnătura metodei este echivalentă cu crearea unei sincronizări (aceasta)<>. In exemplul de mai sus, am adăugat accidental cuvântul cheie sincronizat în metoda, și apoi sincronizați toate coada obiect monitorului, pentru a trimite acest fir să doarmă în timp ce de așteptare pentru următoarea valoare a cozii. Apoi firul curent eliberează blocul de coadă, dar nu blochează acest lucru. Metoda putInt () notifică firul de dormit că a fost adăugată o nouă valoare. Dar din întâmplare am adăugat cuvântul cheie sincronizat la această metodă. Acum, când cel de-al doilea fir a adormit, încă mai rămâne blocat. Prin urmare, primul fir nu poate intra în metoda putInt () până când această blocare este menținută de al doilea fir. Ca rezultat, avem o situație de impas și un program agățat. Dacă executați codul de mai sus, se va întâmpla imediat după pornirea programului.
În viața de zi cu zi această situație poate să nu fie atât de evidentă. Broaste deținute de un fir poate depinde de parametrii și condițiile întâlnite în timpul funcționării, iar blocul sincronizat, cauzând problema nu poate fi atât de aproape de locul în codul unde am plasat o așteptare apel (). Acest lucru face dificilă căutarea unor astfel de probleme, mai ales dacă acestea pot apărea după un timp sau la o sarcină mare.
De multe ori trebuie să verificați executarea unei anumite condiții înainte de a efectua o acțiune cu un obiect sincronizat. Când aveți, de exemplu, o coadă, doriți să așteptați ca aceasta să fie completă. Prin urmare, puteți scrie o metodă care să verifice completitudinea coadajului. Dacă este încă goală, atunci trimiteți firul curent la somn până când acesta este trezit:
Codul de mai sus este sincronizat de coadă înainte de a apela așteptați () și apoi așteptați în buclă în timp ce cel puțin un element apare în coadă. Al doilea bloc sincronizat folosește din nou coada de comandă ca monitor de obiect. Se apelează metoda poll () din coadă pentru a obține valoarea. În scopuri demonstrative, IllegalStateException este aruncat când sondajul revine null. Acest lucru se întâmplă atunci când nu există elemente în coadă pentru a fi preluate.
Când executați acest exemplu, veți vedea că IllegalStateException este aruncat foarte des. Deși am fost sincronizați corect pe coada de monitorizare, a fost eliminată o excepție. Motivul este că avem două blocuri diferite sincronizate. Imaginați-vă că avem două fire care au ajuns la primul bloc sincronizat. Primul fir a intrat în bloc și a căzut într-un vis, deoarece coada este goală. Același lucru este valabil și pentru al doilea fir. Acum, că ambele fire sunt trezite (datorită faptului că au notificat notificarea (), cauzate de alt thread pentru monitor), amândouă au văzut valoarea (elementul) în coada adăugată de producător. Apoi amândoi au venit la a doua barieră. Aici a intrat primul fir și a extras valoarea din coadă. Când al doilea fir intră, coada este deja goală. Prin urmare, ca valoare returnată din coadă, aceasta devine nulă și aruncă o excepție.
Pentru a preveni astfel de situații, trebuie să efectuați toate operațiile care depind de starea monitorului în același bloc sincronizat:
Aici vom efectua metoda poll () în același bloc sincronizat cu metoda isEmpty (). Datorită blocului sincronizat, suntem siguri că numai un fir execută o metodă pentru acest monitor la un moment dat. Prin urmare, niciun alt thread nu poate elimina elemente din coada între apelurile către isEmpty () și sondaj ().