Pred .NET 4.0, ak sme potrebovali použiť triedu Slovník v prostredí s viacvláknami, nemali sme inú možnosť, než implementovať synchronizáciu vlákien sami, aby sme vlákna ochránili.
Mnohí vývojári určite implementovali podobné riešenie bezpečné pre vlákna, buď vytvorením úplne nového typu slovníka bezpečným pre vlákna, alebo jednoducho zapuzdrením objektu slovníka v triede a pridaním mechanizmu zamykania všetkých metód, ktorý nazývame "Dictionary + Locks".
Ale teraz máme ConcurrentDictionary. Popis triedy Dictionary na MSDN uvádza, že ak potrebujete použiť implementáciu bezpečnú pre vlákna, použite ConcurrentDictionary.
Takže teraz, keď máme triedu slovníka bezpečnú pre vlákna, už ju nemusíme implementovať sami. Skvelé, však?
Pôvod problému
V skutočnosti som CocurrentDictionary použil len raz predtým, v teste na testovanie jeho odozvy. Keďže sa mu v testoch darilo, okamžite som ho nahradil svojou triedou, urobil som nejaké testy, a potom sa niečo pokazilo.
Tak čo sa pokazilo? Nepovedal si, že je to bezpečné?
Po ďalšom testovaní som našiel koreň problému. Ale z nejakého dôvodu MSDN verzia 4.0 neobsahuje popis podpisu metódy GetOrAdd, ktorý vyžaduje odovzdanie parametra typu delegát. Po preštudovaní verzie 4.5 som našiel túto poznámku:
Ak voláte GetOrAdd súčasne na rôznych vláknach, addValueFactory môže byť volaný viackrát, ale jeho pár kľúč/hodnota nemusí byť pridaný do slovníka pre každé volanie. To je problém, na ktorý som narazil. Keďže to nebolo predtým opísané v dokumentácii, musel som vykonať ďalšie testy, aby som problém potvrdil. Samozrejme, problém, na ktorý narážam, súvisí s mojím používaním, všeobecne používam typ slovníka na ukladanie niektorých dát do vyrovnávacej pamäte:
Tieto dáta sa vytvárajú veľmi pomaly; Tieto dáta je možné vytvoriť iba raz, pretože druhé vytvorenie vyhodí výnimku, alebo viaceré vytvorenia môžu viesť k úniku zdrojov a podobne; Mal som problém s druhou podmienkou. Ak obe vlákna zistia, že dáta neexistujú, vytvorí sa raz, ale úspešne sa uloží len jeden výsledok. A čo ten druhý?
Ak proces, ktorý vytvoríte, vyvolá výnimku, môžete použiť try: Catch (nie dosť elegantné, ale problém to rieši). Ale čo ak je zdroj vytvorený a nerecyklovaný?
Môžete povedať, že objekt je vytvorený a bude odvozený ako smetiar, ak sa naň už neodkazuje. Zvážte však, čo by sa stalo, keby nastala situácia opísaná nižšie:
Generujte kód dynamicky pomocou Emit. Tento prístup som použil v Redálnom frameworku a všetky implementácie som dal do zostavy, ktorú nebolo možné recyklovať. Ak je typ vytvorený dvakrát, druhý bude vždy existovať, aj keď nikdy nebol použitý. Vytvorte vlákno priamo alebo nepriamo. Napríklad potrebujeme vytvoriť komponent, ktorý používa proprietárne vlákno na spracovanie asynchrónnych správ a závisí od poradia, v akom sú prijaté. Keď je komponent inštancionovaný, vytvorí sa vlákno. Keď je táto inštancia komponentu zničená, vlákno sa tiež ukončí. Ak však po zničení komponentu vymazame odkaz na objekt, vlákno z nejakého dôvodu nekončí a odkaz na objekt zostane. Ak potom niť nezomrie, predmet sa tiež nerecykluje. Vykonajte operáciu P/Invoke. Vyžadujeme, aby počet uzavretých čias prijatej rukoväte bol rovnaký ako počet otvorení. Samozrejme, existuje mnoho podobných situácií. Napríklad slovníkový objekt bude držať spojenie so službou na vzdialenom serveri, ktoré je možné požiadať len raz, a ak je požiadané druhýkrát, druhá služba si myslí, že došlo k nejakej chybe, a zaznamená ju do logu. (V spoločnosti, kde som pracoval, boli za tento stav určité právne sankcie.) ) Takže je ľahké vidieť, že Dictionary + Locks nemožno unáhlene nahradiť ConcurrentDictionary, aj keď dokumentácia tvrdí, že je bezpečný pre vlákna.
Analyzujte problém
Stále tomu nerozumieš?
Je pravda, že tento problém nemusí vzniknúť pri prístupe Slovník + Zámky. Keďže to závisí od konkrétnej implementácie, pozrime sa na tento jednoduchý príklad:
V uvedenom kóde držíme zámok na slovníku predtým, než začneme dotazovať na hodnotu kľúča. Ak špecifikovaný pár kľúč-hodnota neexistuje, vytvorí sa priamo. Zároveň, keďže už máme zámok na tomto slovníku, môžeme do slovníka priamo pridať páry kľúč-hodnota. Potom uvoľnite slovníkový zámok a vráťte výsledok. Ak dve vlákna súčasne dotazujú tú istú kľúčovú hodnotu, prvé vlákno, ktoré získa slovníkový zámok, dokončí vytvorenie objektu a druhé vlákno počká na dokončenie tohto vytvorenia a získa výsledok vytvorenej kľúčovej hodnoty po získaní slovníkového zámku.
To je dobré, však?
Naozaj nie je! Nemyslím si, že vytvorenie objektu paralelne, kde sa nakoniec použije len jeden, nevytvára problém, ktorý som opísal.
Situácia a problém, ktorý sa snažím rozviesť, nemusí byť vždy reprodukovateľný, v paralelnom prostredí môžeme jednoducho vytvoriť dva objekty a potom jeden zahodiť. Takže, ako presne porovnávame Dictionary + Locks a ConcurrentDictionary?
Odpoveď je: závisí to od stratégie používania zámkov a spôsobu používania slovníka.
Hra 1: Vytvoriť ten istý objekt paralelne
Najprv predpokladajme, že objekt môže byť vytvorený dvakrát, čo sa stane, ak tento objekt vytvoria dve vlákna súčasne?
Po druhé, ako dlho venujeme podobným výtvorom?
Môžeme jednoducho vytvoriť príklad, kde inštancia objektu trvá 10 sekúnd. Keď prvé vlákno vytvorí objekt o 5 sekúnd neskôr, druhá implementácia sa pokúsi zavolať metódu GetOrAdd na získanie objektu, a keďže objekt stále neexistuje, začne ho tiež vytvárať.
V tomto stave máme 2 CPU pracujúce paralelne 5 sekúnd a keď prvé vlákno dokončí prácu, druhé vlákno musí pokračovať v behu ešte 5 sekúnd, aby dokončilo konštrukciu objektu. Keď druhé vlákno dokončí stavbu objektu, zistí, že objekt už existuje, a rozhodne sa použiť existujúci objekt a novovytvorený objekt priamo zahodiť.
Ak druhé vlákno jednoducho počká a druhý CPU vykoná inú prácu (spustí iné vlákna alebo aplikácie, ušetrí energiu), po 5 sekundách namiesto 10 sekúnd dostane požadovaný objekt.
Za týchto podmienok teda Dictionary + Locks vyhráva malú hru.
Hra 2: Navštívte rôzne objekty paralelne
Nie, situácia, ktorú si povedal, vôbec nie je pravdivá!
No, vyššie uvedený príklad je trochu zvláštny, ale vystihuje problém, lenže toto použitie je extrémnejšie. Takže zvážte, čo sa stane, ak prvé vlákno vytvára objekt a druhé vlákno potrebuje pristupovať k inému objektu kľúč-hodnota, pričom ten objekt kľúč-hodnota už existuje?
V ConcurrentDictionary dizajn bez zámku spôsobuje, že čítania sú veľmi rýchle, pretože na čítanie nie je žiadny zámok. V prípade Dictionary + Locks bude operácia čítania vzájomne vylučujúca, aj keď ide o úplne iný kľúč, čo samozrejme spomalí operáciu čítania.
Týmto spôsobom ConcurrentDictionary stiahol hru späť.
Poznámka: Tu považujem za to, že rozumiete viacerým konceptom ako Bucket/Node/Entry v kurze slovníka, ak nie, odporúča sa prečítať si článok Ofira Makmala "Pochopenie všeobecného slovníka do hĺbky", ktorý tieto koncepty dobre vysvetľuje.
Tretia hra hry: čítaj viac a píš single
Čo sa stane, ak použijete Multiple Readers a Single Writer namiesto úplného uzamknutia slovníka v Dictionary + Locks?
Ak vlákno vytvára objekt a drží upgradovateľný zámok až do jeho vytvorenia, zámok sa upgraduje na zápisový zámok, potom môže byť čítanie vykonané paralelne.
Problém môžeme vyriešiť aj tak, že necháme operáciu čítania nečinnú 10 sekúnd. Ale ak je čítaní oveľa viac než zápisov, zistíme, že ConcurrentDictionary je stále rýchly, pretože implementuje čítanie v režime bez zámku.
Používanie ReaderWriterLockSlim pre slovníky zhoršuje čítanie a všeobecne sa odporúča používať Full Lock pre slovníky namiesto ReaderWriterLockSlim.
Takže za týchto podmienok ConcurrentDictionary vyhral ďalšiu hru.
Poznámka: V predchádzajúcich článkoch som sa venoval triedam YieldReaderWriterLock a YieldReaderWriterSlim. Použitím tohto zámku na čítanie a zápis sa rýchlosť výrazne zlepšila (teraz sa vyvinula do SpinReaderWriterLockSlim) a umožňuje paralelné vykonávanie viacerých čítaní s minimálnym alebo žiadnym vplyvom. Kým stále používam tento spôsob, bezzámkový ConcurrentDictionary by bol samozrejme rýchlejší.
Hra 4: Pridanie viacerých párov kľúč-hodnota
Súboj ešte neskončil.
Čo ak máme viacero kľúčových hodnôt na pridanie a všetky sa nekolidujú a sú priradené do rôznych kategórií?
Najskôr bola táto otázka zaujímavá, ale urobil som test, ktorý úplne nesedel. Použil som slovník typu <int, int> a továreň na konštrukciu objektu vracala záporný výsledok priamo ako kľúč.
Očakával som, že ConcurrentDictionary bude najrýchlejší, ale ukázalo sa, že je najpomalší. Dictionary + Locks naopak funguje rýchlejšie. Prečo je to tak?
Je to preto, že ConcurrentDictionary prideľuje uzly a zaraďuje ich do rôznych bucketov, čo je optimalizované tak, aby spĺňalo dizajn bez zámkov pre čítacie operácie. Avšak pri pridávaní položiek kľúč-hodnota sa proces vytvárania uzla stáva nákladným.
Aj za paralelných podmienok pridelenie uzlového zámku stále zaberie viac času než použitie plného zámku.
Takže Dictionary + Locks vyhráva túto hru.
Hranie piatej hry: Frekvencia čítania je vyššia
Úprimne, ak by sme mali delegáta, ktorý dokáže rýchlo inštancionovať objekty, nepotrebovali by sme slovník. Môžeme priamo zavolať delegáta, aby získal objekt, však?
V skutočnosti je odpoveď tiež taká, že to závisí od situácie.
Predstavte si, že typ kľúča je reťazec a obsahuje mapy ciest pre rôzne stránky na webovom serveri a zodpovedajúca hodnota je typ objektu, ktorý obsahuje záznam aktuálnych používateľov pristupujúcich k stránke a počet všetkých návštev stránky od spustenia servera.
Vytvorenie takéhoto objektu je takmer okamžité. A potom už nemusíte vytvárať nový objekt, stačí zmeniť hodnoty uložené v ňom. Takže je možné povoliť vytvorenie spôsobu dvakrát, až kým sa nepoužije len jedna inštancia. Avšak, pretože ConcurrentDictionary prideľuje zdroje uzlov pomalšie, použitie Dictionary + Locks vedie k rýchlejšiemu vytváraniu zdrojov.
Takže tento príklad je veľmi špeciálny, vidíme tiež, že Dictionary + Locks funguje lepšie za tejto podmienky, zaberie to menej času.
Aj keď je alokácia uzlov v ConcurrentDictionary pomalšia, nesnažil som sa do nej vložiť 100 miliónov dátových položiek, aby som otestoval čas. Pretože to samozrejme zaberie veľa času.
Ale vo väčšine prípadov, keď je dátová položka vytvorená, vždy sa číta. Ako sa obsah údajov mení, je už iná vec. Takže nezáleží na tom, koľko milisekúnd viac trvá vytvorenie dátovej položky, pretože čítania sú rýchlejšie (len o pár milisekúnd rýchlejšie), ale čítania sa dejú častejšie.
Takže ConcurrentDictionary vyhral hru.
Hra 6: Vytvárajte objekty, ktoré spotrebúvajú rôzne časy
Čo sa stane, ak sa čas potrebný na vytvorenie rôznych dátových položiek líši?
Vytvorte viacero dátových položiek, ktoré spotrebujú rôzne časy, a pridajte ich do slovníka paralelne. Toto je najsilnejší bod ConcurrentDictionary.
ConcurrentDictionary používa niekoľko rôznych mechanizmov uzamykania na súčasné pridávanie dátových položiek, ale logika ako rozhodovanie, ktorý zámok použiť, žiadosť o zmenu veľkosti vedra a podobne, nepomáha. Rýchlosť, akou sa dátové položky vkladajú do vedra, je strojovo rýchla. To, čo robí ConcurrentDictionary naozaj víťazným, je jeho schopnosť vytvárať objekty paralelne.
Avšak môžeme urobiť to isté. Ak nám nezáleží na tom, či vytvárame objekty paralelne, alebo či niektoré z nich boli zahodené, môžeme pridať zámok na detekciu, či dátový predmet už existuje, potom ho uvoľniť, vytvoriť dátový predmet, stlačiť ho na získanie zámku, znova skontrolovať, či dátový predmet existuje, a ak nie, pridať dátový objekt. Kód by mohol vyzerať asi takto:
* Všimnite si, že používam slovník typu <int, int>.
V jednoduchej štruktúre vyššie Dictionary + Locks funguje takmer rovnako dobre ako ConcurrentDictionary pri vytváraní a pridávaní dátových položiek v paralelných podmienkach. Ale je tu aj ten istý problém, kde niektoré hodnoty môžu byť generované, ale nikdy nepoužité.
záver
Takže, existuje nejaký záver?
V tejto chvíli sú stále niektoré:
Všetky hodiny slovníka sú veľmi rýchle. Aj keď som vytvoril milióny dát, stále je to rýchle. Zvyčajne vytvárame len malý počet dátových položiek a medzi čítaniami sú určité časové intervaly, takže si väčšinou nevšimneme časovú záťaž pri čítaní dátových položiek. Ak ten istý objekt nie je možné vytvoriť dvakrát, nepoužívajte ConcurrentDictionary. Ak ti naozaj záleží na výkone, Dictionary + Locks môže byť stále dobrým riešením. Dôležitým faktorom je počet pridaných a odstránených dátových položiek. Ak je však veľa čítacích operácií, je to pomalšie ako ConcurrentDictionary. Aj keď som to nezaviedol, v skutočnosti je tu väčšia sloboda používať schému Slovník + Zámky. Napríklad môžete zamknúť raz, pridať viacero dátových položiek, vymazať viacero dátových položiek alebo viackrát dotazovať a potom uvoľniť zámok. Vo všeobecnosti sa vyhnite používaniu ReaderWriterLockSlim, ak je prečítaní oveľa viac než napísaných. Typy slovníkov sú už oveľa rýchlejšie než dosiahnuť zámok čítania v systéme čítania-zápisu. Samozrejme, toto závisí aj od času potrebného na vytvorenie objektu v zámku. Takže si myslím, že uvedené príklady sú trochu extrémne, ale ukazujú, že použitie ConcurrentDictionary nie je vždy najlepším riešením.
Pocíť rozdiel
Tento článok som napísal s úmyslom nájsť lepšie riešenie.
Už sa snažím hlbšie pochopiť, ako funguje konkrétna hodina slovníka (teraz mám pocit, že som veľmi jasný).
Dá sa povedať, že Bucket a Node v ConcurrentDictionary sú veľmi jednoduché. Urobil som niečo podobné, keď som sa snažil vytvoriť kurz slovníka. Bežný kurz slovníka sa môže zdať jednoduchší, ale v skutočnosti je zložitejší.
V ConcurrentDictionary je každý uzol kompletnou triedou. V triede Dictionary je Node implementovaný pomocou typu hodnoty a všetky uzly sú uložené v obrovskom poli, zatiaľ čo Bucket sa používa na indexovanie v poli. Používa sa tiež namiesto jednoduchého odkazu uzla na jeho ďalší uzol (napokon, ako uzol typu štruktúry nemôže obsahovať člena uzla typu štruktúry).
Pri pridávaní a odstraňovaní slovníka trieda Dictionary nemôže jednoducho vytvoriť nový uzol, musí skontrolovať, či existuje index označujúci uzol, ktorý bol vymazaný, a potom ho znovu použiť. Alebo sa "Count" používa na získanie pozície nového uzla v poli. V skutočnosti, keď je pole plné, trieda Dictionary vynúti zmenu veľkosti.
Pre ConcurrentDictionary možno uzol považovať za nový objekt. Odstránenie uzla znamená jednoducho odstránenie jeho referencie. Pridaním nového uzla stačí vytvoriť novú inštanciu uzla. Zmena veľkosti je len na zabránenie konfliktom, ale nie je povinná.
Ak teda trieda Dictionary zámerne používa zložitejšie algoritmy na jeho spracovanie, ako ConcurrentDictionary zabezpečí, že bude lepšie fungovať v prostredí s viacvláknovým prostredím?
Pravda je: umiestniť všetky uzly do jedného poľa je najrýchlejší spôsob alokácie a čítania, aj keď potrebujeme ďalšie pole na sledovanie, kde tieto dátové položky nájsť. Takže to vyzerá, že rovnaký počet bucketov spotrebuje viac pamäte, ale nové dátové položky netreba presúvať, nie sú potrebné nové synchronizácie objektov a nové garbage collection sa nedejú. Pretože všetko je už pripravené.
Avšak nahradenie obsahu v uzle nie je atómová operácia, čo je jeden z faktorov, prečo je vlákno nebezpečné. Keďže uzly sú všetky objekty, najskôr sa vytvorí uzol a potom sa aktualizuje samostatná referencia, ktorá naň smeruje (tu atómová operácia). Takže čítané vlákno môže čítať obsah slovníka bez zámku a čítanie musí byť jedna zo starých a nových hodnôt, pričom nie je šanca prečítať neúplnú hodnotu.
Takže pravda je: ak nepotrebujete zámok, trieda Slovník je rýchlejšia pri čítaní, pretože práve zámok spomaľuje čítanie.
Tento článok je preložený z článku Paula Zemeka "Dictionary + Locking verzus ConcurrentDictionary" na CodeProject a niektoré tvrdenia sa zmenia z dôvodu pochopenia.
|