Programiranje

Dvakrat preverjeno zaklepanje: pametno, vendar pokvarjeno

Od zelo cenjenih Elementi sloga Java na strani JavaWorld (glejte nasvet Java 67), mnogi dobronamerni guruji Java spodbujajo uporabo idioma dvojno preverjenega zaklepanja (DCL). Zanj je samo ena težava - ta pametni navidezni frazem morda ne bo deloval.

Dvakrat preverjeno zaklepanje je lahko nevarno za vašo kodo!

Ta teden JavaWorld se osredotoča na nevarnosti dvojno preverjenega idioma zaklepanja. Preberite več o tem, kako lahko ta na videz neškodljiva bližnjica uniči vašo kodo:
  • "Opozorilo! Niti v večprocesorskem svetu," Allen Holub
  • Dvakrat preverjeno zaklepanje: pametno, a pokvarjeno, "Brian Goetz
  • Če želite več govoriti o dvojno preverjenem zaklepanju, obiščite Allena Holuba Razprava o teoriji in praksi programiranja

Kaj je DCL?

Idiom DCL je bil zasnovan tako, da podpira leno inicializacijo, ki se zgodi, ko razred odloži inicializacijo predmeta v lasti, dokler ni dejansko potreben:

razred SomeClass {zasebni vir virov = null; javni vir getResource () {if (resource == null) resource = new Resource (); povratni vir; }} 

Zakaj želite odložiti inicializacijo? Morda ustvarjanje Vir je draga operacija in uporabniki SomeClass morda dejansko ne pokliče getResource () v danem teku. V tem primeru se lahko izognete ustvarjanju Vir popolnoma. Ne glede na to SomeClass Objekt je mogoče ustvariti hitreje, če ni treba ustvariti tudi Vir v času gradnje. Zakasnitev nekaterih operacij inicializacije, dokler uporabnik dejansko ne potrebuje njihovih rezultatov, lahko pomaga pri hitrejšem zagonu programov.

Kaj če poskusite uporabiti SomeClass v večnitni aplikaciji? Nato se izkaže pogoj dirke: dve niti lahko istočasno izvedeta test, da ugotovita, ali vir je nična in se zato inicializira vir dvakrat. V večnitnem okolju bi morali prijaviti getResource () biti sinhronizirano.

Na žalost sinhronizirane metode delujejo veliko počasneje - kar 100-krat počasneje - kot običajne nesinhronizirane metode. Ena od motivacij za leno inicializacijo je učinkovitost, vendar se zdi, da morate za hitrejši zagon programa sprejeti počasnejši čas izvajanja, ko se program zažene. To ne zveni kot velik kompromis.

DCL naj bi nam ponudil najboljše iz obeh svetov. Z uporabo DCL, getResource () metoda bi izgledala takole:

razred SomeClass {zasebni vir virov = null; javni vir getResource () {if (resource == null) {sinhronizirano {if (resource == null) resource = new Resource (); }} vrniti vir; }} 

Po prvem klicu getResource (), vir je že inicializiran, s čimer se izognemo zadetku sinhronizacije v najpogostejši kodni poti. DCL s preverjanjem preprečuje tudi stanje dirke vir drugič znotraj sinhroniziranega bloka; ki zagotavlja, da se bo poskušala inicializirati samo ena nit vir. DCL se zdi pametna optimizacija - vendar ne deluje.

Spoznajte Java Memory Model

Natančneje, DCL ne bo deloval. Da bi razumeli, zakaj, moramo pogledati razmerje med JVM in računalniškim okoljem, v katerem deluje. Posebej moramo pogledati Java Memory Model (JMM), opredeljen v 17. poglavju Specifikacija jezika Java, Bill Joy, Guy Steele, James Gosling in Gilad Bracha (Addison-Wesley, 2000), ki podrobno opisujejo, kako Java obravnava interakcijo med nitmi in pomnilnikom.

V nasprotju z večino drugih jezikov Java definira svoj odnos do osnovne strojne opreme s pomočjo formalnega pomnilniškega modela, ki naj bi bil na vseh platformah Java, kar omogoča obljubo Jave "Enkrat piši, zaženi kjer koli". Za primerjavo, drugi jeziki, kot sta C in C ++, nimajo formalnega pomnilniškega modela; v takih jezikih programi podedujejo pomnilniški model strojne platforme, na kateri se program izvaja.

Pri zagonu v sinhronem (enonitnem) okolju je interakcija programa s pomnilnikom dokaj preprosta ali pa se vsaj tako zdi. Programi shranjujejo predmete na pomnilniške lokacije in pričakujejo, da bodo še vedno tam, ko bodo naslednjič pregledali te pomnilniške lokacije.

Pravzaprav je resnica povsem drugačna, a zapletena iluzija, ki jo vzdržuje prevajalnik, JVM in strojna oprema, nam to skriva. Čeprav mislimo, da se programi izvajajo zaporedno - v vrstnem redu, ki ga določa programska koda -, se to ne zgodi vedno. Prevajalniki, procesorji in predpomnilniki si lahko prosto izkoristijo vse vrste svobode z našimi programi in podatki, če le ne vplivajo na rezultat izračuna. Na primer, prevajalniki lahko ustvarijo navodila v drugačnem vrstnem redu od očitne razlage, ki jo program predlaga, in shranijo spremenljivke v registre namesto v pomnilnik; obdelovalci lahko izvajajo navodila vzporedno ali nepravilno; in predpomnilniki se lahko razlikujejo po vrstnem redu zapisovanja v glavni pomnilnik. JMM pravi, da so vsa ta različna preurejanja in optimizacije sprejemljiva, če ohranja okolje kot da je serijski semantika - torej dokler dosežete enak rezultat, kot bi ga imeli, če bi bila navodila izvedena v strogo zaporednem okolju.

Prevajalniki, procesorji in predpomnilniki preuredijo zaporedje programskih operacij, da dosežejo večjo zmogljivost. V zadnjih letih smo opazili izjemne izboljšave v računalniški zmogljivosti. Medtem ko so povečane hitrosti procesorja bistveno prispevale k večji zmogljivosti, je k temu veliko prispeval tudi večji vzporednik (v obliki cevovodnih in super skalarnih izvršilnih enot, dinamičnega razporejanja ukazov in špekulativne izvedbe ter izpopolnjenih večnivojskih pomnilniških predpomnilnikov). Hkrati se je naloga pisanja prevajalnikov močno zapletla, saj mora prevajalnik zaščititi programerja pred temi zapleti.

Med pisanjem enojnih programov ne vidite učinkov teh različnih prerazporeditev navodil ali pomnilnika. Pri večnitnih programih pa je situacija povsem drugačna - ena nit lahko bere pomnilniške lokacije, ki jih je zapisala druga nit. Če nit A nekatere spremenljivke spremeni v določenem vrstnem redu, jih v odsotnosti sinhronizacije morda ne bo videla v istem vrstnem redu - ali pa jih sploh ne bo videla. To bi lahko nastalo, ker je prevajalnik prerazporedil navodila ali začasno shranil spremenljivko v register in jo pozneje zapisal v pomnilnik; ali ker je procesor izvedel navodila vzporedno ali v drugačnem vrstnem redu, kot je določen prevajalnik; ali ker so bila navodila v različnih regijah pomnilnika, predpomnilnik pa je posodobil ustrezna mesta glavnega pomnilnika v drugačnem vrstnem redu, kot je bil zapisan. Ne glede na okoliščine so večnitni programi že po naravi manj predvidljivi, razen če s sinhronizacijo izrecno zagotovite, da imajo niti dosleden pogled na pomnilnik.

Kaj v resnici pomeni sinhronizirano?

Java obravnava vsako nit, kot da deluje na svojem procesorju s svojim lokalnim pomnilnikom, pri čemer se vsaka pogovarja in sinhronizira s skupnim glavnim pomnilnikom. Tudi v enoprocesorskem sistemu je ta model smiseln zaradi učinkov spominskih predpomnilnikov in uporabe procesorskih registrov za shranjevanje spremenljivk. Ko nit spremeni lokacijo v svojem lokalnem pomnilniku, se ta sprememba sčasoma prikaže tudi v glavnem pomnilniku, JMM pa določa pravila, kdaj mora JVM prenašati podatke med lokalnim in glavnim pomnilnikom. Arhitekti Java so ugotovili, da bi preveč restriktivni pomnilniški model resno spodkopal delovanje programa. Poskušali so izdelati pomnilniški model, ki bi programom omogočal dobro delovanje na sodobni računalniški strojni opremi, hkrati pa zagotavljal zagotovila, ki omogočajo nitim interakcijo na predvidljiv način.

Osnovno orodje Java za upodabljanje interakcij med nitmi je predvidljivo sinhronizirano ključna beseda. Mnogi programerji pomislijo sinhronizirano v smislu uveljavljanja semaforja za vzajemno izključitev (mutex), da se hkrati prepreči izvajanje kritičnih odsekov za več niti. Na žalost ta intuicija ne opisuje v celoti, kaj sinhronizirano pomeni.

Semantika sinhronizirano res vključujejo medsebojno izključitev izvedbe na podlagi statusa semaforja, vključujejo pa tudi pravila o interakciji sinhronizacijske niti z glavnim pomnilnikom. Zlasti pridobitev ali sprostitev ključavnice sproži a spominska ovira - prisilna sinhronizacija med lokalnim pomnilnikom niti in glavnim pomnilnikom. (Nekateri procesorji - na primer Alpha) imajo izrecna strojna navodila za izvajanje pomnilniških ovir.) Ko nit zapusti datoteko sinhronizirano izvede pregrado za pisanje - pred sprostitvijo ključavnice mora izvleči vse spremenljivke, spremenjene v tem bloku, v glavni pomnilnik. Podobno tudi pri vnosu a sinhronizirano izvede bralno pregrado - kot da je lokalni pomnilnik razveljavljen, vse spremenljivke, na katere se bodo sklicevale v bloku, pa mora pridobiti iz glavnega pomnilnika.

Pravilna uporaba sinhronizacije zagotavlja, da bo ena nit videla učinke druge na predvidljiv način. Šele ko se niti A in B sinhronizirata na istem objektu, JMM zagotovi, da nit B vidi spremembe, ki jih naredi nit A, in spremembe, ki jih naredi nit A znotraj sinhronizirano blok atomsko v nit B (bodisi se celoten blok izvrši bodisi noben.) Poleg tega JMM to zagotavlja sinhronizirano bloki, ki se sinhronizirajo na istem objektu, se bodo izvajali v enakem vrstnem redu kot v programu.

Torej, kaj je okvarjeno pri DCL?

DCL se opira na nesinhronizirano uporabo vir polje. Zdi se, da je to neškodljivo, vendar ni. Če želite videti zakaj, si predstavljajte, da je nit A znotraj sinhronizirano blok, izvrševanje stavka vir = nov vir (); medtem ko nit B šele vstopa getResource (). Upoštevajte učinek te inicializacije na pomnilnik. Spomin za novo Vir predmet bo dodeljen; konstruktor za Vir bo poklican, inicializira polja članov novega predmeta; in polje vir od SomeClass bo dodeljena referenca na novo ustvarjeni objekt.

Ker pa se nit B ne izvaja znotraj a sinhronizirano blok, lahko te operacije pomnilnika vidi v drugačnem vrstnem redu, kot ga izvede ena nit A. Mogoče je, da B te dogodke vidi v naslednjem vrstnem redu (in prevajalnik lahko tudi preureja navodila, kot je ta): dodelite pomnilnik, dodelite sklic na vir, konstruktor klicev. Recimo, da nit B pride po dodelitvi pomnilnika in vir polje je nastavljeno, vendar preden se pokliče konstruktor. To vidi vir ni nič, preskoči sinhronizirano blok in vrne sklic na delno zgrajeno Vir! Ni treba posebej poudarjati, da rezultat ni niti pričakovan niti zaželen.

Ob predstavitvi tega primera je veliko ljudi sprva dvomljivih. Številni zelo inteligentni programerji so poskušali popraviti DCL tako, da deluje, vendar nobena od teh domnevno popravljenih različic ne deluje. Treba je opozoriti, da bi lahko DCL dejansko deloval na nekaterih različicah nekaterih JVM-jev, saj le malo JVM-jev dejansko pravilno izvaja JMM. Vendar ne želite, da se pravilnost vaših programov zanaša na podrobnosti izvedbe - zlasti napake - specifične za določeno različico določenega JVM, ki ga uporabljate.

Druge nevarnosti sočasnosti so vdelane v DCL - in v vsako nesinhronizirano sklicevanje na pomnilnik, ki ga napiše druga nit, celo neškodljivo branje. Recimo, da je nit A končala inicializacijo Vir in izstopi iz sinhronizirano blok, ko vstopi nit B getResource (). Zdaj pa Vir je popolnoma inicializiran, nit A pa izbriše svoj lokalni pomnilnik v glavni pomnilnik. The virPolja 'se lahko sklicujejo na druge predmete, shranjene v pomnilniku, skozi polja članov, ki bodo prav tako odstranjena. Medtem ko bo nit B morda videla veljaven sklic na novo ustvarjeno Vir, ker ni izvedel bralne ovire, je še vedno videl zastarele vrednosti virpolja članov.

Hlapno tudi ne pomeni, kaj mislite

Pogosto predlagani nefiks je razglasitev vir področje SomeClass kot hlapljivo. Čeprav JMM preprečuje prerazporeditev zapisov v spremenljive spremenljivke med seboj in zagotavlja njihovo takojšnje izpiranje v glavni pomnilnik, kljub temu dovoljuje, da se branje in zapisovanje spremenljivih spremenljivk prerazporedi glede na nehlapna branja in zapisovanja. To pomeni - razen če vsi Vir polja so hlapljivo tudi nit B lahko še vedno zazna konstruktorjev učinek, kot da se dogaja po njem vir je nastavljen tako, da se sklicuje na novo ustvarjeno Vir.

Alternative DCL

Najučinkovitejši način za odpravo idioma DCL je, da se mu izognemo. Seveda se temu najpreprosteje izognemo s sinhronizacijo. Kadar spremenljivko, ki jo piše ena nit, bere druga, uporabite sinhronizacijo, da zagotovite, da so spremembe vidne drugim nitim na predvidljiv način.

Druga možnost za izogibanje težavam z DCL je, da opustite leno inicializacijo in namesto tega uporabite željna inicializacija. Namesto da odloži inicializacijo vir dokler ni prvič uporabljen, ga pri gradnji inicializirajte. Nalagalnik razredov, ki se sinhronizira na Razred object, izvrši statične inicializacijske bloke v času inicializacije razreda. To pomeni, da je učinek statičnih inicializatorjev samodejno viden vsem nitkam takoj, ko se razred naloži.

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