Programmering

Dobbeltsjekket låsing: Smart, men ødelagt

Fra den høyt ansette Elementer av Java Style til sidene til JavaWorld (se Java Tips 67), mange velmenende Java-guruer oppfordrer til bruk av dobbeltsjekket låsing (DCL) -idiom. Det er bare ett problem med det - dette smarte utseendet fungerer kanskje ikke.

Dobbeltsjekket låsing kan være farlig for koden din!

Denne uka JavaWorld fokuserer på farene ved det dobbeltkontrollerte låsediomet. Les mer om hvordan denne tilsynelatende ufarlige snarveien kan forårsake kaos på koden din:
  • "Advarsel! Tråder i en multiprosessorverden," Allen Holub
  • Dobbeltsjekket låsing: Smart, men ødelagt, "Brian Goetz
  • For å snakke mer om dobbeltkontrollert låsing, gå til Allen Holubs Programmeringsteori og praksisdiskusjon

Hva er DCL?

DCL-idiomet ble designet for å støtte lat initialisering, som oppstår når en klasse definerer initialisering av et eid objekt til det faktisk er nødvendig:

klasse SomeClass {privat ressursressurs = null; public Resource getResource () {if (resource == null) resource = new Resource (); returressurs; }} 

Hvorfor vil du utsette initialiseringen? Kanskje skape en Ressurs er en kostbar operasjon, og brukere av SomeClass kanskje ikke faktisk ringe getResource () i et gitt løp. I så fall kan du unngå å lage Ressurs fullstendig. Uansett, SomeClass objektet kan opprettes raskere hvis det ikke også trenger å opprette et Ressurs ved byggetid. Hvis du utsetter noen initialiseringsoperasjoner til en bruker faktisk trenger resultatene, kan det hjelpe programmer å starte raskere.

Hva om du prøver å bruke SomeClass i en flertrådet applikasjon? Deretter oppnås en løpsbetingelse: to tråder kan samtidig utføre testen for å se om ressurs er null og initialiseres som et resultat ressurs to ganger. I et flertrådet miljø, bør du erklære getResource () å være synkronisert.

Dessverre går synkroniserte metoder mye tregere - så mye som 100 ganger tregere - enn vanlige usynkroniserte metoder. En av motivasjonene for lat initialisering er effektivitet, men det ser ut til at for å oppnå raskere programoppstart, må du godta langsommere utførelsestid når programmet starter. Det høres ikke ut som en god avveining.

DCL påstår å gi oss det beste fra begge verdener. Ved hjelp av DCL, getResource () metoden vil se slik ut:

klasse SomeClass {privat ressursressurs = null; offentlig ressurs getResource () {if (ressurs == null) {synkronisert {hvis (ressurs == null) ressurs = ny ressurs (); }} returner ressurs; }} 

Etter den første samtalen til getResource (), ressurs er allerede initialisert, noe som unngår synkroniseringstreffet i den vanligste kodebanen. DCL avstemmer også løpetilstanden ved å sjekke ressurs en annen gang inne i den synkroniserte blokken; som sikrer at bare en tråd vil prøve å initialisere ressurs. DCL virker som en smart optimalisering - men det fungerer ikke.

Møt Java Memory Model

Mer nøyaktig, DCL er ikke garantert å fungere. For å forstå hvorfor, må vi se på forholdet mellom JVM og datamiljøet det kjører på. Spesielt må vi se på Java Memory Model (JMM), definert i kapittel 17 i Java språkspesifikasjon, av Bill Joy, Guy Steele, James Gosling og Gilad Bracha (Addison-Wesley, 2000), som beskriver hvordan Java håndterer samspillet mellom tråder og minne.

I motsetning til de fleste andre språk, definerer Java forholdet til den underliggende maskinvaren gjennom en formell minnemodell som forventes å holde på alle Java-plattformer, noe som muliggjør Java's løfte om "Skriv en gang, kjør hvor som helst." Til sammenligning mangler andre språk som C og C ++ en formell minnemodell; på slike språk arver programmer minnemodellen til maskinvareplattformen som programmet kjører på.

Når du kjører i et synkront (single-threaded) miljø, er et programs interaksjon med minne ganske enkelt, eller i det minste ser det ut som det. Programmer lagrer gjenstander på minnesteder og forventer at de fremdeles vil være der neste gang disse minnestasjonene blir undersøkt.

Faktisk er sannheten ganske annerledes, men en komplisert illusjon opprettholdt av kompilatoren, JVM og maskinvaren, skjuler den for oss. Selv om vi tenker på programmer som kjøres sekvensielt - i den rekkefølgen som er spesifisert av programkoden - skjer det ikke alltid. Kompilatorer, prosessorer og cacher har frihet til å ta alle slags friheter med våre programmer og data, så lenge de ikke påvirker resultatet av beregningen. For eksempel kan kompilatorer generere instruksjoner i en annen rekkefølge enn den åpenbare tolkningen programmet foreslår og lagre variabler i registre i stedet for minne; prosessorer kan utføre instruksjoner parallelt eller ute av drift; og cacher kan variere i hvilken rekkefølge skrivene forplikter seg til hovedminnet. JMM sier at alle disse forskjellige omorganiseringene og optimaliseringene er akseptable, så lenge miljøet opprettholder som om serienummer semantikk - det vil si så lenge du oppnår det samme resultatet som du ville fått hvis instruksjonene ble utført i et strengt sekvensielt miljø.

Kompilatorer, prosessorer og cacher omorganiserer rekkefølgen av programoperasjoner for å oppnå høyere ytelse. De siste årene har vi sett enorme forbedringer i databehandlingen. Mens økte prosessorklokkehastigheter har bidratt vesentlig til høyere ytelse, har økt parallellitet (i form av rørledede og superscalar kjøringsenheter, dynamisk instruksjonsplanlegging og spekulativ kjøring, og sofistikerte hurtignivåer på flere nivåer) også vært en stor bidragsyter. Samtidig har oppgaven med å skrive kompilatorer blitt mye mer komplisert, ettersom kompilatoren må beskytte programmereren mot disse kompleksitetene.

Når du skriver programmer med en tråd, kan du ikke se effekten av disse forskjellige instruksjonene eller omorganiseringen av minnedrift. Imidlertid, med flertrådede programmer, er situasjonen en helt annen - en tråd kan lese minneplasser som en annen tråd har skrevet. Hvis tråd A endrer noen variabler i en bestemt rekkefølge, i fravær av synkronisering, kan det hende at tråd B ikke ser dem i samme rekkefølge - eller kanskje ikke ser dem i det hele tatt, for den saks skyld. Det kan oppstå fordi kompilatoren omorganiserte instruksjonene eller midlertidig lagret en variabel i et register og skrev den ut til minnet senere; eller fordi prosessoren utførte instruksjonene parallelt eller i en annen rekkefølge enn kompilatoren spesifisert; eller fordi instruksjonene var i forskjellige regioner i minnet, og cachen oppdaterte de tilsvarende hovedminneplasseringene i en annen rekkefølge enn den de ble skrevet i. Uansett omstendigheter, er flertrådede programmer iboende mindre forutsigbare, med mindre du eksplisitt sørger for at tråder har et konsistent syn på minne ved å bruke synkronisering.

Hva betyr egentlig synkronisert?

Java behandler hver tråd som om den kjører på sin egen prosessor med sitt eget lokale minne, hver snakker med og synkroniseres med et delt hovedminne. Selv på et enkelt prosessorsystem er den modellen fornuftig på grunn av effekten av minnebuffere og bruken av prosessorregistre for å lagre variabler. Når en tråd endrer et sted i det lokale minnet, bør den endringen til slutt vises i hovedminnet også, og JMM definerer reglene for når JVM må overføre data mellom lokalt og hovedminne. Java-arkitektene innså at en altfor restriktiv minnemodell alvorlig ville undergrave programytelsen. De forsøkte å lage en minnemodell som tillater programmer å fungere godt på moderne datamaskinvare mens de fremdeles gir garantier som gjør at tråder kan samhandle på forutsigbare måter.

Java primære verktøy for gjengivelse av interaksjoner mellom tråder forutsigbart er synkronisert nøkkelord. Mange programmerere tenker på synkronisert strengt når det gjelder å håndheve en gjensidig ekskluderingssemafor (mutex) for å forhindre utførelse av kritiske seksjoner med mer enn en tråd om gangen. Dessverre beskriver den intuisjonen ikke fullt ut hva synkronisert midler.

Semantikken til synkronisert inkluderer faktisk gjensidig utelukkelse av utførelse basert på statusen til en semafor, men de inkluderer også regler om synkroniseringstrådens interaksjon med hovedminnet. Spesielt utløser anskaffelse eller frigjøring av en lås a minnebarriere - en tvungen synkronisering mellom trådens lokale minne og hovedminne. (Noen prosessorer - som Alpha - har eksplisitte maskininstruksjoner for å utføre minnebarrierer.) Når en tråd kommer ut av en synkronisert blokk, utfører den en skrivebarriere - den må skylle ut alle variabler som er modifisert i den blokken til hovedminnet før låsen slippes. Tilsvarende når du går inn i en synkronisert blokk utfører den en lesebarriere - det er som om det lokale minnet er ugyldiggjort, og det må hente alle variabler som det vil bli referert til i blokken fra hovedminnet.

Riktig bruk av synkronisering garanterer at en tråd vil se effekten av en annen på en forutsigbar måte. Bare når trådene A og B synkroniseres på det samme objektet, vil JMM garantere at tråd B ser endringene som er gjort av tråd A, og at endringene er gjort av tråd A inne i synkronisert blokkeringen vises atomisk til tråd B (enten hele blokken utføres eller ingen av den gjør.) Videre sørger JMM for at synkronisert blokker som synkroniseres på det samme objektet, ser ut til å utføres i samme rekkefølge som de gjør i programmet.

Så hva er ødelagt ved DCL?

DCL er avhengig av en usynkronisert bruk av ressurs felt. Det ser ut til å være ufarlig, men det er det ikke. For å se hvorfor, forestill deg at tråden A er inne i synkronisert blokkere, utføre uttalelsen ressurs = ny ressurs (); mens tråd B bare kommer inn getResource (). Vurder effekten på minnet av denne initialiseringen. Minne for det nye Ressurs objektet vil bli tildelt; konstruktøren for Ressurs vil bli kalt, initialisere medlemsfeltene til det nye objektet; og åkeren ressurs av SomeClass vil bli tildelt en referanse til det nylig opprettede objektet.

Men siden tråd B ikke kjøres inne i synkronisert blokkere, kan det hende at disse minneoperasjonene ser i en annen rekkefølge enn den ene tråden A utfører. Det kan være slik at B ser disse hendelsene i følgende rekkefølge (og kompilatoren er også fri til å ombestille instruksjonene slik:) tildel minne, tildel referanse til ressurs, ringkonstruktør. Anta at tråd B kommer etter at minnet er tildelt og ressurs feltet er satt, men før konstruktøren kalles. Det ser det ressurs er ikke null, hopper over synkronisert blokk, og returnerer en referanse til en delvis konstruert Ressurs! Unødvendig å si er resultatet verken forventet eller ønsket.

Når dette eksemplet presenteres, er mange mennesker skeptiske til å begynne med. Mange svært intelligente programmerere har prøvd å fikse DCL slik at den fungerer, men ingen av disse antatt faste versjonene fungerer heller. Det bør bemerkes at DCL faktisk kan fungere på noen versjoner av noen JVM-er - så få JVM-er som faktisk implementerer JMM-en riktig. Imidlertid vil du ikke at riktigheten av programmene dine skal stole på implementeringsdetaljer - spesielt feil - spesifikk for den spesifikke versjonen av den spesifikke JVM-en du bruker.

Andre samtidige farer er innebygd i DCL - og i enhver usynkronisert referanse til minne skrevet av en annen tråd, til og med ufarlig. Anta at tråd A har fullført initialiseringen av Ressurs og går ut av synkronisert blokker når tråd B kommer inn getResource (). Nå er det Ressurs er fullstendig initialisert, og tråd A skyller sitt lokale minne ut til hovedminnet. De ressursFeltene kan henvise til andre objekter som er lagret i minnet gjennom medlemsfeltene, som også vil skylles ut. Mens tråd B kan se en gyldig referanse til det nyopprettede Ressurs, fordi den ikke utførte en lesebarriere, kunne den fremdeles se foreldede verdier på ressurssine medlemsfelt.

Flyktig betyr heller ikke hva du synes

Et ofte foreslått ikke-fiks er å erklære ressurs innen SomeClass som flyktige. Imidlertid, mens JMM forhindrer at skriv til flyktige variabler blir omorganisert med hensyn til hverandre og sørger for at de skylles til hovedminnet umiddelbart, tillater det fortsatt lesing og skriving av flyktige variabler som skal omorganiseres med hensyn til ikke-flyktige leser og skriver. Det betyr - med mindre alle Ressurs felt er flyktige også - tråd B kan fremdeles oppfatte konstruktørens effekt som skjer etter ressurs er satt til å referere til det nyopprettede Ressurs.

Alternativer til DCL

Den mest effektive måten å fikse DCL-uttrykket er å unngå det. Den enkleste måten å unngå det, er selvfølgelig å bruke synkronisering. Når en variabel skrevet av en tråd blir lest av en annen, bør du bruke synkronisering for å garantere at modifikasjoner er synlige for andre tråder på en forutsigbar måte.

Et annet alternativ for å unngå problemene med DCL er å slippe lat initialisering og i stedet bruke ivrig initialisering. Snarere enn å forsinke initialiseringen av ressurs Før den brukes første gang, initialiser den under konstruksjonen. Klasselaster, som synkroniserer på klassene ' Klasse objekt, utfører statiske initialiseringsblokker ved initialisering av klassen. Det betyr at effekten av statiske initialiserere automatisk blir synlig for alle tråder så snart klassen lastes inn.

$config[zx-auto] not found$config[zx-overlay] not found