Ohjelmointi

Kaksinkertaisesti tarkistettu lukitus: fiksu, mutta rikki

Vuodesta arvostettu Java-tyylin elementit sivulle JavaWorld (katso Java Tip 67), monet hyvää tarkoittavat Java-gurut kannustavat käyttämään kaksinkertaisesti tarkistettua lukitusta (DCL). Siinä on vain yksi ongelma - tämä älykäs näennäinen idiomi ei välttämättä toimi.

Kaksinkertaisesti tarkistettu lukitus voi olla vaarallista koodillesi!

Tämä viikko JavaWorld keskittyy kaksinkertaisesti tarkistetun lukitusidion vaaroihin. Lue lisää siitä, kuinka tämä näennäisesti vaaraton pikakuvake voi tuhota koodisi:
  • "Varoitus! Langoittaminen moniprosessorimaailmassa", Allen Holub
  • Kaksinkertaisesti tarkistettu lukitus: älykäs, mutta rikki ", Brian Goetz
  • Jos haluat puhua kaksinkertaisesti tarkistetusta lukitsemisesta, mene Allen Holubille Ohjelmointiteoria ja käytäntö -keskustelu

Mikä on DCL?

DCL-idiomi on suunniteltu tukemaan laiskaa alustusta, joka tapahtuu, kun luokka lykkää omistetun objektin alustamista, kunnes sitä todella tarvitaan:

luokka SomeClass {yksityisten resurssien resurssi = null; julkinen resurssi getResource () {if (resurssi == null) resurssi = uusi resurssi (); palautusresurssi; }} 

Miksi haluat lykätä alustamista? Ehkä luoda Resurssi on kallis toimenpide, ja JotkutLuokka ei välttämättä soita getResource () missä tahansa ajon aikana. Siinä tapauksessa voit välttää Resurssi täysin. Riippumatta JotkutLuokka objekti voidaan luoda nopeammin, jos sen ei tarvitse luoda myös Resurssi rakennusajankohtana. Joidenkin alustustoimintojen viivästyminen, kunnes käyttäjä todella tarvitsee tuloksia, voi auttaa ohjelmia käynnistymään nopeammin.

Entä jos yrität käyttää JotkutLuokka monisäikeisessä sovelluksessa? Sitten saadaan kilpailuehtoja: kaksi ketjua voi suorittaa testin samanaikaisesti nähdäksesi resurssi on nolla ja sen seurauksena alustaa resurssi kahdesti. Sinun tulisi julistaa monisäikeisessä ympäristössä getResource () olla synkronoitu.

Valitettavasti synkronoidut menetelmät toimivat paljon hitaammin - jopa 100 kertaa hitaammin - kuin tavalliset synkronoimattomat menetelmät. Yksi motiiveista laiskalle alustukselle on tehokkuus, mutta näyttää siltä, ​​että nopeamman ohjelman käynnistymisen saavuttamiseksi sinun on hyväksyttävä hitaampi suoritusaika, kun ohjelma käynnistyy. Se ei kuulosta suurelta kompromissilta.

DCL pyrkii antamaan meille molempien maailmojen parhaat puolet. DCL: ää käyttämällä getResource () menetelmä näyttäisi tältä:

luokka SomeClass {yksityisten resurssien resurssi = null; julkinen resurssi getResource () {if (resurssi == null) {synkronoitu {if (resurssi == null) resurssi = uusi resurssi (); }} palautusresurssi; }} 

Ensimmäisen puhelun jälkeen getResource (), resurssi on jo alustettu, mikä välttää synkronointihittin yleisimmällä koodipolulla. DCL estää myös kilpailutilanteen tarkistamalla resurssi toisen kerran synkronoidun lohkon sisällä; varmistaa, että vain yksi ketju yrittää alustaa resurssi. DCL näyttää fiksulta optimoinnilta - mutta se ei toimi.

Tapaa Java-muistimalli

Tarkemmin sanottuna DCL: n ei voida taata toimivan. Ymmärtääksemme miksi meidän on tarkasteltava JVM: n ja tietokoneympäristön suhdetta, jossa se toimii. Erityisesti meidän on tarkasteltava Java-muistimallia (JMM), joka on määritelty Java-kielimääritys, kirjoittanut Bill Joy, Guy Steele, James Gosling ja Gilad Bracha (Addison-Wesley, 2000), jossa kerrotaan, miten Java käsittelee langan ja muistin välistä vuorovaikutusta.

Toisin kuin useimmat muut kielet, Java määrittelee suhteensa taustalla olevaan laitteistoon muodollisen muistimallin kautta, jonka odotetaan olevan kaikilla Java-alustoilla. Vertailun vuoksi muilta kieliltä, ​​kuten C ja C ++, puuttuu muodollinen muistimalli; tällaisilla kielillä ohjelmat perivät sen laitteistoalustan muistimallin, jolla ohjelma toimii.

Kun suoritetaan synkronisessa (yksisäikeisessä) ympäristössä, ohjelman vuorovaikutus muistin kanssa on melko yksinkertaista tai ainakin näyttää siltä. Ohjelmat tallentavat kohteita muistipaikkoihin ja odottavat, että ne ovat edelleen siellä seuraavalla kerralla, kun muistipaikkoja tutkitaan.

Itse asiassa totuus on aivan erilainen, mutta kääntäjän, JVM: n ja laitteiston ylläpitämä monimutkainen harha piilottaa sen meiltä. Vaikka ajattelemme ohjelmien suorittavan peräkkäin - ohjelmakoodin määrittelemässä järjestyksessä - sitä ei aina tapahdu. Kääntäjät, prosessorit ja välimuistit voivat vapaasti käyttää kaikenlaisia ​​vapauksia ohjelmiemme ja tietojemme kanssa, kunhan ne eivät vaikuta laskennan tulokseen. Esimerkiksi kääntäjät voivat luoda käskyjä eri järjestyksessä kuin ohjelman ilmeinen tulkinta ja tallentaa muuttujat rekistereihin muistin sijaan; prosessorit voivat suorittaa ohjeita rinnakkain tai epäkunnossa; ja välimuistit voivat vaihdella järjestyksessä, jossa kirjoitukset sitoutuvat päämuistiin. JMM: n mukaan kaikki nämä erilaiset uudelleenjärjestelyt ja optimoinnit ovat hyväksyttäviä, kunhan ympäristö säilyy ikään kuin sarjana semantiikka - ts. niin kauan kuin saavutat saman tuloksen kuin sinulla olisi, jos ohjeet suoritettaisiin tiukasti peräkkäisessä ympäristössä.

Kääntäjät, prosessorit ja välimuistit järjestävät ohjelmatoimintojen järjestyksen paremman suorituskyvyn saavuttamiseksi. Viime vuosina olemme nähneet valtavia parannuksia laskentatehokkuudessa. Suuremmat prosessorin kellotaajuudet ovat vaikuttaneet merkittävästi parempaan suorituskykyyn, mutta lisääntynyt rinnakkaisuus (putkilinjaisten ja superskalaaristen suoritusyksiköiden, dynaamisen käskyjen ajoituksen ja spekulatiivisen suorituksen sekä kehittyneiden monitasoisten muistivälimuistojen muodossa) on myös ollut merkittävä tekijä. Samalla kääntäjien kirjoittaminen on kasvanut paljon monimutkaisemmaksi, koska kääntäjän on suojattava ohjelmoijaa näiltä monimutkaisuuksilta.

Kun kirjoitat yksisäikeisiä ohjelmia, et näe näiden eri ohjeiden tai muistitoimintojen uudelleenjärjestelyjen vaikutuksia. Monisäikeisissä ohjelmissa tilanne on kuitenkin aivan erilainen - yksi säie voi lukea toisen säikeen kirjoittamat muistipaikat. Jos säie A muokkaa joitain muuttujia tietyssä järjestyksessä, synkronoinnin puuttuessa säie B ei ehkä näe niitä samassa järjestyksessä - tai ei ehkä näe niitä lainkaan. Tämä voi johtua siitä, että kääntäjä järjestää ohjeet uudelleen tai tallensi muuttujan väliaikaisesti rekisteriin ja kirjoitti sen muistiin myöhemmin; tai koska prosessori suoritti ohjeet rinnakkain tai eri järjestyksessä kuin määritetty kääntäjä; tai koska ohjeet olivat eri muistialueilla, ja välimuisti päivitti vastaavat päämuistipaikat eri järjestyksessä kuin siinä, johon ne kirjoitettiin. Olosuhteista riippumatta monisäikeiset ohjelmat ovat luonnostaan ​​vähemmän ennustettavia, ellet nimenomaisesti varmista, että langoilla on yhtenäinen näkymä muistista synkronoinnin avulla.

Mitä synkronoitu todella tarkoittaa?

Java kohtelee kutakin ketjua ikään kuin se toimisi omalla prosessorillaan, jolla on oma paikallinen muisti, joista kukin puhuu ja synkronoi jaetun päämuistin kanssa. Jopa yhden prosessorin järjestelmässä kyseinen malli on järkevä muistivälimuistien vaikutusten ja prosessorirekisterien käytön vuoksi muuttujien tallentamiseksi. Kun säie muuttaa sijaintia paikallisessa muistissaan, tämän muutoksen pitäisi lopulta näkyä myös päämuistissa, ja JMM määrittelee säännöt, milloin JVM: n on siirrettävä tietoja paikallisen ja päämuistin välillä. Java-arkkitehdit tajusivat, että liian rajoittava muistimalli heikentäisi vakavasti ohjelman suorituskykyä. He yrittivät luoda muistimallin, joka antaisi ohjelmille mahdollisuuden toimia hyvin nykyaikaisella tietokonelaitteistolla tarjoten silti takuita, jotka antaisivat ketjujen olla vuorovaikutuksessa ennustettavalla tavalla.

Java on ensisijainen työkalu vuorovaikutusten hahmottamiseksi ketjujen välillä ennustettavasti synkronoitu avainsana. Monet ohjelmoijat ajattelevat synkronoitu tiukasti keskinäisen syrjäytymisen semaforin (mutex) estää kriittisten osien suorittamisen useammalla kuin yhdellä säikeellä kerrallaan. Valitettavasti tämä intuitio ei kuvaa täysin mitä synkronoitu tarkoittaa.

Semantiikka synkronoitu eivät todellakaan sisällä keskinäistä poissulkemista suorituksesta semaforin tilan perusteella, mutta ne sisältävät myös sääntöjä synkronointilangan vuorovaikutuksesta päämuistin kanssa. Erityisesti lukon hankinta tai vapauttaminen laukaisee a muistimuuri - pakollinen synkronointi langan paikallisen muistin ja päämuistin välillä. (Joillakin prosessoreilla - kuten Alpha - on selkeät koneohjeet muistiestojen suorittamiseksi.) Kun säie poistuu a synkronoitu Lohko, se suorittaa kirjoitusesteen - sen on huuhdeltava kaikki lohkossa muokatut muuttujat päämuistiin ennen lukituksen vapauttamista. Vastaavasti, kun syötät a synkronoitu lohko, se suorittaa lukunesteen - se on kuin paikallinen muisti olisi mitätöity, ja sen on haettava kaikki muuttujat, joihin lohkossa viitataan, päämuistista.

Synkronoinnin asianmukainen käyttö takaa, että yksi säie näkee toisen vaikutukset ennustettavalla tavalla. Vasta kun ketjut A ja B synkronoituvat samaan objektiin, JMM takaa, että säie B näkee säikeen A tekemät muutokset ja että säikeen A tekemät muutokset synkronoitu esto ilmestyy atomisesti ketjuun B (joko koko lohko suoritetaan tai mikään niistä ei.) Lisäksi JMM varmistaa sen synkronoitu lohkot, jotka synkronoivat samalla objektilla, näyttävät suoritettavan samassa järjestyksessä kuin ohjelmassa.

Joten mikä on rikki DCL: ssä?

DCL perustuu .synkronoimattomaan käyttöön resurssi ala. Se näyttää olevan vaaraton, mutta ei ole. Kuvittele, että lanka A on sisällä synkronoitu lohko, suorittamalla käsky resurssi = uusi resurssi (); kun lanka B on juuri tulossa getResource (). Harkitse tämän alustuksen vaikutusta muistiin. Muisti uudelle Resurssi kohde varataan; rakentaja Resurssi kutsutaan, alustamalla uuden objektin jäsenkentät; ja kenttä resurssi / JotkutLuokka määritetään viittaus äskettäin luotuun objektiin.

Koska lanka B ei kuitenkaan suorita a: n sisällä synkronoitu lohko, se voi nähdä nämä muistitoiminnot eri järjestyksessä kuin yksi säie A suorittaa. Saattaa olla, että B näkee nämä tapahtumat seuraavassa järjestyksessä (ja kääntäjä voi myös vapaasti järjestää uudelleen tällaiset ohjeet): allokoi muisti, anna viite resurssi, puhelun rakentaja. Oletetaan, että lanka B tulee mukaan, kun muisti on varattu ja resurssi kenttä on asetettu, mutta ennen kuin konstruktoria kutsutaan. Se näkee sen resurssi ei ole nolla, ohittaa synkronoitu lohko ja palauttaa viitteen osittain rakennettuun Resurssi! Tarpeetonta sanoa, että tulos ei ole odotettavissa eikä toivottu.

Kun tämä esimerkki esitetään, monet ihmiset ovat aluksi skeptisiä. Monet erittäin älykkäät ohjelmoijat ovat yrittäneet korjata DCL: n niin, että se toimii, mutta mikään näistä oletettavasti kiinteistä versioista ei myöskään toimi. On huomattava, että DCL saattaa itse asiassa toimia joidenkin JVM-mallien versioissa - koska harvat JVM: t todella toteuttavat JMM: n oikein. Et kuitenkaan halua, että ohjelmiesi oikeellisuus luottaa toteutuksen yksityiskohtiin - etenkin virheisiin -, jotka liittyvät nimenomaan käyttämäsi JVM: n tiettyyn versioon.

Muut samanaikaisuusvaarat on upotettu DCL: ään - ja kaikkiin synkronoimattomiin viitteisiin muistiin, jonka toinen säike on kirjoittanut, jopa harmittomalta näyttävältä. Oletetaan, että lanka A on alustanut Resurssi ja poistuu synkronoitu estää, kun lanka B tulee sisään getResource (). Nyt Resurssi on täysin alustettu ja lanka A huuhdo paikallisen muistinsa päämuistiin. resurssiKentät voivat viitata muihin muistiin tallennettuihin kohteisiin jäsenkenttien kautta, jotka myös tyhjennetään. Vaikka ketju B saattaa nähdä kelvollisen viittauksen uuteen luotuun Resurssi, koska se ei suorittanut lukunestettä, se näki silti arvot resurssijäsenen kentät.

Haihtuva ei tarkoita sitä, mitä ajattelet

Yleisesti ehdotettu korjaamaton on ilmoittaa resurssi kenttä JotkutLuokka kuten haihtuva. Vaikka JMM estää haihtuvien muuttujien kirjoitusten järjestämisen uudelleen toistensa suhteen ja varmistaa, että ne huuhdellaan päämuistiin välittömästi, se sallii kuitenkin haihtuvien muuttujien lukemisen ja kirjoittamisen järjestyksen uudelleen haihtumattomien lukujen ja kirjojen suhteen. Se tarkoittaa - ellei kaikkia Resurssi kentät ovat haihtuva samoin - lanka B voi silti havaita konstruktorin vaikutuksen tapahtuvan sen jälkeen resurssi on asetettu viittaamaan äskettäin luotuun Resurssi.

Vaihtoehdot DCL: lle

Tehokkain tapa korjata DCL-idioomi on välttää se. Yksinkertaisin tapa välttää se on tietysti synkronointi. Aina, kun toinen lukee toisen säikeen kirjoittaman muuttujan, sinun on käytettävä synkronointia varmistaaksesi, että muutokset näkyvät muille säikeille ennustettavalla tavalla.

Toinen vaihtoehto DCL-ongelmien välttämiseksi on pudottaa laiska alustus ja käyttää sen sijaan innokas alustus. Sen sijaan, että viivästyttäisi resurssi alusta se rakentamisen aikana, kunnes sitä käytetään ensimmäisen kerran. Luokkakuormaaja, joka synkronoi luokkien Luokka objekti, suorittaa staattiset alustuslohkot luokan alustushetkellä. Tämä tarkoittaa, että staattisten alustusohjelmien vaikutus näkyy automaattisesti kaikille säikeille heti, kun luokka latautuu.

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