Programmering

Dobbeltkontrolleret låsning: Smart, men brudt

Fra de højt ansete Elementer i Java Style til siderne i JavaWorld (se Java Tip 67), mange velmenende Java-guruer tilskynder til brugen af ​​dobbeltkontrolleret låsning (DCL). Der er kun et problem med det - dette kloge udtryk fungerer muligvis ikke.

Dobbeltkontrolleret låsning kan være farlig for din kode!

Denne uge JavaWorld fokuserer på farerne ved det dobbeltkontrollerede låseudtryk. Læs mere om, hvordan denne tilsyneladende harmløse genvej kan skabe kaos på din kode:
  • "Advarsel! Trådning i en multiprocessorverden," Allen Holub
  • Dobbelttjekket låsning: Smart, men brudt, "Brian Goetz
  • Hvis du vil tale mere om dobbeltkontrolleret låsning, skal du gå til Allen Holubs Programmeringsteori & praksis diskussion

Hvad er DCL?

DCL-idiomet blev designet til at understøtte doven initialisering, som opstår, når en klasse definerer initialisering af et ejet objekt, indtil det faktisk er nødvendigt:

klasse SomeClass {privat ressource ressource = null; offentlig ressource getResource () {hvis (ressource == null) ressource = ny ressource (); returressource }} 

Hvorfor vil du udsætte initialisering? Måske skabe en Ressource er en dyr handling, og brugere af SomeClass måske ikke faktisk ringe getResource () i et givet løb. I så fald kan du undgå at oprette Ressource helt. Uanset hvad SomeClass objekt kan oprettes hurtigere, hvis det ikke også behøver at oprette et Ressource ved anlægstid. Forsinkelse af nogle initialiseringshandlinger, indtil en bruger faktisk har brug for deres resultater, kan hjælpe programmer med at starte hurtigere.

Hvad hvis du prøver at bruge SomeClass i et multitrådet program? Derefter resulterer en løbetilstand: to tråde kunne udføre testen samtidigt for at se om ressource er nul og initialiseres som et resultat ressource to gange. I et multitrådet miljø skal du erklære getResource () at være synkroniseret.

Desværre kører synkroniserede metoder meget langsommere - så meget som 100 gange langsommere - end almindelige usynkroniserede metoder. En af motiverne til doven initialisering er effektivitet, men det ser ud til, at for at opnå hurtigere programstart skal du acceptere langsommere udførelsestid, når programmet starter. Det lyder ikke som en god kompromis.

DCL foregiver at give os det bedste fra begge verdener. Brug af DCL, getResource () metode ville se sådan ud:

klasse SomeClass {privat ressource ressource = null; offentlig ressource getResource () {hvis (ressource == null) {synkroniseret {hvis (ressource == null) ressource = ny ressource (); }} returnere ressource; }} 

Efter det første opkald til getResource (), ressource er allerede initialiseret, hvilket undgår synkroniseringshit i den mest almindelige kodesti. DCL afstemmer også løbetilstanden ved at kontrollere ressource en anden gang inde i den synkroniserede blok; der sikrer, at kun en tråd vil forsøge at initialisere ressource. DCL virker som en smart optimering - men det virker ikke.

Mød Java Memory Model

Mere nøjagtigt fungerer DCL ikke garanteret. For at forstå hvorfor skal vi se på forholdet mellem JVM og det computermiljø, som det kører på. Især skal vi se på Java Memory Model (JMM), defineret i kapitel 17 i Java-sprogspecifikationaf Bill Joy, Guy Steele, James Gosling og Gilad Bracha (Addison-Wesley, 2000), der beskriver, hvordan Java håndterer interaktionen mellem tråde og hukommelse.

I modsætning til de fleste andre sprog definerer Java sit forhold til den underliggende hardware gennem en formel hukommelsesmodel, der forventes at holde på alle Java-platforme, hvilket muliggør Java's løfte om "Skriv en gang, kør hvor som helst." Til sammenligning mangler andre sprog som C og C ++ en formel hukommelsesmodel; på sådanne sprog arver programmer hukommelsesmodellen for den hardwareplatform, som programmet kører på.

Når du kører i et synkront miljø (en tråd), er et programs interaktion med hukommelsen ret simpelt, eller i det mindste ser det ud til det. Programmer gemmer genstande på hukommelsesplaceringer og forventer, at de stadig vil være der, næste gang disse hukommelsesplaceringer undersøges.

Faktisk er sandheden en helt anden, men en kompliceret illusion, der opretholdes af compileren, JVM og hardwaren, skjuler den for os. Selvom vi tænker på programmer, der udføres sekventielt - i den rækkefølge, der er angivet af programkoden - sker det ikke altid. Compilere, processorer og cacher kan frit tage alle mulige friheder med vores programmer og data, så længe de ikke påvirker resultatet af beregningen. For eksempel kan kompilatorer generere instruktioner i en anden rækkefølge end den åbenlyse fortolkning, som programmet foreslår, og gemme variabler i registre i stedet for hukommelse; processorer kan udføre instruktioner parallelt eller ude af drift; og caches kan variere i rækkefølge, som skrivning forpligter sig til hovedhukommelsen. JMM siger, at alle disse forskellige omordninger og optimeringer er acceptable, så længe miljøet opretholder som-hvis-seriel semantik - det vil sige så længe du opnår det samme resultat, som du ville få, hvis instruktionerne blev udført i et strengt sekventielt miljø.

Compilere, processorer og cacher omarrangerer rækkefølgen af ​​programhandlinger for at opnå højere ydeevne. I de senere år har vi set enorme forbedringer i computerens ydeevne. Mens øgede processorurhastigheder har bidraget væsentligt til højere ydeevne, har øget parallelisme (i form af pipelined og superscalar eksekveringsenheder, dynamisk instruktionsplanlægning og spekulativ udførelse og sofistikerede hukommelsescacher på flere niveauer) også været en stor bidragyder. Samtidig er opgaven med at skrive kompilatorer vokset meget mere kompliceret, da compileren skal beskytte programmøren mod disse kompleksiteter.

Når du skriver programmer med enkelt gevind, kan du ikke se virkningerne af disse forskellige instruktioner eller hukommelsesoperationer. Men med multitrådede programmer er situationen en helt anden - en tråd kan læse hukommelsesplaceringer, som en anden tråd har skrevet. Hvis tråd A ændrer nogle variabler i en bestemt rækkefølge, i mangel af synkronisering, kan tråd B muligvis ikke se dem i samme rækkefølge - eller måske ikke se dem overhovedet, for den sags skyld. Det kan resultere, fordi compileren omarrangerede instruktionerne eller midlertidigt lagrede en variabel i et register og skrev den ud til hukommelsen senere; eller fordi processoren udførte instruktionerne i parallel eller i en anden rækkefølge end den specificerede compiler; eller fordi instruktionerne var i forskellige hukommelsesregioner, og cachen opdaterede de tilsvarende hovedhukommelsesplaceringer i en anden rækkefølge end den, hvori de blev skrevet. Uanset omstændighederne er flertrådede programmer i sagens natur mindre forudsigelige, medmindre du udtrykkeligt sikrer, at tråde har et ensartet syn på hukommelsen ved hjælp af synkronisering.

Hvad betyder synkroniseret virkelig?

Java behandler hver tråd som om den kører på sin egen processor med sin egen lokale hukommelse, hver taler med og synkroniseres med en delt hovedhukommelse. Selv på et enkelt processorsystem giver denne model mening på grund af virkningerne af hukommelsescacher og brugen af ​​processorregistre til at gemme variabler. Når en tråd ændrer en placering i sin lokale hukommelse, skal denne ændring til sidst også vises i hovedhukommelsen, og JMM definerer reglerne for, hvornår JVM skal overføre data mellem lokal og hovedhukommelse. Java-arkitekterne indså, at en alt for restriktiv hukommelsesmodel alvorligt ville underminere programmets ydeevne. De forsøgte at skabe en hukommelsesmodel, der gjorde det muligt for programmer at fungere godt på moderne computerhardware, mens de stadig leverede garantier, der gjorde det muligt for tråde at interagere på forudsigelige måder.

Java primære værktøj til gengivelse af interaktioner mellem tråde forudsigeligt er synkroniseret nøgleord. Mange programmører tænker på synkroniseret strengt med hensyn til håndhævelse af en gensidig udelukkelsessemafor (mutex) for at forhindre udførelse af kritiske sektioner med mere end en tråd ad gangen. Desværre beskriver denne intuition ikke fuldt ud hvad synkroniseret midler.

Semantikken i synkroniseret inkluderer faktisk gensidig udelukkelse af udførelse baseret på en semafores status, men de inkluderer også regler om synkroniseringstrådens interaktion med hovedhukommelsen. Især erhvervelse eller frigivelse af en lås udløser a hukommelsesbarriere - en tvungen synkronisering mellem trådens lokale hukommelse og hovedhukommelse. (Nogle processorer - som Alpha - har eksplicitte maskininstruktioner til at udføre hukommelsesbarrierer.) Når en tråd kommer ud af en synkroniseret blok, udfører den en skrivebarriere - den skal skylle alle variabler, der er modificeret i denne blok, ud til hovedhukommelsen, før låsen frigøres. Tilsvarende når du indtaster en synkroniseret blok udfører den en læsebarriere - det er som om den lokale hukommelse er blevet ugyldiggjort, og den skal hente alle variabler, der vil blive henvist til i blokken fra hovedhukommelsen.

Korrekt brug af synkronisering garanterer, at en tråd vil se virkningerne af en anden på en forudsigelig måde. Kun når tråd A og B synkroniseres på det samme objekt, garanterer JMM, at tråd B ser de ændringer, der er foretaget af tråd A, og at ændringer foretaget af tråd A inde i synkroniseret blok vises atomisk til tråd B (enten hele blokken udføres, eller ingen af ​​den gør.) Desuden sikrer JMM det synkroniseret blokke, der synkroniseres på det samme objekt, ser ud til at udføre i samme rækkefølge som de gør i programmet.

Så hvad er ødelagt ved DCL?

DCL er afhængig af en usynkroniseret brug af ressource Mark. Det ser ud til at være harmløst, men det er det ikke. For at se hvorfor, forestil dig at tråd A er inde i synkroniseret blokere, udføre erklæringen ressource = ny ressource (); mens tråd B lige er ved at komme ind getResource (). Overvej effekten på hukommelsen af ​​denne initialisering. Hukommelse til det nye Ressource objekt tildeles konstruktøren til Ressource kaldes, initialiserer medlemsfelterne for det nye objekt og marken ressource af SomeClass tildeles en reference til det nyoprettede objekt.

Men da tråd B ikke udføres inde i a synkroniseret blokere det muligvis disse hukommelsesoperationer i en anden rækkefølge end den ene tråd A udfører. Det kan være tilfældet, at B ser disse begivenheder i følgende rækkefølge (og kompilatoren kan også frit ombestille instruktionerne som denne): tildel hukommelse, tildel reference til ressource, kalder konstruktør. Antag at tråd B kommer efter at hukommelsen er tildelt og ressource felt er indstillet, men før konstruktøren kaldes. Det ser det ressource er ikke nul, springer over synkroniseret blokere og returnerer en henvisning til en delvist konstrueret Ressource! Det er overflødigt at sige, at resultatet hverken forventes eller ønskes.

Når dette eksempel præsenteres, er mange mennesker skeptiske i starten. Mange meget intelligente programmører har forsøgt at rette DCL, så det fungerer, men ingen af ​​disse angiveligt faste versioner fungerer heller ikke. Det skal bemærkes, at DCL faktisk kan arbejde på nogle versioner af nogle JVM'er - så få JVM'er faktisk implementerer JMM korrekt. Du ønsker imidlertid ikke, at rigtigheden af ​​dine programmer skal stole på implementeringsoplysninger - især fejl - specifikt for den bestemte version af den bestemte JVM, du bruger.

Andre samtidige farer er indlejret i DCL - og i enhver usynkroniseret henvisning til hukommelse skrevet af en anden tråd, selv uskadelig udseende læser. Antag, at tråd A har afsluttet initialiseringen af Ressource og går ud af synkroniseret blokere, når tråd B kommer ind getResource (). Nu er det Ressource er fuldt initialiseret, og tråd A skyller sin lokale hukommelse ud til hovedhukommelsen. Det ressourceFelter kan henvise til andre objekter, der er gemt i hukommelsen gennem dens medlemsfelter, som også skylles ud. Mens tråd B muligvis kan se en gyldig reference til det nyoprettede Ressource, fordi den ikke udførte en læsebarriere, kunne den stadig se uaktuelle værdier på ressource's medlemsfelter.

Flygtig betyder heller ikke, hvad du synes

Et almindeligt foreslået nonfix er at erklære ressource felt af SomeClass som flygtige. Selvom JMM forhindrer, at skriv til flygtige variabler omarrangeres i forhold til hinanden og sikrer, at de straks skylles til hovedhukommelsen, tillader det stadig læsning og skrivning af flygtige variabler, der skal omordnes med hensyn til ikke-flygtige læser og skriver. Det betyder - medmindre alle Ressource felter er flygtige også - tråd B kan stadig opleve konstruktørens virkning som at ske efter ressource er indstillet til at henvise til det nyoprettede Ressource.

Alternativer til DCL

Den mest effektive måde at rette DCL-idiomet på er at undgå det. Den enkleste måde at undgå det på er selvfølgelig at bruge synkronisering. Når en variabel skrevet af en tråd læses af en anden, skal du bruge synkronisering for at garantere, at ændringer er synlige for andre tråde på en forudsigelig måde.

En anden mulighed for at undgå problemer med DCL er at droppe doven initialisering og i stedet bruge ivrig initialisering. I stedet for at forsinke initialiseringen af ressource indtil den først bruges, initialiser den ved konstruktionen. Klasselæsseren, der synkroniseres på klasserne ' Klasse objekt, udfører statiske initialiseringsblokke ved klasseinitialiseringstid. Det betyder, at effekten af ​​statiske initialiserere automatisk er synlig for alle tråde, så snart klassen indlæses.