Før .NET 4.0, hvis vi måtte bruke Dictionary-klassen i et multitrådet miljø, hadde vi ikke annet valg enn å implementere trådsynkronisering selv for å holde trådene trygge.
Mange utviklere har definitivt implementert en lignende trådsikker løsning, enten ved å lage en helt ny trådsikker ordboktype, eller ganske enkelt ved å kapsle inn et ordbokobjekt i en klasse og legge til en låsemekanisme for alle metoder, som vi kaller "Dictionary + Locks".
Men nå har vi ConcurrentDictionary. Den trådsikre beskrivelsen av Dictionary-klassedokumentasjonen på MSDN sier at hvis du trenger å bruke en trådsikker implementering, bør du bruke ConcurrentDictionary.
Så, nå som vi har en trådsikker ordbokklasse, trenger vi ikke lenger å implementere den selv. Flott, ikke sant?
Opprinnelsen til problemet
Faktisk har jeg bare brukt CocurrentDictionary én gang før, i testen min for å teste responsen. Fordi den gjorde det bra på prøvene, byttet jeg den umiddelbart ut med klassen min, gjorde noen tester, og så gikk noe galt.
Så, hva gikk galt? Sa du ikke trådsikker?
Etter mer testing fant jeg roten til problemet. Men av en eller annen grunn inkluderer ikke MSDN versjon 4.0 en beskrivelse av signaturen for GetOrAdd-metoden som krever at man sender en delegattypeparameter. Etter å ha sett på versjon 4.5, fant jeg denne merknaden:
Hvis du kaller GetOrAdd samtidig på forskjellige tråder, kan addValueFactory bli kalt flere ganger, men nøkkel/verdi-paret kan hende ikke legges til i ordboken for hvert kall. Det er problemet jeg støtte på. Fordi det ikke var beskrevet tidligere i dokumentasjonen, måtte jeg gjøre mer testing for å bekrefte problemet. Selvfølgelig er problemet jeg støter på relatert til bruken min, generelt bruker jeg ordboktypen for å cache noen data:
Disse dataene er veldig trege å lage; Disse dataene kan bare opprettes én gang, fordi den andre opprettelsen vil utløse et unntak, eller flere opprettelser kan føre til ressurslekkasje, osv.; Jeg hadde et problem med den andre tilstanden. Hvis begge trådene oppdager at en databit ikke eksisterer, vil den bli opprettet én gang, men bare ett resultat vil bli lagret med suksess. Hva med den andre?
Hvis prosessen du lager gir et unntak, kan du bruke try: Catch (ikke elegant nok, men det løser problemet). Men hva om en ressurs skapes og ikke resirkuleres?
Du kan si at et objekt er laget og vil bli samlet inn i søppelfeltet hvis det ikke lenger refereres til i det. Men vurder hva som ville skje hvis situasjonen beskrevet nedenfor inntraff:
Generer kode dynamisk med Emit. Jeg brukte denne tilnærmingen i et Remote-rammeverk og la alle implementasjonene i en assembler som ikke kunne resirkuleres. Hvis en type opprettes to ganger, vil den andre alltid eksistere, selv om den aldri har blitt brukt. Lag en tråd direkte eller indirekte. For eksempel må vi bygge en komponent som bruker en proprietær tråd for å behandle asynkrone meldinger og er avhengig av rekkefølgen de mottas i. Når komponenten instansieres, opprettes en tråd. Når denne komponentinstansen ødelegges, avsluttes også tråden. Men hvis vi sletter referansen til objektet etter å ha ødelagt komponenten, men tråden slutter ikke av en eller annen grunn og beholder referansen til objektet. Hvis tråden ikke dør, vil ikke objektet bli resirkulert heller. Utfør en P/Invoke-operasjon. Krev at antall lukkede tider for mottatt håndtak må være det samme som antall åpninger. Selvfølgelig finnes det mange lignende situasjoner. For eksempel vil et ordboksobjekt holde en tilkobling til en tjeneste på en ekstern server, som bare kan forespørres én gang, og hvis det forespørres en gang til, vil den andre tjenesten tro at det har oppstått en feil og logge det i loggen. (I et firma jeg jobbet for, var det noen juridiske sanksjoner for denne tilstanden.) ) Så det er lett å se at Dictionary + Locks ikke kan erstattes raskt med ConcurrentDictionary, selv om dokumentasjonen sier at det er trådsikkert.
Analyser problemet
Forstår du fortsatt ikke?
Det er sant at dette problemet kanskje ikke oppstår under Dictionary + Locks-tilnærmingen. Siden dette avhenger av den spesifikke implementeringen, la oss se på dette enkle eksempelet:
I koden ovenfor holder vi låsen på ordboken før vi begynner å spørre om nøkkelverdien. Hvis det angitte nøkkel-verdi-paret ikke eksisterer, vil det bli opprettet direkte. Samtidig, fordi vi allerede har lås på den ordboken, kan vi legge til nøkkel-verdi-par direkte i ordboken. Deretter frigjør du ordbokslåsen, og returnerer resultatet. Hvis to tråder spør etter samme nøkkelverdi samtidig, vil den første tråden som får ordbokslåsen fullføre opprettelsen av objektet, og den andre tråden vil vente på at denne opprettelsen er fullført og få resultatet av den opprettede nøkkelverdien etter å ha fått ordbokslåsen.
Det er bra, ikke sant?
Det er det virkelig ikke! Jeg tror ikke at det å lage et objekt parallelt på denne måten, hvor bare ett brukes til slutt, ikke skaper problemet jeg har beskrevet.
Situasjonen og problemet jeg prøver å utdype, er kanskje ikke alltid reproduserbart; i et parallelt miljø kan vi enkelt lage to objekter og deretter forkaste ett. Så, hvordan sammenligner vi egentlig Dictionary + Locks og ConcurrentDictionary?
Svaret er: det avhenger av strategien for låsbruk og hvordan ordboken brukes.
Spill 1: Lag det samme objektet parallelt
Først, la oss anta at et objekt kan opprettes to ganger, så hva skjer hvis to tråder oppretter dette objektet samtidig?
For det andre, hvor lang tid bruker vi på lignende kreasjoner?
Vi kan enkelt bygge et eksempel der det tar 10 sekunder å instansiere et objekt. Når den første tråden oppretter objektet 5 sekunder senere, prøver den andre implementasjonen å kalle GetOrAdd-metoden for å hente objektet, og siden objektet fortsatt ikke eksisterer, begynner den også å lage objektet.
I denne tilstanden har vi 2 CPU-er som jobber parallelt i 5 sekunder, og når den første tråden er ferdig, må den andre tråden fortsatt kjøre i 5 sekunder for å fullføre konstruksjonen av objektet. Når den andre tråden er ferdig med å bygge objektet, oppdager den at et objekt allerede eksisterer, og velger å bruke det eksisterende objektet og forkaste det nyopprettede objektet direkte.
Hvis den andre tråden bare venter og den andre CPU-en gjør noe annet arbeid (kjører andre tråder eller applikasjoner, sparer litt strøm), vil den få ønsket objekt etter 5 sekunder i stedet for 10 sekunder.
Så, under disse forholdene, vinner Dictionary + Locks et lite spill.
Spill 2: Besøk ulike objekter parallelt
Nei, situasjonen du nevnte stemmer ikke i det hele tatt!
Vel, eksempelet ovenfor er litt spesielt, men det beskriver problemet, det er bare at denne bruken er mer ekstrem. Så, tenk på hva som skjer hvis den første tråden lager et objekt, og den andre tråden trenger tilgang til et annet nøkkelverdiobjekt, og det nøkkelverdiobjektet allerede eksisterer?
I ConcurrentDictionary gjør det låsfrie designet lesingen veldig rask fordi det ikke er noen lås på lesingen. I tilfellet Dictionary + Locks vil leseoperasjonen være gjensidig utelukkende, selv om det er en helt annen nøkkel, noe som åpenbart vil bremse leseoperasjonen.
På denne måten trakk ConcurrentDictionary tilbake et spill.
Merk: Her mener jeg at du forstår flere konsepter som Bøtte/Node/Oppføring i ordbokklassen, hvis ikke, anbefales det å lese Ofir Makmals artikkel "Understanding Generic Dictionary in-depth", som forklarer disse konseptene godt.
Det tredje spillet i spillet: les mer og skriv enkeltspill
Hva skjer hvis du bruker Multiple Readers og Single Writer i stedet for full lås på ordboken i Dictionary + Locks?
Hvis en tråd lager et objekt og holder en oppgraderbar lås til objektet er opprettet, oppgraderes låsen til en skrivelås, og leseoperasjonen kan utføres parallelt.
Vi kan også løse problemet ved å la en leseoperasjon stå i ro i 10 sekunder. Men hvis det er langt flere lesinger enn skrivinger, vil vi finne at ConcurrentDictionary fortsatt er rask fordi den implementerer låsfrie modus-lesinger.
Å bruke ReaderWriterLockSlim for ordbøker gjør lesingen verre, og det anbefales generelt å bruke Full Lock for ordbøker i stedet for ReaderWriterLockSlim.
Så, under disse forholdene, vant ConcurrentDictionary et nytt spill.
Merk: Jeg har dekket YieldReaderWriterLock- og YieldReaderWriterLockSlim-klassene i tidligere artikler. Ved å bruke denne lese-skrive-låsen har hastigheten blitt betydelig forbedret (nå utviklet til SpinReaderWriterLockSlim) og gjør det mulig å utføre flere lesinger parallelt med liten eller ingen effekt. Selv om jeg fortsatt bruker denne metoden, vil en låsløs ConcurrentDictionary åpenbart være raskere.
Spill 4: Legg til flere nøkkel-verdi-par
Oppgjøret er ikke over ennå.
Hva om vi har flere nøkkelverdier å legge til, og alle ikke kolliderer og tildeles forskjellige bøtter?
Først var dette spørsmålet nysgjerrig, men jeg tok en test som ikke helt passet. Jeg brukte en ordbok av typen <int, int> og objektets konstruksjonsfabrikk returnerte et negativt resultat direkte som nøkkel.
Jeg forventet at ConcurrentDictionary skulle være den raskeste, men det viste seg å være den tregeste. Dictionary + Locks, derimot, presterer raskere. Hvorfor er det slik?
Dette er fordi ConcurrentDictionary allokerer noder og plasserer dem i forskjellige bøtter, noe som er optimalisert for å møte det låsfrie designet for leseoperasjoner. Men når man legger til nøkkelverdielementer, blir prosessen med å opprette en node kostbar.
Selv under parallelle forhold tar allokering av en nodelås fortsatt mer tid enn å bruke en full lås.
Så, Dictionary + Locks vinner dette spillet.
Å spille det femte spillet: Hyppigheten av leseoperasjoner er høyere
Ærlig talt, hvis vi hadde en delegat som raskt kunne instansiere objekter, ville vi ikke trengt en ordbok. Vi kan ringe delegaten direkte for å hente objektet, ikke sant?
Faktisk er svaret også at det avhenger av situasjonen.
Tenk deg at nøkkeltypen er en streng og inneholder stikart for ulike sider på webserveren, og den tilsvarende verdien er en objekttype som inneholder posten for de nåværende brukerne som har tilgang til siden og antall besøk til siden serveren startet.
Å lage et slikt objekt skjer nesten umiddelbart. Og etter det trenger du ikke å lage et nytt objekt, bare endre verdiene som er lagret i det. Så det er mulig å tillate opprettelse av en vei to ganger til bare én instans brukes. Men fordi ConcurrentDictionary allokerer noderessurser saktere, vil bruk av Dictionary + Locks føre til raskere opprettelsestider.
Så, med dette eksempelet er veldig spesielt, ser vi også at Dictionary + Locks fungerer bedre under denne betingelsen, og tar mindre tid.
Selv om nodeallokeringen i ConcurrentDictionary er tregere, prøvde jeg ikke å legge inn 100 millioner dataelementer for å teste tiden. For det tar åpenbart mye tid.
Men i de fleste tilfeller, når et dataelement er opprettet, blir det alltid lest. Hvordan innholdet i dataelementet endres, er en annen sak. Så det spiller ingen rolle hvor mange millisekunder det tar å lage et dataelement, fordi lesingene er raskere (bare noen millisekunder raskere), men lesingene skjer oftere.
Så, ConcurrentDictionary vant spillet.
Spill 6: Lag objekter som bruker ulike tider
Hva skjer hvis tiden det tar å lage ulike dataelementer varierer?
Lag flere dataelementer som bruker ulike tider og legg dem til i ordboken parallelt. Dette er det sterkeste punktet til ConcurrentDictionary.
ConcurrentDictionary bruker flere ulike låsemekanismer for å tillate at dataelementer legges til samtidig, men logikk som å bestemme hvilken lås man skal bruke, be om en lås for å endre størrelsen på bøtten, osv., hjelper ikke. Hastigheten dataelementer legges i en bøtte med er maskinrask. Det som virkelig gjør ConcurrentDictionary vinnende, er evnen til å lage objekter parallelt.
Men vi kan faktisk gjøre det samme. Hvis vi ikke bryr oss om vi lager objekter parallelt, eller om noen av dem er forkastet, kan vi legge til en lås for å oppdage om dataelementet allerede eksisterer, så frigjøre låsen, opprette dataelementet, trykke på det for å få låsen, sjekke igjen om dataelementet eksisterer, og hvis ikke, legge til dataelementet. Koden kan se omtrent slik ut:
* Merk at jeg bruker en ordbok av typen <int, int>.
I den enkle strukturen ovenfor fungerer Dictionary + Locks nesten like bra som ConcurrentDictionary når man oppretter og legger til dataelementer under parallelle forhold. Men det finnes også det samme problemet, hvor noen verdier kan bli generert, men aldri brukt.
konklusjon
Så, finnes det en konklusjon?
Akkurat nå er det fortsatt noen:
Alle ordbokklasser er veldig raske. Selv om jeg har skapt millioner av data, går det fortsatt raskt. Vanligvis lager vi bare et lite antall dataelementer, og det er noen tidsintervaller mellom lesingene, så vi merker vanligvis ikke tidsoverhead ved å lese dataelementer. Hvis det samme objektet ikke kan opprettes to ganger, ikke bruk ConcurrentDictionary. Hvis du virkelig er bekymret for ytelsen, kan Dictionary + Locks fortsatt være en god løsning. En viktig faktor er antall dataelementer som legges til og fjernes. Men hvis det er mange leseoperasjoner, er det tregere enn ConcurrentDictionary. Selv om jeg ikke introduserte det, er det faktisk mer frihet til å bruke Dictionary + Locks-ordningen. For eksempel kan du låse én gang, legge til flere dataelementer, slette flere dataelementer, eller spørre flere ganger, osv., og så oppheve låsen. Generelt bør du unngå å bruke ReaderWriterLockSlim hvis det er langt flere lesninger enn skriver. Ordboktyper er allerede mye raskere enn å få en leselås i en lese-skrive-lås. Selvfølgelig avhenger dette også av tiden det tar å lage et objekt i en lås. Så jeg synes eksemplene som gis er litt ekstreme, men de viser at det ikke alltid er den beste løsningen å bruke ConcurrentDictionary.
Kjenn forskjellen
Jeg skrev denne artikkelen med intensjon om å finne en bedre løsning.
Jeg prøver allerede å få en dypere forståelse av hvordan et spesifikt ordbokkurs fungerer (nå føler jeg at jeg er veldig tydelig).
Man kan hevde at Bucket og Node i ConcurrentDictionary er veldig enkle. Jeg gjorde noe lignende da jeg prøvde å lage en ordbokklasse. Den vanlige ordbokklassen kan virke enklere, men i realiteten er den mer kompleks.
I ConcurrentDictionary er hver node en komplett klasse. I Dictionary-klassen er Node implementert med en verditype, og alle noder holdes i et stort array, mens Bucket brukes til å indeksere arrayet. Den brukes også i stedet for en Nodes enkle referanse til dens neste Node (tross alt, som en Node av en struct-type kan den ikke inneholde et Node-medlem av en struct-type).
Når man legger til og fjerner en ordbok, kan ikke Dictionary-klassen bare opprette en ny node, den må sjekke om det finnes en indeks som markerer en node som er slettet, og deretter gjenbruke den. Eller «Count» brukes for å finne posisjonen til den nye Noden i arrayet. Faktisk, når arrayet er fullt, tvinger Dictionary-klassen frem en størrelsesendring.
For ConcurrentDictionary kan en node betraktes som et nytt objekt. Å fjerne en node er rett og slett å fjerne dens referanse. Å legge til en ny Node kan enkelt opprette en ny Node-instans. Å endre størrelsen er kun for å unngå konflikter, men det er ikke obligatorisk.
Så, hvis Dictionary-klassen bevisst bruker mer komplekse algoritmer for å håndtere den, hvordan skal ConcurrentDictionary sikre at den presterer bedre i et multitrådet miljø?
Sannheten er: å samle alle nodene i ett array er den raskeste måten å allokere og lese på, selv om vi trenger et annet array for å holde oversikt over hvor vi finner disse dataelementene. Så det ser ut til at det å ha samme antall bøtter vil bruke mer minne, men de nye dataelementene trenger ikke å omfordeles, ingen nye objektsynkroniseringer er nødvendige, og ny søppelsamling skjer ikke. Fordi alt allerede er på plass.
Å erstatte innhold i en node er imidlertid ikke en atomoperasjon, noe som er en av faktorene som gjør tråden usikker. Siden alle noder er objekter, opprettes en node i utgangspunktet, og deretter oppdateres en separat referanse for å peke på den (atomoperasjon her). Så lesetråden kan lese ordbokinnholdet uten lås, og lesingen må være en av de gamle og nye verdiene, og det er ingen sjanse for å lese en ufullstendig verdi.
Så, sannheten er: hvis du ikke trenger en lås, er Dictionary-klassen raskere på lesing, fordi det er låsen som bremser lesingen.
Denne artikkelen er oversatt fra Paulo Zemeks artikkel "Dictionary + Locking versus ConcurrentDictionary" på CodeProject, og noen setninger vil endres av forståelsesgrunner.
|