Ennen .NET 4.0:aa, jos meidän piti käyttää sanakirjaluokkaa monisäikeisessä ympäristössä, meillä ei ollut muuta vaihtoehtoa kuin toteuttaa säikeiden synkronointi itse säikeiden turvallisuuden takaamiseksi.
Monet kehittäjät ovat varmasti toteuttaneet vastaavan säiketurvallisen ratkaisun joko luomalla kokonaan uuden säiketurvallisen sanakirjatyypin tai yksinkertaisesti kapseloimalla sanakirjaobjektin luokkaan ja lisäämällä lukitusmekanismin kaikkiin metodeihin, joita kutsumme "Sanakirjaksi + lukituksiksi".
Mutta nyt meillä on ConcurrentDictionary. MSDN:n Dictionary-luokkadokumentaation säiketurvallisen kuvauksen mukaan jos tarvitset säiketurvallista toteutusta, käytä ConcurrentDictionarya.
Nyt kun meillä on säikeiden turvallinen sanakirjaluokka, meidän ei enää tarvitse toteuttaa sitä itse. Mahtavaa, eikö?
Ongelman alkuperä
Itse asiassa olen käyttänyt CocurrentDictionarya vain kerran aiemmin testissäni testatakseni sen reagointikykyä. Koska se menestyi hyvin kokeissa, korvasin sen heti omalla kurssillani, tein testejä, ja sitten jokin meni pieleen.
Mikä meni pieleen? Etkö sanonut lankaturvallista?
Lisätestien jälkeen löysin ongelman juurisyyn. Mutta jostain syystä MSDN-versio 4.0 ei sisällä kuvausta GetOrAdd metodin allekirjoituksesta, joka vaatisi delegaattityyppiparametrin läpäisemistä. Katsottuani version 4.5 löysin tämän huomautuksen:
Jos kutsut GetOrAddia samanaikaisesti eri säikeissä, addValueFactorya voidaan kutsua useita kertoja, mutta sen avain/arvoparia ei välttämättä lisätä sanakirjaan jokaisessa kutsussa. Siihen ongelmaan törmäsin. Koska asiaa ei ollut aiemmin kuvattu dokumentaatiossa, jouduin tekemään lisää testejä ongelman varmistamiseksi. Tietenkin ongelma, johon törmän, liittyy käyttöoni, yleisesti käytän sanakirjatyyppiä välimuistiin jonkin datan tallentamiseen:
Tämän datan syntyminen on hyvin hidasta; Tämä data voidaan luoda vain kerran, koska toinen luominen aiheuttaa poikkeuksen, tai useat luonnit voivat johtaa resurssien vuotoon jne.; Minulla oli ongelma toisen ehdon kanssa. Jos molemmat säikeet huomaavat, ettei dataa ole olemassa, se luodaan kerran, mutta vain yksi tulos tallennetaan onnistuneesti. Entä toinen?
Jos luomasi prosessi heittää poikkeuksen, voit käyttää try:tä: catch (ei tarpeeksi elegantti, mutta ratkaisee ongelman). Mutta entä jos resurssi luodaan eikä kierrätetä?
Voisit sanoa, että objekti on luotu ja se kerätään roskia, jos siihen ei enää viitata. Kuitenkin, mitä tapahtuisi, jos alla kuvattu tilanne tapahtuisi:
Tuota koodia dynaamisesti Emitillä. Käytin tätä lähestymistapaa etäohjauksessa ja laitoin kaikki toteutukset kokoonpanoon, jota ei voitu kierrättää. Jos tyyppi luodaan kahdesti, toinen on aina olemassa, vaikka sitä ei olisi koskaan käytetty. Luo ketju suoraan tai epäsuorasti. Esimerkiksi meidän täytyy rakentaa komponentti, joka käyttää suljettua säikettä käsittelemään asynkronisia viestejä ja perustuu niiden vastaanottojärjestykseen. Kun komponentti on instansoitu, syntyy säie. Kun tämä komponenttiinstanssi tuhotaan, säie päättyy myös. Mutta jos poistamme viittauksen objektiin komponentin tuhoamisen jälkeen, säie ei lopu jostain syystä ja pitää viittauksen objektiin. Jos lanka ei kuole, esinettäkään ei kierrätetä. Suorita P/Invoke-operaatio. Vaaditaan, että vastaanotetun kahvan suljettujen aikojen määrä on sama kuin aukojen määrä. On totta, että samankaltaisia tilanteita on monia. Esimerkiksi sanakirjaobjekti pitää yhteyden etäpalvelimen palveluun, jota voi pyytää vain kerran, ja jos sitä pyydetään toisen kerran, toinen palvelu luulee jonkinlaisen virheen tapahtuneen ja kirjaa sen lokiin. (Yrityksessä, jossa työskentelin, tästä tilasta oli joitakin oikeudellisia seuraamuksia.) ) Näin ollen on helppo nähdä, ettei Dictionary + Locksia voi kiireesti korvata ConcurrentDictionaryllä, vaikka dokumentaatiossa sanotaan, että se on säikeiden turvallinen.
Analysoi ongelma
Et vieläkään ymmärrä?
On totta, että tämä ongelma ei välttämättä ilmene Dictionary + Locks -lähestymistavassa. Koska tämä riippuu toteutuksesta, katsotaanpa tätä yksinkertaista esimerkkiä:
Yllä olevassa koodissa pidämme lukitusta sanakirjassa ennen kuin aloitamme avaimen arvon kyselyn. Jos määriteltyä avain-arvoparia ei ole, se luodaan suoraan. Samaan aikaan, koska meillä on jo lukko kyseisessä sanakirjassa, voimme lisätä avain-arvoparit suoraan sanakirjaan. Sitten vapauta sanakirjan lukko ja palauttaa tulos. Jos kaksi säikettä hakee samaa avainarvoa samanaikaisesti, ensimmäinen säie, joka saa sanakirjalukon, viimeistelee objektin luomisen, ja toinen säie odottaa tämän luomisen valmistumista ja saa luodun avaimen arvon tuloksen sanaston lukituksen jälkeen.
Se on hyvä, eikö?
Se ei todellakaan ole! En usko, että tällaisen rinnakkaisen objektin luominen, jossa lopulta käytetään vain yhtä objektia, ei aiheuta kuvaamaani ongelmaa.
Tilanne ja ongelma, jota yritän selittää, eivät välttämättä aina ole toistettavissa, rinnakkaisessa ympäristössä voimme yksinkertaisesti luoda kaksi objektia ja sitten hylätä toisen. Joten, miten tarkalleen vertaamme Dictionary + Locksia ja ConcurrentDictionarya?
Vastaus on: se riippuu lukon käyttöstrategiasta ja siitä, miten sanakirjaa käytetään.
Peli 1: Luo sama objekti rinnakkain
Ensiksi oletetaan, että objekti voidaan luoda kahdesti, joten mitä tapahtuu, jos kaksi säiettä luovat tämän objektin samanaikaisesti?
Toiseksi, kuinka kauan käytämme samankaltaisiin luomuksiin?
Voimme yksinkertaisesti rakentaa esimerkin, jossa objektin instansointi kestää 10 sekuntia. Kun ensimmäinen säie luo objektin 5 sekuntia myöhemmin, toinen toteutus yrittää kutsua GetOrAdd -metodin saadakseen objektin, ja koska oliota ei vieläkään ole olemassa, sekin alkaa luoda objektia.
Tässä tilanteessa meillä on kaksi suoritinta, jotka työskentelevät rinnakkain 5 sekunnin ajan, ja kun ensimmäinen säie lopettaa toimintansa, toisen säikeen täytyy jatkaa käynnissä 5 sekuntia saadakseen objektin rakentamisen valmiiksi. Kun toinen säie lopettaa objektin rakentamisen, se huomaa, että objekti on jo olemassa, ja valitsee käyttää olemassa olevaa objektia ja hylätä uuden luodun objektin suoraan.
Jos toinen säie vain odottaa ja toinen prosessori tekee muuta työtä (ajaa muita säikeitä tai sovelluksia, säästää virtaa), se saa halutun objektin 5 sekunnin kuluttua 10 sekunnin sijaan.
Näissä olosuhteissa Dictionary + Locks voittaa pienen pelin.
Peli 2: Vieraile eri esineissä rinnakkain
Ei, se tilanne, jonka sanoit, ei ole lainkaan totta!
No, yllä oleva esimerkki on hieman omituinen, mutta se kuvaa ongelman, mutta tämä käyttö on äärimmäisempää. Joten mieti, mitä tapahtuu, jos ensimmäinen säie luo objektin, ja toisen säikeen täytyy päästä käsiksi toiseen avain-arvo-objektiin, ja tuo avain-arvo-objekti on jo olemassa?
ConcurrentDictionaryssa lukitukseton rakenne tekee lukemisesta hyvin nopeaa, koska lukemisessa ei ole lukkoa. Dictionary + Locks -tapauksessa lukutoiminto on lukittu toisensa poissulkevaksi, vaikka se olisi täysin eri avain, mikä luonnollisesti hidastaa lukutoimintoa.
Näin ConcurrentDictionary vetäytyi pelistä.
Huomautus: Tässä katson, että ymmärrät useita käsitteitä, kuten Bucket/Node/Entry sanakirjaluokassa, ja jos et, suositellaan lukemaan Ofir Makmalin artikkelin "Understanding Generic Dictionary in-depth", joka selittää nämä käsitteet hyvin.
Pelin kolmas peli: lue lisää ja kirjoita yksittäinen
Mitä tapahtuu, jos käytät Multiple Readers ja Single Writer -toimintoa sen sijaan, että käyttäisit sanakirjan täydellistä lukkoa Dictionary + Locksissa?
Jos säie luo objektia ja pitää päivitettävän lukon siihen asti, kunnes objekti luodaan, lukko päivitetään kirjoituslukoksi, jolloin lukutoiminto voidaan suorittaa rinnakkain.
Ongelman voi myös ratkaista jättämällä lukuoperaation käyttämättömäksi 10 sekunniksi. Mutta jos lukukertoja on paljon enemmän kuin kirjoituksia, huomaamme, että ConcurrentDictionary on edelleen nopea, koska se toteuttaa lukituksettomien tilalukujen käyttöä.
ReaderWriterLockSlimin käyttö sanakirjoille heikentää lukemista, ja yleisesti suositellaan Full Lockin käyttöä sanakirjoille ReaderWriterLockSlimin sijaan.
Näissä olosuhteissa ConcurrentDictionary voitti toisen pelin.
Huomautus: Olen käsitellyt YieldReaderWriterLock- ja YieldReaderWriterSlim-kursseja aiemmissa artikkeleissa. Tämän luku-kirjoituslukituksen avulla nopeus on parantunut huomattavasti (nykyisin kehittynyt SpinReaderWriterLockSlimiksi) ja mahdollistaa useiden lukujen suorittamisen rinnakkain lähes ilman vaikutusta. Vaikka käytän tätä tapaa, lukiton ConcurrentDictionary olisi selvästi nopeampi.
Peli 4: Lisää useita avainarvopareja
Kohtaaminen ei ole vielä ohi.
Entä jos meillä on useita avainarvoja lisättävänä, ja kaikki eivät törmää yhteen ja ne on määritetty eri äkkeisiin?
Aluksi tämä kysymys oli utelias, mutta tein testin, joka ei oikein sopinut. Käytin sanakirjaa tyypillä <int, int> ja objektin rakennuslaitos palautti negatiivisen tuloksen suoraan avaimena.
Odotin ConcurrentDictionaryn olevan nopein, mutta se osoittautui hitaimmaksi. Dictionary + Locks puolestaan toimii nopeammin. Miksi niin?
Tämä johtuu siitä, että ConcurrentDictionary jakaa solmut ja sijoittaa ne eri äkkeisiin, mikä on optimoitu vastaamaan lukutoimintojen lukituksetonta suunnittelua. Kuitenkin, kun lisätään avainarvokohteita, solmun luominen käy kalliiksi.
Jopa rinnakkaisolosuhteissa solmulukon varaaminen vie silti enemmän aikaa kuin täyden lukon käyttäminen.
Eli Dictionary + Locks voittaa tämän pelin.
Viidennen pelin pelaaminen: Lukutoimintojen tiheys on korkeampi
Rehellisesti sanottuna, jos meillä olisi edustaja, joka voisi nopeasti luoda esineitä, emme tarvitsisi sanakirjaa. Voimme soittaa suoraan edustajalle saadaksemme esineen, eikö niin?
Itse asiassa vastaus on myös se, että se riippuu tilanteesta.
Kuvittele, että avaintyyppi on merkkijono, joka sisältää polkukartat eri sivuille verkkopalvelimella, ja vastaava arvo on objektityyppi, joka sisältää tietueet nykyisistä käyttäjistä, jotka käyttävät sivua, sekä kaikkien käyntien määrän palvelimen alusta lähtien.
Tällaisen esineen luominen tapahtuu lähes välittömästi. Sen jälkeen sinun ei tarvitse luoda uutta objektia, vaan voit vain muuttaa siihen tallennettuja arvoja. Näin ollen on mahdollista sallia tavan luominen kahdesti, kunnes käytössä on vain yksi instanssi. Koska ConcurrentDictionary kuitenkin jakaa solmuresursseja hitaammin, Dictionary + Locks -toiminnon käyttö nopeuttaa luomisaikoja.
Joten tämän esimerkin ollessa hyvin erityinen, näemme myös, että Dictionary + Locks toimii paremmin tässä tilanteessa ja vie vähemmän aikaa.
Vaikka solmujen allokointi ConcurrentDictionaryssa on hitaampaa, en yrittänyt laittaa siihen 100 miljoonaa dataa testatakseni aikaa. Koska se vie tietenkin paljon aikaa.
Mutta useimmissa tapauksissa, kun datakohde on luotu, se luetaan aina. Se, miten datakohteen sisältö muuttuu, on toinen asia. Joten ei ole väliä, kuinka monta millisekuntia lisää datan luomiseen kuluu, koska lukeminen on nopeampaa (vain muutaman millisekunnin nopeampi), mutta lukemiset tapahtuvat useammin.
Joten ConcurrentDictionary voitti pelin.
Peli 6: Luo esineitä, jotka kuluttavat eri aikoja
Mitä tapahtuu, jos eri tietokohteiden luomiseen kuluva aika vaihtelee?
Luo useita tietokohteita, jotka kuluttavat eri aikoja, ja lisää ne sanakirjaan rinnakkain. Tämä on ConcurrentDictionaryn vahvin puoli.
ConcurrentDictionary käyttää useita erilaisia lukitusmekanismeja, jotta tietokohteita voidaan lisätä samanaikaisesti, mutta logiikka kuten lukkojen valinta, lukon pyytäminen ämpärin koon muuttamiseen jne. ei auta. Datan laittamisen nopeus ämpäriin on koneen nopeus. Se, mikä todella tekee ConcurrentDictionarysta voiton, on sen kyky luoda objekteja rinnakkain.
Kuitenkin voimme itse asiassa tehdä saman. Jos emme välitä, luommeko objekteja rinnakkain vai onko osa niistä hylätty, voimme lisätä lukon havaitsemaan, onko datakohde jo olemassa, sitten vapauttaa lukon, luoda datakohteen, painaa sitä saadaksesi lukon, tarkistaa uudelleen onko datakohde olemassa, ja jos ei ole, lisätä datakohteen. Koodi voisi näyttää jotakuinkin tältä:
* Huomaa, että käytän sanakirjaa, joka on tyyppi <int, int>.
Yllä olevassa yksinkertaisessa rakenteessa Dictionary + Locks toimii lähes yhtä hyvin kuin ConcurrentDictionary, kun datakohteita luodaan ja lisätään rinnakkaisissa olosuhteissa. Mutta sama ongelma on sama, jossa jotkut arvot saatetaan generoida, mutta niitä ei koskaan käytetä.
johtopäätös
Joten, onko johtopäätös?
Tällä hetkellä on vielä joitakin:
Kaikki sanakirjakurssit ovat todella nopeita. Vaikka olen luonut miljoonia dataa, se on silti nopeaa. Normaalisti luomme vain pienen määrän datakohteita, ja lukujen välillä on tiettyjä aikavälejä, joten emme yleensä huomaa datan lukemiseen liittyvää aikakuormaa. Jos samaa objektia ei voi luoda kahdesti, älä käytä ConcurrentDictionaryä. Jos suorituskyky todella huolettaa, Dictionary + Locks voi silti olla hyvä ratkaisu. Tärkeä tekijä on lisättyjen ja poistettujen tietokohteiden määrä. Mutta jos lukutoimintoja on paljon, se on hitaampaa kuin ConcurrentDictionary. Vaikka en sitä esitellyt, Dictionary + Locks -järjestelmän käyttämiseen on itse asiassa enemmän vapautta. Esimerkiksi voit lukita kerran, lisätä useita tietokohteita, poistaa useita datakohteita tai kysyä useita kertoja jne., ja sitten vapauttaa lukon. Yleisesti ottaen vältä ReaderWriterLockSlimin käyttöä, jos lukemiskertoja on paljon enemmän kuin kirjoituksia. Sanakirjatyypit ovat jo paljon nopeampia kuin lukulukituksen saaminen luku-kirjoituslukkoon. Tämä riippuu tietenkin myös siitä, kuinka paljon aikaa lukon objektin luomiseen kuluu. Joten mielestäni annetut esimerkit ovat hieman äärimmäisiä, mutta ne osoittavat, että ConcurrentDictionaryn käyttö ei aina ole paras ratkaisu.
Tunne ero
Kirjoitin tämän artikkelin tarkoituksenani löytää parempi ratkaisu.
Yritän jo saada syvempää ymmärrystä siitä, miten tietty sanakirjakurssi toimii (nyt tuntuu, että olen hyvin selkeä).
Voidaan väittää, että Bucket ja Node ConcurrentDictionaryssa ovat hyvin yksinkertaisia. Tein jotain vastaavaa, kun yritin luoda sanakirjakurssin. Tavallinen sanakirjaluokka saattaa vaikuttaa yksinkertaisemmalta, mutta todellisuudessa se on monimutkaisempi.
ConcurrentDictionaryssa jokainen solmu on täydellinen luokka. Sanakirjaluokassa Node toteutetaan arvotyypillä, ja kaikki solmut säilytetään suuressa taulukossa, kun taas Bucketia käytetään taulukon indeksointiin. Sitä käytetään myös solmun yksinkertaisen viittauksen sijaan seuraavaan solmuun (koska se ei voi olla rakennetyypin solmu, se ei voi sisältää rakennetyypin solmujäsentä).
Kun sanakirjaa lisätään ja poistetaan, Sanakirjaluokka ei voi yksinkertaisesti luoda uutta solmua, vaan sen täytyy tarkistaa, onko poistetun solmun merkintäindeksi ja käyttää sitä uudelleen. Tai "Count" käytetään uuden solmun sijainnin määrittämiseen taulukossa. Itse asiassa, kun taulukko on täynnä, sanakirjaluokka pakottaa koon muutoksen.
ConcurrentDictionaryssa solmua voidaan ajatella uutena objektina. Solmun poistaminen on yksinkertaisesti sen viitteen poistamista. Uuden solmun lisääminen voi yksinkertaisesti luoda uuden Node-instanssin. Koon muuttaminen on vain ristiriitojen välttämiseksi, mutta se ei ole pakollista.
Joten, jos Sanakirjaluokka käyttää tarkoituksellisesti monimutkaisempia algoritmeja sen käsittelyyn, miten ConcurrentDictionary varmistaa, että se toimii paremmin monisäikeisessä ympäristössä?
Totuus on: kaikkien solmujen sijoittaminen yhteen taulukkoon on nopein tapa varata ja lukea, vaikka tarvitsisimme toisen taulukon seuraamaan, mistä nämä tiedot löytyvät. Näyttää siis siltä, että saman määrän ämpärien käyttäminen kuluttaa enemmän muistia, mutta uusia tietokohteita ei tarvitse uudelleenvarata, uusia objektien synkronointeja ei tarvita, eikä uusia roskien keruuta tapahdu. Koska kaikki on jo valmiina.
Sisällön korvaaminen solmussa ei kuitenkaan ole atominen operaatio, mikä on yksi syy siihen, miksi sen säike on turvaton. Koska solmut ovat kaikki olioita, solmu luodaan aluksi, ja sitten erillinen viite päivitetään osoittamaan siihen (atomioperaatio tässä). Joten lukusäie voi lukea sanakirjan sisällön ilman lukittua, ja lukemisen täytyy olla yksi vanhoista ja uusista arvoista, eikä ole mahdollisuutta lukea epätäydellistä arvoa.
Eli totuus on: jos et tarvitse lukkoa, sanakirjaluokka on nopeampi lukemisessa, koska lukitus hidastaa lukemista.
Tämä artikkeli on käännetty Paulo Zemekin artikkelista "Dictionary + Locking versus ConcurrentDictionary" CodeProjectissa, ja jotkut väitteet muuttuvat ymmärryksen vuoksi.
|