Înainte de .NET 4.0, dacă trebuia să folosim clasa Dictionary într-un mediu multithreaded, nu aveam altă opțiune decât să implementăm sincronizarea firelor noi înșine pentru a păstra firele în siguranță.
Mulți dezvoltatori au implementat cu siguranță o soluție similară de tip thread-safe, fie prin crearea unui tip complet nou de dicționar, fie prin încapsularea unui obiect Dictionary într-o clasă și adăugarea unui mecanism de blocare la toate metodele, pe care îl numim "Dictionary + Locks".
Dar acum avem ConcurrentDictionary. Descrierea thread-safe a documentației clasei Dictionary pe MSDN afirmă că, dacă aveți nevoie de o implementare thread-safe, folosiți ConcurrentDictionary.
Așadar, acum că avem o clasă de dicționar sigură la thread-safe, nu mai trebuie să o implementăm noi înșine. Grozav, nu-i așa?
Originea problemei
De fapt, am folosit CocurrentDictionary o singură dată înainte, în testul meu, pentru a-i testa răspunsul. Pentru că a avut rezultate bune la teste, am înlocuit-o imediat cu clasa mea, am făcut niște teste, apoi ceva a mers prost.
Deci, ce a mers prost? Nu ai spus sigur pentru fire?
După mai multe teste, am găsit rădăcina problemei. Dar, dintr-un motiv oarecare, versiunea 4.0 a MSDN nu include o descriere a semnăturii metodei GetOrAdd care să necesite transmiterea unui parametru de tip delegat. După ce m-am uitat la versiunea 4.5, am găsit această notă:
Dacă apelați GetOrAdd simultan pe fire diferite, addValueFactory poate fi apelat de mai multe ori, dar perechea cheie/valoare s-ar putea să nu fie adăugată în dicționar pentru fiecare apel. Asta a fost problema cu care m-am confruntat. Pentru că nu era descris anterior în documentație, a trebuit să fac mai multe teste pentru a confirma problema. Desigur, problema cu care mă confrunt este legată de utilizarea mea, în general, folosesc tipul dicționar pentru a stoca unele date:
Aceste date sunt foarte lente de creat; Aceste date pot fi create o singură dată, deoarece a doua creație va arunca o excepție sau mai multe creări pot duce la scurgeri de resurse etc.; Am avut o problemă cu a doua afecțiune. Dacă ambele fire descoperă că o bucată de date nu există, aceasta va fi creată o singură dată, dar doar un singur rezultat va fi salvat cu succes. Dar cealaltă?
Dacă procesul pe care îl creezi aruncă o excepție, poți folosi încercarea: Prinde (nu destul de elegant, dar rezolvă problema). Dar ce se întâmplă dacă o resursă este creată și nu reciclată?
Ai putea spune că un obiect este creat și va fi colectat de gunoi dacă nu mai este menționat în el. Totuși, gândește-te ce s-ar întâmpla dacă situația descrisă mai jos s-ar întâmpla:
Generează cod dinamic cu Emit. Am folosit această abordare într-un cadru Remoting și am pus toate implementările într-un ansamblu care nu putea fi reciclat. Dacă un tip este creat de două ori, al doilea va exista întotdeauna, chiar dacă nu a fost niciodată folosit. Creează un fir direct sau indirect. De exemplu, trebuie să construim o componentă care să folosească un fir proprietar pentru a procesa mesajele asincrone și să se bazeze pe ordinea în care sunt primite. Când componenta este instanțiată, se creează un fir de execuție. Când această instanță componentă este distrusă, firul este și el terminat. Dar dacă ștergem referința la obiect după ce distrugem componenta, firul nu se termină dintr-un motiv oarecare și păstrează referința la obiect. Apoi, dacă firul nu moare, obiectul nu va fi reciclat nici el. Efectuează o operație P/Invoke. Se solicită ca numărul de timpi de închidere pentru mânerul primit să fie același cu numărul de deschideri. Desigur, există multe situații similare. De exemplu, un obiect dicționar va menține o conexiune către un serviciu de pe un server la distanță, care poate fi solicitată o singură dată, iar dacă este solicitat a doua oară, celălalt serviciu va considera că a apărut o eroare și o va înregistra în jurnal. (La o companie la care am lucrat, existau unele sancțiuni legale pentru această afecțiune.) ) Așadar, este ușor de observat că Dictionary + Locks nu poate fi înlocuit în grabă cu ConcurrentDictionary, chiar dacă documentația spune că este thread-safe.
Analizează problema
Încă nu înțelegi?
Este adevărat că această problemă s-ar putea să nu apară în cadrul abordării Dicționar + Încuietori. Deoarece acest lucru depinde de implementarea specifică, să vedem acest exemplu simplu:
În codul de mai sus, ținem lacătul pe dicționar înainte de a începe interogarea valorii cheii. Dacă perechea cheie-valoare specificată nu există, aceasta va fi creată direct. În același timp, deoarece deținem deja un blocaj pe acel dicționar, putem adăuga perechi cheie-valoare direct în dicționar. Apoi eliberează blocajul dicționarului și returnează rezultatul. Dacă două fire interogează aceeași valoare de cheie în același timp, primul fir care primește blocarea dicționarului va finaliza crearea obiectului, iar celălalt fir va aștepta finalizarea acestei creări și va obține rezultatul valorii cheie create după obținerea blocării dicționarului.
E bine, nu-i așa?
Chiar nu este! Nu cred că crearea unui obiect în paralel așa, unde la final se folosește doar unul, nu creează problema pe care am descris-o.
Situația și problema pe care încerc să le detaliez pot să nu fie întotdeauna reproductibile, într-un mediu paralel putem pur și simplu să creăm două obiecte și apoi să aruncăm unul. Deci, cum comparăm exact Dictionary + Locks cu ConcurrentDictionary?
Răspunsul este: depinde de strategia de utilizare a lacătelor și de modul în care este folosit dicționarul.
Jocul 1: Creează același obiect în paralel
În primul rând, să presupunem că un obiect poate fi creat de două ori, deci ce se întâmplă dacă două fire creează acest obiect în același timp?
În al doilea rând, cât timp petrecem pe creații similare?
Putem construi pur și simplu un exemplu în care instanțierea unui obiect durează 10 secunde. Când primul fir creează obiectul la 5 secunde după, a doua implementare încearcă să apeleze metoda GetOrAdd pentru a obține obiectul, iar cum obiectul încă nu există, începe să creeze obiectul.
În această condiție, avem 2 procesoare care lucrează în paralel timp de 5 secunde, iar când primul fir termină de funcționat, al doilea fir trebuie să continue să ruleze timp de 5 secunde pentru a finaliza construcția obiectului. Când al doilea fir termină de construit obiectul, descoperă că un obiect există deja și alege să folosească obiectul existent și să arunce direct noul obiect creat.
Dacă al doilea fir pur și simplu așteaptă și al doilea CPU face o altă muncă (rulând alte fire sau aplicații, economisind energie), va primi obiectul dorit după 5 secunde în loc de 10 secunde.
Așadar, în aceste condiții, Dictionary + Locks câștigă un joc mic.
Jocul 2: Vizitează diferite obiecte în paralel
Nu, situația pe care ai spus-o nu este deloc adevărată!
Ei bine, exemplul de mai sus este puțin ciudat, dar descrie problema, doar că această utilizare este mai extremă. Așadar, gândește-te ce se întâmplă dacă primul fir creează un obiect, iar al doilea fir trebuie să acceseze un alt obiect cheie-valoare, iar acel obiect cheie-valoare există deja?
În ConcurrentDictionary, designul fără blocare face citirile foarte rapide deoarece nu există blocare pe citire. În cazul Dictionary + Locks, operația de citire va fi blocată mutual exclusiv, chiar dacă este o cheie complet diferită, ceea ce, evident, va încetini operația de citire.
Astfel, ConcurrentDictionary a retras un joc.
Notă: Aici consider că înțelegi mai multe concepte precum Bucket/Nod/Entry în clasa dicționarului; dacă nu, este recomandat să citești articolul lui Ofir Makmal "Înțelegerea Dicționarului Generic în profunzime", care explică bine aceste concepte.
Al treilea joc al jocului: citește mai mult și scrie single
Ce se întâmplă dacă folosești Multiple Readers și Single Writer în loc de un blocaj complet pe dicționar în Dictionary + Locks?
Dacă un fir de fir creează un obiect și menține o blocare actualizabilă până când obiectul este creat, blocarea este actualizată la blocare de scriere, atunci operația de citire poate fi efectuată în paralel.
Putem rezolva problema lăsând o operațiune de citire inactivă timp de 10 secunde. Dar dacă există mult mai multe citiri decât scrieri, vom constata că ConcurrentDictionary este încă rapid pentru că implementează citiri fără blocare.
Folosirea ReaderWriterLockSlim pentru dicționare face citirile mai dificile, iar în general se recomandă să folosești Full Lock pentru dicționare în loc de ReaderWriterLockSlim.
Așadar, în aceste condiții, ConcurrentDictionary a câștigat un alt joc.
Notă: Am abordat cursurile YieldReaderWriterLock și YieldReaderWriterLockSlim în articole anterioare. Prin utilizarea acestui blocaj de citire-scriere, viteza a fost îmbunătățită considerabil (evoluând acum în SpinReaderWriterLockSlim) și permite executarea mai multor citiri în paralel, cu un impact minim sau inexistent. Deși încă folosesc această metodă, un ConcurrentDictionary fără blocare ar fi evident mai rapid.
Jocul 4: Adaugă mai multe perechi cheie-valoare
Confruntarea nu s-a terminat încă.
Ce se întâmplă dacă avem mai multe valori cheie de adunat și toate nu se ciocnesc și sunt atribuite în bucket-uri diferite?
La început, această întrebare a fost curioasă, dar am făcut un test care nu s-a potrivit prea bine. Am folosit un dicționar de tipuri <int, int> iar fabrica de construcții a obiectului returna un rezultat negativ direct ca cheie.
Mă așteptam ca ConcurrentDictionary să fie cel mai rapid, dar s-a dovedit a fi cel mai lent. Dictionary + Locks, pe de altă parte, funcționează mai rapid. De ce?
Acest lucru se datorează faptului că ConcurrentDictionary alocă noduri și le plasează în diferite bucket-uri, ceea ce este optimizat pentru a respecta designul fără blocări pentru operațiunile de citire. Totuși, atunci când se adaugă elemente cheie-valoare, procesul de creare a unui nod devine costisitor.
Chiar și în condiții paralele, alocarea unui Node lock consumă totuși mai mult timp decât folosirea unui blocaj complet.
Deci, Dicționar + Lacăte câștigă acest joc.
Jucând al cincilea joc: Frecvența operațiilor de citire este mai mare
Sincer, dacă am avea un delegat care să poată instanția rapid obiectele, nu am avea nevoie de un Dicționar. Putem suna direct delegatul să obțină obiectul, nu?
De fapt, răspunsul este și că depinde de situație.
Imaginează-ți că tipul cheii este un șir și conține hărți de traseu pentru diverse pagini din serverul web, iar valoarea corespunzătoare este un tip de obiect care conține înregistrarea utilizatorilor actuali care au accesat pagina și numărul tuturor vizitelor pe pagină de la începutul serverului.
Crearea unui astfel de obiect este aproape instantanee. După aceea, nu mai trebuie să creezi un obiect nou, ci doar să schimbi valorile salvate în el. Astfel, este posibil să se permită crearea unei metode de două ori până când se folosește o singură instanță. Totuși, deoarece ConcurrentDictionary alocă resursele nodurilor mai lent, folosirea Dictionary + Locks va duce la timpi de creare mai rapizi.
Așadar, acest exemplu este foarte special, vedem și că Dictionary + Locks funcționează mai bine în această condiție, durând mai puțin timp.
Deși alocarea nodurilor în ConcurrentDictionary este mai lentă, nu am încercat să pun 100 de milioane de elemente de date pentru a testa timpul. Pentru că, evident, asta necesită mult timp.
Dar, în majoritatea cazurilor, odată ce un element de date este creat, acesta este întotdeauna citit. Cum se schimbă conținutul elementului de date este o altă poveste. Deci nu contează câte milisecunde mai durează pentru a crea un element de date, pentru că citirile sunt mai rapide (doar câteva milisecunde mai rapide), dar citirile au loc mai frecvent.
Așadar, ConcurrentDictionary a câștigat jocul.
Jocul 6: Creează obiecte care consumă timpi diferiți
Ce se întâmplă dacă timpul necesar pentru a crea diferite elemente de date variază?
Creează mai multe date care consumă timpi diferiți și adaugă-le în dicționar în paralel. Acesta este cel mai puternic punct al ConcurrentDictionary.
ConcurrentDictionary folosește o serie de mecanisme de blocare diferite pentru a permite adăugarea simultană a elementelor de date, dar logica precum decizia ce blocat să folosească, solicitarea unui blocaj să schimbe dimensiunea găleții etc., nu ajută. Viteza cu care elementele de date sunt introduse într-o găleată este rapidă ca o mașină. Ceea ce face cu adevărat ConcurrentDictionary câștigător este capacitatea sa de a crea obiecte în paralel.
Totuși, putem face același lucru. Dacă nu ne pasă dacă creăm obiecte în paralel sau dacă unele au fost eliminate, putem adăuga un blocaj pentru a detecta dacă elementul de date există deja, apoi eliberăm blocajul, creăm elementul de date, apăsăm pentru a obține blocarea, verificăm din nou dacă elementul de date există, iar dacă nu, adăugăm elementul de date. Codul ar putea arăta cam așa:
* Rețineți că folosesc un dicționar de tip <int, int>.
În structura simplă de mai sus, Dictionary + Locks funcționează aproape la fel de bine ca ConcurrentDictionary atunci când se creează și se adaugă elemente de date în condiții paralele. Dar există și aceeași problemă, unde unele valori pot fi generate, dar niciodată folosite.
concluzie
Deci, există o concluzie?
În acest moment, mai sunt câteva:
Toate cursurile de dicționar sunt foarte rapide. Chiar dacă am creat milioane de date, tot este rapid. De obicei, creăm doar un număr mic de elemente de date și există intervale de timp între citiri, așa că, în general, nu observăm suprasolicitarea de timp a citirii elementelor de date. Dacă același obiect nu poate fi creat de două ori, nu folosiți ConcurrentDictionary. Dacă ești cu adevărat îngrijorat de performanță, Dictionary + Locks ar putea fi totuși o soluție bună. Un factor important este numărul de date adăugate și eliminate. Dar dacă există multe operații de citire, este mai lent decât ConcurrentDictionary. Deși nu l-am introdus, există de fapt mai multă libertate în a folosi schema Dicționar + Blocaje. De exemplu, poți bloca o dată, adăuga mai multe elemente de date, șterge mai multe elemente de date sau interoga de mai multe ori, etc., și apoi eliberezi blocarea. În general, evită să folosești ReaderWriterLockSlim dacă există mult mai multe citiri decât scrieri. Tipurile de dicționar sunt deja mult mai rapide decât obținerea unui blocaj de citire într-un blocaj de citire-scriere. Desigur, acest lucru depinde și de timpul consumat pentru a crea un obiect într-un lacăt. Așadar, cred că exemplele date sunt puțin extreme, dar arată că folosirea ConcurrentDictionary nu este întotdeauna cea mai bună soluție.
Simte diferența
Am scris acest articol cu intenția de a căuta o soluție mai bună.
Încerc deja să înțeleg mai profund cum funcționează un anumit curs de dicționar (acum simt că sunt foarte clar).
Se poate spune că Bucket și Node în ConcurrentDictionary sunt foarte simple. Am făcut ceva similar când am încercat să creez o clasă de dicționar. Clasa obișnuită de Dicționar poate părea mai simplă, dar de fapt este mai complexă.
În ConcurrentDictionary, fiecare nod este o clasă completă. În clasa Dictionary, Node este implementat folosind un tip de valoare, iar toate nodurile sunt păstrate într-un tablou uriaș, în timp ce Bucket este folosit pentru indexarea în tablou. Este folosit și în locul referinței simple a unui Nod către următorul Nod (până la urmă, ca Nod de tip struct, nu poate conține un membru Node de tip struct).
La adăugarea și eliminarea unui dicționar, clasa Dictionary nu poate pur și simplu să creeze un nod nou, ci trebuie să verifice dacă există un index care marchează un nod care a fost șters și apoi să-l refolosească. Sau "Count" este folosit pentru a obține poziția noului nod în array. De fapt, când tabloul este plin, clasa Dicționar forțează o schimbare de dimensiune.
Pentru ConcurrentDictionary, un Nod poate fi considerat un obiect nou. Eliminarea unui Nod înseamnă pur și simplu eliminarea referinței sale. Adăugarea unui Node nou poate crea pur și simplu o nouă instanță Node. Schimbarea dimensiunii este doar pentru a evita conflictele, dar nu este obligatorie.
Așadar, dacă clasa Dictionary folosește intenționat algoritmi mai complecși pentru a o gestiona, cum va asigura ConcurrentDictionary că performează mai bine într-un mediu multi-threaded?
Adevărul este: punerea tuturor nodurilor într-un singur tablou este cea mai rapidă modalitate de a aloca și citi, chiar dacă avem nevoie de un alt tablou pentru a ține evidența unde găsim acele elemente de date. Deci se pare că având același număr de bucket-uri va folosi mai multă memorie, dar noile elemente de date nu trebuie realocate, nu sunt necesare sincronizări noi de obiecte și nu are loc o nouă colectare a gunoiului. Pentru că totul este deja pus la punct.
Totuși, înlocuirea conținutului într-un Nod nu este o operațiune atomică, ceea ce este unul dintre factorii care fac firul său de fir nesigur. Deoarece toate nodurile sunt obiecte, inițial se creează un nod, iar apoi o referință separată este actualizată pentru a indica către acesta (operație atomică aici). Astfel, firul de citire poate citi conținutul dicționarului fără blocare, iar citirea trebuie să fie una dintre valorile vechi și noi, și nu există riscul de a citi o valoare incompletă.
Așadar, adevărul este: dacă nu ai nevoie de blocare, clasa Dictionary este mai rapidă la citiri, pentru că blocarea încetinește citirea.
Acest articol este tradus din articolul lui Paulo Zemek "Dictionary + Locking versus ConcurrentDictionary" de pe CodeProject, iar unele afirmații se vor schimba din motive de înțelegere.
|