A .NET 4.0 előtt, ha a Dictionary osztályt többszálas környezetben kellett használnunk, nem volt más választásunk, mint magunk implementálni a szálszinkronizációt, hogy biztonságban legyenek a szálak.
Sok fejlesztő valóban hasonló szálbiztonsági megoldást valósított meg, akár teljesen új szálbiztonsági szótártípus létrehozásával, akár egyszerűen egy Dictionary objektum bekapzszulázásával egy osztályba, és minden metódushoz hozzáadott zármechanizmussal, amit "Dictionary + Locks"-nak nevezünk.
De most már van ConcurrentDictionary. Az MSDN Dictionary osztály dokumentációjának thread-safe leírása szerint ha szálbiztonsági implementációt kell használni, használd a ConcurrentDictionary-t.
Most, hogy van egy szálbiztos szótárosztályunk, már nem kell magunknak implementálnunk. Remek, nem igaz?
A probléma eredete
Valójában csak egyszer használtam a CocurrentDictionary-t a tesztemben, hogy teszteljem a válaszkészségét. Mivel jól teljesített a teszteken, azonnal lecseréltem az osztályomra, végeztem némi tesztet, aztán valami rosszul sült el.
Szóval, mi ment félre? Nem azt mondtad, hogy szál biztonságos?
További tesztelés után megtaláltam a probléma gyökerét. Valamiért azonban az MSDN 4.0-s verziója nem tartalmazza a GetOrAdd metódus aláírásának leírását, amely megköveteli egy delegált típusú paraméter átadását. Miután megnéztem a 4.5-ös verziót, ezt a megjegyzést találtam:
Ha egyszerre hívod a GetOrAdd-et különböző szálakon, addValueFactory többször is meghívható, de a kulcs/érték pár nem feltétlenül kerül a szótárba minden hívásnál. Ez volt az a probléma, amivel találkoztam. Mivel korábban nem volt leírva a dokumentációban, további vizsgálatokat kellett végeznem, hogy megerősítsem a problémát. Természetesen a probléma, amivel szembesülök, a használatomhoz kapcsolódik, általában a szótár típust használom néhány adat gyorsítótárához:
Ezek az adatok nagyon lassan készülnek; Ez az adat csak egyszer hozható létre, mert a második létrehozás kivételt eredményez, vagy több létrehozás erőforrás-szivárgáshoz vezethet, stb.; A második feltétellel volt problémám. Ha mindkét szál megállapítja, hogy egy adat nem létezik, egyszer létrehozzák azt, de csak egy eredmény menthető el sikeresen. Mi van a másikkal?
Ha a létrehozott folyamat kivételt ad, használhatod a try: Fogás (nem elég elegáns, de megoldja a problémát). De mi van, ha egy erőforrást létrehoznak, és nem újrahasznosítják?
Mondhatod, hogy egy objektum létrejött, és szemétbe kerül, ha már nem hivatkozik rá. Azonban gondoljuk át, mi történne, ha az alábbi helyzet megtörténik:
Generálj kódot dinamikusan az Emit-tel. Ezt a megközelítést egy távoli keretrendszerben használtam, és az összes implementációt egy olyan összeállításba tettem, amit nem lehetett újrahasznosítani. Ha egy típust kétszer hoznak létre, a második mindig létezik, még akkor is, ha soha nem használták. Hozz létre egy szálat közvetlenül vagy közvetve. Például olyan komponenst kell építenünk, amely egy saját szálat használ az aszinkron üzenetek feldolgozására, és a fogadási sorrendtől függ. Amikor a komponens megteremtik, egy szál keletkezik. Ha ez a komponens példány megsemmisül, a szál is megszűnik. De ha törlönk az objektumra vonatkozó hivatkozást a komponens megsemmisítése után, de a szál valamiért nem ér véget, és megtartja az objektumra vonatkozó hivatkozást. Ha a szál nem hallik meg, a tárgy sem kerül újrahasznosításra. Végezzen P/Invoke műveletet. Megköveteljük, hogy a kapott nyerő zárt időpontjai száma ugyanannyi legyen, mint a nyitások száma. Persze, sok hasonló helyzet van. Például egy szótár objektum egy távoli szerveren lévő szolgáltatáshoz kapcsolódik, amit csak egyszer lehet kérni, és ha másodszor kérik, a másik szolgáltatás azt gondolja, hogy valamilyen hiba történt, és beírja a naplóba. (Egy cégnél, ahol dolgoztam, voltak jogi büntetések ezért az állapotért.) ) Így könnyű látni, hogy a Dictionary + Locks nem sietve cserélhető ConcurrentDictionary-re, még akkor sem, ha a dokumentáció szerint szálbiztonságú.
Elemezd a problémát
Még mindig nem érted?
Igaz, hogy ez a probléma nem feltétlenül merül fel a Dictionary + Locks megközelítés alatt. Mivel ez a konkrét megvalósítástól függ, nézzük meg ezt az egyszerű példát:
A fenti kódban a szótár zárát tartjuk, mielőtt elkezdenénk lekérdezni a kulcsértéket. Ha a megadott kulcs-érték pár nem létezik, közvetlenül létrehozza. Ugyanakkor, mivel már zárunk a szótáron, kulcs-érték párokat is hozzáadhatunk közvetlenül a szótárhoz. Ezután engedd el a szótárzárat, és visszaadd az eredményt. Ha két szál egyszerre ugyanazt a kulcsértéket kérdezi, az első szál, amely megkapja a szótárzáratot, befejezi az objektum létrehozását, míg a másik szál megvárja a létrehozás befejezését, és a szótár zárolása után megkapja a létrehozott kulcsérték eredményét.
Ez jó, nem igaz?
Tényleg nem! Nem hiszem, hogy egy ilyen párhuzamos objektum létrehozása, ahol végül csak egyet használnak, nem okozza azt a problémát, amit leírtam.
A helyzet és probléma, amiről ki akarok fejteni, nem mindig reprodukálható, párhuzamos környezetben egyszerűen létrehozhatunk két objektumot, majd egyet eldobhatunk. Szóval, pontosan hogyan hasonlítsuk össze a Dictionary + Locks és a ConcurrentDictionary között?
A válasz az: ez a zárhasználati stratégiától és a szótár használatának módjától függ.
1. játék: Ugyanazt az objektumot párhuzamosan hozzuk létre
Először is, tegyük fel, hogy egy objektumot kétszer is létre lehet hozni, akkor mi történik, ha két szál egyszerre hozza létre ezt az objektumot?
Másodszor, mennyi időt töltünk hasonló alkotásokkal?
Egyszerűen építhetünk egy példát, ahol egy objektum megalkotása 10 másodpercet vesz igénybe. Amikor az első szál 5 másodperccel később létrehozza az objektumot, a második implementáció megpróbálja a GetOrAdd metódusos módot hívni az objektum megszerzéséhez, és mivel az objektum még mindig nem létezik, az is elkezdi létrehozni az objektumot.
Ebben az állapotban két CPU párhuzamosan dolgozik 5 másodpercig, és amikor az első szál befejezi a működést, a második szálnak még 5 másodpercig kell futnia, hogy befejezze az objektum építését. Amikor a második szál befejezi az objektum építését, azt találja, hogy egy objektum már létezik, és úgy dönt, hogy a meglévő objektumot használja, és közvetlenül eldobja az újonnan létrehozott objektumot.
Ha a második szál egyszerűen vár, és a második CPU más munkát végez (más szálakat vagy alkalmazásokat futtat, energiat spórolva), akkor 5 másodperc múlva kapja meg a kívánt objektumot 10 helyett.
Tehát ezek mellett a Dictionary + Locks nyer egy kis játékot.
2. játék: Különböző objektumok párhuzamosan való meglátogatása
Nem, az a helyzet, amit mondtál, egyáltalán nem igaz!
Nos, a fenti példa kissé furcsa, de leírja a problémát, csak ez a használat szélsőségesebb. Gondoljunk tehát mi történik, ha az első szál egy objektumot hoz létre, és a második szálnak egy másik kulcs-érték objektumhoz kell férnie, és az a kulcs-érték objektum már létezik?
A ConcurrentDictionary záratlan kialakítása nagyon gyorssá teszi az olvasásokat, mert nincs zár az olvasáson. Dictionary + Locks esetében az olvasási művelet zárolva zárja egymást, még akkor is, ha teljesen más kulcsról van szó, ami nyilvánvalóan lassítja az olvasási műveletet.
Így a ConcurrentDictionary visszahúzott egy játékot.
Megjegyzés: Itt úgy gondolom, hogy több fogalmat is értesz, mint például a Vödör/Csomópont/Bejegyzés a szótár osztályban, ha nem, ajánlott olvasd el Ofir Makmal "A generikus szótár mélyrelátása" című cikkét, amely jól magyarázza ezeket a fogalmakat.
A játék harmadik játéka: olvass többet és írj egyet
Mi történik, ha a Dictionary + Locks szótárán a Multiple Readers és az Single Writer (Több Olvasó) és az Egyetlen Író (Single Writer) opciót használod a teljes zárolás helyett?
Ha egy szál objektumot hoz létre, és fejleszthető zárat tart, amíg az objektum létrejött, akkor a zárat írózárrá fejlesztik, akkor az olvasási művelet párhuzamosan is elvégezhető.
A problémát úgy is megoldhatjuk, hogy egy olvasási műveletet 10 másodpercig tétlenül hagyunk. De ha sokkal több olvasás van, mint írás, akkor azt fogjuk tapasztalni, hogy a ConcurrentDictionary továbbra is gyors, mert zár nélküli mód olvasásokat valósít meg.
A ReaderWriterLockSlim használata szótárokhoz rontja az olvasást, ezért általában ajánlott a Full Lock-ot használni a szótáraknál a ReaderWriterLockSlim helyett.
Így ezek mellett a ConcurrentDictionary újabb győzelmet aratott.
Megjegyzés: Korábbi cikkekben már foglalkoztam a YieldReaderWriterLock és YieldReaderWriterLockSlim órákkal. Ezzel az olvasás-írási zár használatával jelentősen javult a sebesség (most már SpinReaderWriterLockSlim-re fejlődött), és lehetővé teszi, hogy több olvasás párhuzamosan végrehajtható kevés vagy semmilyen hatás nélkül. Bár még mindig ezt használom, egy zár nélküli ConcurrentDictionary nyilvánvalóan gyorsabb lenne.
4. mérkőzés: Több kulcs-érték pár hozzáadása
A összecsapás még nem ért véget.
Mi van, ha több kulcsértéket kell hozzáadni, és mindegyik nem ütközik, hanem különböző vödörökbe vannak rendelve?
Eleinte ez a kérdés furcsa volt, de csináltam egy tesztet, ami nem igazán illeszkedett. Használtam egy <int, int> típusú szótárt, és az objektum építő gyára közvetlenül negatív eredményt adott vissza kulcsként.
Azt vártam, hogy a ConcurrentDictionary lesz a leggyorsabb, de végül a leglassabbnak bizonyult. A Dictionary + Locks viszont gyorsabban teljesít. Miért van ez?
Ennek oka, hogy a ConcurrentDictionary csomópontokat oszt ki, és különböző vödrökbe helyezi őket, ami optimalizált a zármentes olvasási műveletek kialakításához. Azonban kulcsérték-értékű elemek hozzáadásakor a csomópont létrehozásának folyamata költséges lesz.
Még párhuzamos körülmények között is több időt vesz igénybe a csomópontzár kijelölése, mint a teljes zár.
Szóval, a Dictionary + Locks nyeri ezt a játékot.
Az ötödik játék játszása: Az olvasási műveletek gyakorisága magasabb
Őszintén szólva, ha lenne egy delegáltunk, aki gyorsan képes tárgyakat megjeleníteni, nem lenne szükségünk szótárra. Közvetlenül felhívhatjuk a delegált, hogy megszerezze az objektumot, ugye?
Valójában a válasz az, hogy ez a helyzettől függ.
Képzeljük el, hogy a kulcstípus string, és tartalmazza a webszerver különböző oldalainak útvonaltérképeit, a megfelelő érték pedig egy objektumtípus, amely tartalmazza az oldalra közeledő aktuális felhasználók rekordját és az összes látogatás számát a szerver indulása óta.
Egy ilyen tárgy létrehozása szinte azonnal megtörténik. Ezután már nem kell új objektumot létrehoznod, csak megváltoztatod az ott tárolt értékeket. Így lehetséges kétszer is engedélyezni egy mód létrehozását, amíg csak egy példányt használunk. Azonban mivel a ConcurrentDictionary lassabban osztja ki a Node erőforrásokat, a Dictionary + Locks használata gyorsabb létrehozási időt eredményez.
Tehát ez a példa nagyon különleges, azt is látjuk, hogy a Dictionary + Locks ebben az állapotban jobban teljesít, kevesebb időt vesz igénybe.
Bár a csomópontok elosztása a ConcurrentDictionary-ben lassabb, nem próbáltam 100 millió adatelemet beletenni az idő tesztelésére. Mert ez nyilvánvalóan sok időt vesz igénybe.
De a legtöbb esetben, ha egy adatelem létrejött, mindig olvasható. Az, hogy hogyan változik az adatelem tartalma, az már más kérdés. Tehát nem számít, hány milliszekundum kell még egy adat létrehozásához, mert az olvasások gyorsabbak (csak néhány millimásodperccel gyorsabban), de az olvasások gyakrabban történnek.
Így a ConcurrentDictionary nyerte a játékot.
6. játék: Olyan objektumok létrehozása, amelyek különböző időpontokat fogyasztanak
Mi történik, ha az adatelemek létrehozásához szükséges idő változik?
Hozz létre több adattételt, amelyek különböző időpontokat fogyasztanak, és párhuzamosan add fel őket a szótárba. Ez a ConcurrentDictionary legerősebb pontja.
A ConcurrentDictionary számos különböző zárolási mechanizmust használ, hogy lehetővé tegye az adatelemek egyidejű hozzáadását, de az olyan logika, mint például a zár eldöntése, a vödör méretének megváltoztatására vonatkozó zárkérés stb. nem segít. Az adatelemek vödörbe helyezésének sebessége gépi sebességű. Ami igazán nyeri a ConcurrentDictionaryt, az az, hogy képes párhuzamosan létrehozni objektumokat.
Viszont ugyanezt meg tudjuk tenni. Ha nem érdekel, hogy párhuzamosan hozunk létre objektumokat, vagy ha néhányat eldobtak, hozzáadhatunk egy zárolást, hogy felismerjük, létezik-e az adatelem, majd elengedjük a zárat, létrehozzuk az adat elemet, megnyomjuk, hogy megkapjuk a zárolást, újra ellenőrizzük, létezik-e az adatelem, és ha nincs, hozzáadhatjuk az adat elemet. A kód így nézhet ki:
* Fontos megjegyezni, hogy <int, int> típusú szótárt használok.
A fent említett egyszerű struktúrában a Dictionary + Locks szinte olyan jól teljesít, mint a ConcurrentDictionary, amikor párhuzamos körülmények között adatelemeket hoz létre és adunk hozzá. De ugyanaz a probléma is fennáll, amikor bizonyos értékek generálhatók, de soha nem használhatók.
következtetés
Szóval, van erre következtetés?
Jelenleg még mindig vannak néhány:
Minden szótáróra nagyon gyors. Bár milliókat hoztam létre, mégis gyors. Általában csak kevés számú adatelemet hozunk létre, és vannak időintervallumok az olvasások között, így általában nem veszjük észre az adatelemek olvasásának időigényét. Ha ugyanazt az objektumot nem lehet kétszer létrehozni, ne használd a ConcurrentDictionary-t. Ha igazán aggódsz a teljesítmény miatt, a Dictionary + Locks még mindig jó megoldás lehet. Fontos tényező a hozzáadott és eltávolított adatelemek száma. De ha sok olvasási művelet van, akkor lassabb, mint a ConcurrentDictionary. Bár nem vezettem be, valójában nagyobb szabadság van a Szótár + Zárak séma használatára. Például egyszer zárolhatsz, több adatelemet hozzáadhatsz, több adatelemet törölhetsz, többször is lekérdezést indíthatsz, stb., majd feloldhatod a zárolást. Általánosságban kerüld a ReaderWriterLockSlim használatát, ha sokkal több olvasmány van, mint az írás. A szótártípusok már így is sokkal gyorsabbak, mint olvasási zár olvasási zár esetén. Természetesen ez attól is függ, mennyi időt vesz igénybe egy objektum létrehozásához egy zárban. Szóval szerintem a bemutatott példák kissé szélsőségesek, de megmutatják, hogy a ConcurrentDictionary használata nem mindig a legjobb megoldás.
Érezd a különbséget
Ezt a cikket azzal a szándékkal írtam, hogy jobb megoldást keresek.
Már most is próbálom mélyebben megérteni, hogyan működik egy adott szótár óra (most úgy érzem, nagyon világos vagyok).
Vitatható, hogy a Bucket és Node a ConcurrentDictionary-ben nagyon egyszerű. Én is csináltam hasonlót, amikor megpróbáltam létrehozni egy szótár osztályt. A hagyományos Szótár óra egyszerűbbnek tűnhet, de valójában összetettebb.
A ConcurrentDictionary-ben minden csomópont teljes osztály. A Dictionary osztályban a Node értéktípussal van megvalósítva, és minden csomópontot egy hatalmas tömbben tart, míg a Bucket indexelés a tömbben. A csomópont egyszerű hivatkozása helyett is használják (hiszen struct típusú csomópontként nem tartalmazhat struct típusú csomópont tagját).
Szótár hozzáadásakor és eltávolításánál a Dictionary osztály nem hozhat létre egyszerűen új csomópontot, ellenőriznie kell, hogy van-e index, amely jelöli a törölt csomópontot, majd újra kell használnia. Vagy a "Count" segítségével megkapjuk az új csomópont pozícióját a tömbben. Valójában, amikor a tömb tele van, a Dictionary osztály méretváltoztatást kényszerít ki.
A ConcurrentDictionary esetében a Node új objektumként is értelmezhető. Egy csomópont eltávolítása egyszerűen a hivatkozás eltávolítása. Egy új Node hozzáadása egyszerűen létrehozhat egy új Node példányt. A méret módosítása csak az ütközések elkerülése érdekében van, de nem kötelező.
Tehát, ha a Dictionary osztály szándékosan összetettebb algoritmusokat használ a kezelésére, hogyan biztosítja a ConcurrentDictionary, hogy jobban teljesítsen egy többszálas környezetben?
Az igazság az, hogy az összes csomópontot egy tömbbe helyezni a leggyorsabb módja a kijelölésnek és az olvasásnak, még akkor is, ha egy másik tömbre van szükségünk, hogy nyomon kövessük, hol találhatók ezek az adatelemek. Úgy tűnik, hogy ugyanannyi vödr több memóriát igényel, de az új adatelemeket nem kell átcsoportosítani, nincs szükség új objektumszinkronizációra, és nem történik új szemétgyűjtés. Mert minden már a helyén van.
Azonban a tartalom cseréje egy csomópontban nem atomi művelet, ami az egyik tényező, ami miatt a szál nem biztonságos. Mivel a csomópontok mind objektumok, először egy csomópontot hoznak létre, majd egy külön hivatkozást frissítenek, hogy rájuk mutasson (itt atomi művelet). Tehát az olvasási szál zár nélkül is olvashatja a szótár tartalmát, és az olvasásnak az egyik régi és új értéknek kell lennie, és nincs esély arra, hogy hiányos értéket olvasson.
Szóval, az igazság az, hogy ha nincs szükséged zárásra, a Szótár osztály gyorsabb az olvasásban, mert a zár lassítja az olvasást.
Ez a cikk Paulo Zemek "Dictionary + Locking versus ConcurrentDictionary" című cikkéből származik a CodeProject-en, és néhány állítás megértés miatt változhat.
|