Pred .NET 4.0, če smo morali uporabljati razred Dictionary v večnitnem okolju, nismo imeli druge izbire, kot da sami implementiramo sinhronizacijo niti, da bi bile niti varne.
Veliko razvijalcev je zagotovo implementiralo podobno rešitev varno za niti (thread-safe) rešitev, bodisi z ustvarjanjem povsem nove vrste varnega slovarja ali preprosto z enkapsulacijo objekta Dictionary v razred in dodajanjem mehanizma zaklepanja vsem metodam, ki ga imenujemo "Dictionary + Locks".
Zdaj pa imamo ConcurrentDictionary. Opis dokumentacije razreda Dictionary na MSDN navaja, da če potrebujete implementacijo varno za niti (thread-safe), uporabite ConcurrentDictionary.
Zdaj, ko imamo razred slovarja varnega za niti, ga ni več treba implementirati sami. Super, kajne?
Izvor problema
Pravzaprav sem CocurrentDictionary uporabil le enkrat prej, v testu za testiranje odzivnosti. Ker se je dobro odrezal na testih, sem ga takoj zamenjal s svojim razredom, opravil nekaj testiranj, nato pa je šlo nekaj narobe.
Torej, kaj je šlo narobe? Nisi rekel, da je varno za niti?
Po dodatnem testiranju sem našel vzrok težave. A iz nekega razloga MSDN različica 4.0 ne vključuje opisa podpisa metode GetOrAdd, ki zahteva posredovanje parametra tipa delegat. Po pregledu različice 4.5 sem našel to opombo:
Če istočasno pokličete GetOrAdd na različnih nitih, se lahko addValueFactory pokliče večkrat, vendar njegov par ključ/vrednost morda ne bo dodan v slovar za vsak klic. To je težava, na katero sem naletel. Ker to prej ni bilo opisano v dokumentaciji, sem moral opraviti dodatne teste, da sem potrdil težavo. Seveda je težava, na katero naletim, povezana z mojo uporabo, na splošno uporabljam tip slovarja za shranjevanje nekaterih podatkov:
Ti podatki se ustvarjajo zelo počasi; Ti podatki se lahko ustvarijo le enkrat, ker bo druga ustvaritev sprožila izjemo ali pa več ustvaritev povzroči uhajanje virov itd.; Imel sem težave z drugim pogojem. Če obe niti ugotovita, da podatek ne obstaja, bo ta ustvarjen enkrat, vendar bo uspešno shranjen le en rezultat. Kaj pa druga?
Če proces, ki ga ustvarite, vrže izjemo, lahko uporabite try: Catch (ni dovolj elegantno, a reši težavo). Kaj pa, če je vir ustvarjen in ne recikliran?
Lahko bi rekli, da je objekt ustvarjen in bo odstranjen v smeti, če se nanj ne sklicuje več. Vendar pa pomislite, kaj bi se zgodilo, če bi se zgodila spodaj opisana situacija:
Dinamično generirajte kodo z Emit. Ta pristop sem uporabil v oddaljenem ogrodju in vključil vse implementacije v sestavo, ki je ni bilo mogoče reciklirati. Če je tip ustvarjen dvakrat, bo drugi vedno obstajal, tudi če nikoli ni bil uporabljen. Ustvarite nit neposredno ali posredno. Na primer, moramo zgraditi komponento, ki uporablja lastniško nit za obdelavo asinhronih sporočil in se zanaša na vrstni red, v katerem so prejeta. Ko je komponenta instancirana, se ustvari nit. Ko je ta instanca komponente uničena, se nit prav tako prekine. Če pa izbrišemo referenco na objekt po uničenju komponente, se nit iz nekega razloga ne konča in ohranja referenco na objekt. Če nit ne umre, predmet tudi ne bo recikliran. Izvedite operacijo P/Invoke. Zahtevajte, da je število zaprtj za prejeti ročaj enako številu odprtin. Seveda obstaja veliko podobnih situacij. Na primer, slovarski objekt bo imel povezavo s storitvijo na oddaljenem strežniku, ki jo je mogoče zahtevati le enkrat, in če jo zahtevamo drugič, bo druga storitev mislila, da je prišlo do neke napake, in jo zabeležila v dnevnik. (V podjetju, kjer sem delal, so bile za to stanje določene pravne kazni.) ) Torej je jasno, da Dictionary + Locks ni mogoče na hitro zamenjati s ConcurrentDictionary, tudi če dokumentacija pravi, da je varen za niti.
Analizirajte problem
Še vedno ne razumeš?
Res je, da se ta težava morda ne pojavi pri pristopu Slovar + ključavnice. Ker je to odvisno od specifične implementacije, si poglejmo ta preprost primer:
V zgornji kodi zadržimo ključavnico na slovarju, preden začnemo poizvedovati vrednost ključa. Če določen par ključ-vrednost ne obstaja, se ustvari neposredno. Hkrati pa, ker že imamo zaklep na ta slovar, lahko neposredno dodamo pare ključ-vrednost v slovar. Nato sprostite zaklep slovarja in vrnite rezultat. Če dve niti istočasno poizvedujeta po isti vrednosti ključa, bo prva nit, ki dobi zaklep slovarja, dokončala ustvarjanje objekta, druga nit pa bo počakala na dokončanje tega ustvarjanja in po pridobitvi zaklepa prejela rezultat ustvarjene vrednosti ključa.
To je dobro, kajne?
Res ni! Ne mislim, da ustvarjanje objekta vzporedno, kjer je na koncu uporabljen samo en, ne povzroči težave, ki sem jo opisal.
Situacija in problem, ki ju poskušam podrobneje opisati, morda nista vedno ponovljiva; v vzporednem okolju lahko preprosto ustvarimo dva objekta in nato enega zavržemo. Kako torej natančno primerjamo Dictionary + Locks in ConcurrentDictionary?
Odgovor je: odvisno je od strategije uporabe ključavnic in načina uporabe slovarja.
Igra 1: Ustvari isti predmet vzporedno
Najprej predpostavimo, da je mogoče objekt ustvariti dvakrat, kaj se zgodi, če ga hkrati ustvarita dve niti?
Drugič, koliko časa porabimo za podobne stvaritve?
Preprosto lahko zgradimo primer, kjer instanciranje objekta traja 10 sekund. Ko prva nit ustvari objekt 5 sekund kasneje, druga implementacija poskuša poklicati metodo GetOrAdd za pridobitev objekta, in ker objekt še vedno ne obstaja, ga začne tudi ustvarjati.
V tem stanju imamo dva CPU-ja, ki delata vzporedno 5 sekund, in ko prva nit konča delovati, mora druga nit še naprej delovati 5 sekund, da dokonča gradnjo objekta. Ko druga nit konča gradnjo objekta, ugotovi, da objekt že obstaja, in se odloči, da uporabi obstoječi objekt ter neposredno zavrže novo ustvarjeni objekt.
Če druga nit preprosto počaka in drugi procesor opravi kakšno drugo delo (zagon drugih niti ali aplikacij, varčuje z energijo), bo želeni objekt dobil po 5 sekundah namesto po 10 sekundah.
Tako v teh pogojih Dictionary + Locks zmaga v manjši igri.
Igra 2: Obisk različnih predmetov vzporedno
Ne, situacija, ki si jo omenil, sploh ni resnična!
No, zgornji primer je nekoliko nenavaden, a opisuje problem, le da je ta uporaba bolj ekstremna. Torej, razmislite, kaj se zgodi, če prva nit ustvarja objekt, druga nit pa mora dostopati do drugega objekta ključ-vrednost, ta pa že obstaja?
V ConcurrentDictionary zasnova brez zaklepanja omogoča zelo hitro branje, ker ni zaklepa na branju. V primeru Dictionary + Locks bo operacija branja zaklenjena medsebojno izključujoča, tudi če gre za povsem drug ključ, kar seveda upočasni operacijo branja.
Na ta način je ConcurrentDictionary umaknil igro.
Opomba: Tukaj menim, da razumete več konceptov, kot so vedro/vozlišče/vnos, v razredu slovar, če ne, je priporočljivo prebrati članek Ofira Makmala "Razumevanje splošnega slovarja v globino", ki te koncepte dobro pojasnjuje.
Tretja igra igre: beri več in piši samsko
Kaj se zgodi, če uporabite Več bralcev in Single Writer namesto popolne zaklepanja slovarja v Dictionary + Locks?
Če nit ustvarja objekt in ima nadgradljivo zaklepanje, dokler se objekt ne ustvari, se zaklep nadgradi v zapisovalno zaklepanje, nato pa se lahko bralna operacija izvede vzporedno.
Problem lahko rešimo tudi tako, da pustimo operacijo branja neaktivno 10 sekund. Če pa je branj veliko več kot zapisanih, bomo ugotovili, da je ConcurrentDictionary še vedno hiter, ker omogoča branje v načinu brez zaklepanja.
Uporaba ReaderWriterLockSlim za slovarje poslabša branje, zato je splošno priporočljivo uporabljati Full Lock za slovarje namesto ReaderWriterLockSlim.
Tako je ConcurrentDictionary pod temi pogoji zmagal še eno igro.
Opomba: V prejšnjih člankih sem obravnaval razrede YieldReaderWriterLock in YieldReaderWriterLockSlim. Z uporabo tega zaklepa za branje in pisanje se je hitrost bistveno izboljšala (zdaj se je razvila v SpinReaderWriterLockSlim) in omogoča izvajanje več branj vzporedno z malo ali brez vpliva. Medtem ko še vedno uporabljam ta način, bi bil brezzaklepni ConcurrentDictionary očitno hitrejši.
Igra 4: Dodajte več parov ključ-vrednost
Obračun še ni končan.
Kaj pa, če imamo več ključnih vrednosti, ki jih lahko dodamo, pa se ne prepletajo in so dodeljene v različnih kategorijah?
Sprva je bilo to vprašanje radovedno, a sem naredil test, ki ni povsem ustrezal. Uporabil sem slovar tipa <int, int> in tovarna konstrukcije objekta je kot ključ neposredno vrnila negativen rezultat.
Pričakoval sem, da bo ConcurrentDictionary najhitrejši, a se je izkazalo, da je bil najpočasnejši. Dictionary + Locks pa deluje hitreje. Zakaj je temu tako?
To je zato, ker ConcurrentDictionary dodeljuje vozlišča in jih razporeja v različna vedra, kar je optimizirano za izpolnjevanje zasnove brez zaklepanja za operacije branja. Vendar pa pri dodajanju elementov ključ-vrednost proces ustvarjanja vozlišča postane drag.
Tudi v vzporednih pogojih dodeljevanje zaklepa vozlišča še vedno vzame več časa kot uporaba popolnega zaklepa.
Torej, Dictionary + Locks zmaga v tej igri.
Igranje pete igre: Pogostost operacij branja je višja
Iskreno, če bi imeli delegata, ki bi znal hitro instancirati objekte, ne bi potrebovali slovarja. Lahko neposredno pokličemo delegata, da dobi predmet, kajne?
Pravzaprav je odgovor tudi, da je odvisno od situacije.
Predstavljajte si, da je tip ključa niz in vsebuje zemljevide poti za različne strani na spletnem strežniku, ustrezna vrednost pa je tip objekta, ki vsebuje zapis trenutnih uporabnikov, ki dostopajo do strani, in število vseh obiskov strani od začetka strežnika.
Ustvarjanje takšnega predmeta je skoraj takojšnje. In potem ni treba ustvarjati novega objekta, samo spremenite vrednosti, ki so v njem shranjene. Tako je mogoče omogočiti ustvarjanje poti dvakrat, dokler ni uporabljena le ena instanca. Ker pa ConcurrentDictionary počasneje dodeljuje vire vozlišč, bo uporaba Dictionary + Locks pospešila čas ustvarjanja.
Torej, s tem zelo posebnim primerom vidimo, da Dictionary + Locks deluje bolje pod tem pogojem, saj traja manj časa.
Čeprav je dodeljevanje vozlišč v ConcurrentDictionary počasnejše, nisem poskušal vanj vnesti 100 milijonov podatkovnih elementov, da bi preveril čas. Ker to očitno vzame veliko časa.
V večini primerov, ko je podatkovni element ustvarjen, se vedno prebere. Kako se vsebina podatkovnega elementa spremeni, je druga zgodba. Torej ni pomembno, koliko milisekund več je potrebnih za ustvarjanje podatkovnega elementa, ker so branja hitrejša (le nekaj milisekund hitrejša), vendar se branja dogajajo pogosteje.
Torej, ConcurrentDictionary je zmagal v igri.
Igra 6: Ustvari predmete, ki porabijo različne čase
Kaj se zgodi, če se čas, potreben za ustvarjanje različnih podatkovnih elementov, razlikuje?
Ustvarite več podatkovnih elementov, ki porabijo različen čas, in jih dodajte v slovar vzporedno. To je najmočnejša točka ConcurrentDictionary.
ConcurrentDictionary uporablja več različnih mehanizmov zaklepanja, da omogoča sočasno dodajanje podatkovnih elementov, vendar logika, kot je odločanje, katero ključavnico uporabiti, zahteva za spremembo velikosti vedra itd., ne pomaga. Hitrost, s katero se podatkovni elementi spravijo v vedro, je strojno hitra. Tisto, kar ConcurrentDictionary resnično poskrbi za zmago, je njegova sposobnost vzporednega ustvarjanja objektov.
Vendar pa lahko pravzaprav naredimo isto. Če nam ni pomembno, ali ustvarjamo objekte vzporedno ali če so nekateri že zavrženi, lahko dodamo zaklep, ki zazna, ali podatkovni element že obstaja, nato sprostimo zaklep, ustvarimo podatkovni element, pritisnemo za zaklepanje, ponovno preverimo, ali podatkovni element obstaja, in če ne, dodamo podatkovni element. Koda bi lahko izgledala nekako takole:
* Upoštevajte, da uporabljam slovar tipa <int, int>.
V zgornji preprosti strukturi Dictionary + Locks deluje skoraj tako dobro kot ConcurrentDictionary pri ustvarjanju in dodajanju podatkovnih elementov v vzporednih pogojih. A obstaja tudi enak problem, kjer so nekatere vrednosti morda generirane, a nikoli uporabljene.
Sklep
Torej, ali obstaja kakšen zaključek?
Trenutno je še vedno nekaj:
Vsi slovarski predmeti so zelo hitri. Čeprav sem ustvaril milijone podatkov, je še vedno hitro. Običajno ustvarimo le majhno število podatkovnih elementov, med branji pa so določeni časovni intervali, zato običajno ne opazimo časovnega obremenitve branja podatkovnih elementov. Če istega objekta ni mogoče ustvariti dvakrat, ne uporabljajte ConcurrentDictionary. Če te res skrbi zmogljivost, je Dictionary + Locks še vedno dobra rešitev. Pomemben dejavnik je število dodanih in odstranjenih podatkovnih elementov. Če pa je veliko operacij branja, je ta počasnejši od ConcurrentDictionary. Čeprav tega nisem uvedel, je dejansko več svobode za uporabo sheme Dictionary + Locks. Na primer, lahko zaklenete enkrat, dodate več podatkovnih elementov, izbrišete več podatkovnih elementov ali večkrat poizvedujete itd., nato pa sprostite zaklep. Na splošno se izogibajte uporabi ReaderWriterLockSlim, če je veliko več prebranih kot napisanih. Tipi slovarja so že veliko hitrejši kot zaklepanje branja v zaklepu branja in pisanja. Seveda je to odvisno tudi od časa, potrebnega za ustvarjanje objekta v zaklepu. Zato menim, da so primeri nekoliko ekstremni, vendar kažejo, da uporaba ConcurrentDictionary ni vedno najboljša rešitev.
Občutite razliko
Ta članek sem napisal z namenom iskanja boljše rešitve.
Že zdaj poskušam bolje razumeti, kako deluje določen predmet slovarja (zdaj imam občutek, da sem zelo jasen).
Lahko bi rekli, da sta Bucket in Node v ConcurrentDictionary zelo preprosta. Naredil sem nekaj podobnega, ko sem poskušal ustvariti tečaj slovarja. Običajni predmet Slovar se morda zdi preprostejši, a v resnici je bolj zapleten.
V ConcurrentDictionary je vsako vozlišče popoln razred. V razredu Dictionary je Node implementiran z vrednostnim tipom, vsa vozlišča pa so shranjena v ogromnem polju, medtem ko se Bucket uporablja za indeksiranje v polju. Uporablja se tudi namesto preproste reference vozlišča na naslednje vozlišče (konec koncev kot vozlišče tipa strukture ne more vsebovati člana vozlišča tipa strukture).
Pri dodajanju in odstranjevanju slovarja razred Dictionary ne more preprosto ustvariti novega vozlišča, temveč mora preveriti, ali obstaja indeks, ki označuje vozlišče, ki je bilo izbrisano, in ga nato ponovno uporabiti. Ali pa se "Count" uporabi za določitev položaja novega vozlišča v polju. Pravzaprav, ko je polje polno, razred Slovar prisili spremembo velikosti.
Za ConcurrentDictionary lahko vozlišče razumemo kot nov objekt. Odstranitev vozlišča pomeni preprosto odstranitev njegove reference. Dodajanje novega vozlišča lahko preprosto ustvari novo instanco vozlišča. Spreminjanje velikosti je le zato, da se izognemo konfliktom, vendar ni obvezno.
Torej, če razred Dictionary namerno uporablja bolj zapletene algoritme za njegovo obravnavo, kako bo ConcurrentDictionary zagotovil, da bo bolje deloval v večnitnem okolju?
Resnica je: postavitev vseh vozlišč v eno polje je najhitrejši način za dodeljevanje in branje, tudi če potrebujemo drugo polje za sledenje, kje najti te podatkovne elemente. Torej se zdi, da bo enako število vedrov porabilo več pomnilnika, vendar novih podatkovnih elementov ni treba prerazporediti, ni potrebnih novih sinhronizacij objektov in novih zbiralcev smeti se ne pojavi. Ker je vse že pripravljeno.
Vendar pa zamenjava vsebine v vozlišču ni atomska operacija, kar je eden od dejavnikov, ki naredi njegovo nit nevarno. Ker so vozlišča vsa objekti, se najprej ustvari vozlišče, nato pa se ločena referenca posodobi, da nanj kaže (tukaj atomska operacija). Torej lahko brana nit prebere vsebino slovarja brez zaklepa, branje pa mora biti ena od stare in nove vrednosti, pri čemer ni možnosti, da bi prebrali nepopolno vrednost.
Torej, resnica je: če ne potrebujete zaklepa, je razred Slovar hitrejši pri branju, ker je zaklep tisti, ki upočasni branje.
Ta članek je preveden iz članka Paula Zemeka "Dictionary + Locking versus ConcurrentDictionary" na CodeProjectu, nekatere izjave pa se bodo zaradi razumevanja spremenile.
|