Programmēšana

Divreiz pārbaudīta bloķēšana: gudra, bet salauzta

No augsti novērtētā Java stila elementi uz JavaWorld (skat. Java padomu 67), daudzi labi domājoši Java guru mudina izmantot divreiz pārbaudītās bloķēšanas (DCL) idiomu. Ar to ir tikai viena problēma - šī gudrā šķietamā idioma var nedarboties.

Divreiz pārbaudīta bloķēšana var būt bīstama jūsu kodam!

Šonedēļ JavaWorld koncentrējas uz divreiz pārbaudītās bloķēšanas idiomas bīstamību. Lasiet vairāk par to, kā šī šķietami nekaitīgā saīsne var izpostīt jūsu kodu:
  • "Brīdinājums! Vītne daudzprocesoru pasaulē," Alens Holubs
  • Divreiz pārbaudīta bloķēšana: Gudrs, bet salauzts, "Braiens Gecs
  • Lai runātu vairāk par divreiz pārbaudītu bloķēšanu, dodieties uz Allen Holub's Programmēšanas teorijas un prakses diskusija

Kas ir DCL?

DCL idioma tika izstrādāta, lai atbalstītu slinku inicializāciju, kas notiek, kad klase atlika īpašumā esoša objekta inicializāciju, līdz tas faktiski ir nepieciešams:

klase SomeClass {privātā resursa resurss = null; publiskais resurss getResource () {if (resurss == null) resurss = jauns resurss (); atgriešanās resurss; }} 

Kāpēc jūs vēlaties atlikt inicializāciju? Varbūt izveidojot Resurss ir dārga darbība, un SomeClass iespējams, patiesībā nezvanīs getResource () jebkurā konkrētā skrējienā. Tādā gadījumā jūs varat izvairīties no Resurss pilnībā. Neatkarīgi no tā SomeClass objektu var izveidot ātrāk, ja tam nav jāizveido arī Resurss būvniecības laikā. Dažu inicializācijas darbību aizkavēšana, līdz lietotājam faktiski ir nepieciešami viņu rezultāti, var palīdzēt programmām ātrāk startēt.

Ko darīt, ja jūs mēģināt izmantot SomeClass vairāku pavedienu lietojumprogrammā? Pēc tam tiek iegūti sacensību apstākļi: divi pavedieni vienlaikus varētu izpildīt testu, lai pārliecinātos, vai tas notiek resurss ir nulle un kā rezultātā inicializēt resurss divreiz. Daudzvītņu vidē jums vajadzētu paziņot getResource () būt sinhronizēts.

Diemžēl sinhronizētās metodes darbojas daudz lēnāk - pat 100 reizes lēnāk - nekā parastās nesinhronizētās metodes. Viena no slinkās inicializācijas motivācijām ir efektivitāte, taču šķiet, ka, lai panāktu ātrāku programmas startēšanu, pēc programmas starta ir jāpieņem lēnāks izpildes laiks. Tas neizklausās pēc lieliskas kompromisa.

DCL mērķis ir sniegt mums labāko no abām pasaulēm. Izmantojot DCL, getResource () metode izskatās šādi:

klase SomeClass {privātā resursa resurss = null; publiskais resurss getResource () {if (resurss == null) {sinhronizēts {ja (resurss == null) resurss = jauns resurss (); }} atgriešanās resurss; }} 

Pēc pirmā zvana uz getResource (), resurss jau ir inicializēts, kas ļauj izvairīties no sinhronizācijas trāpījuma visbiežāk sastopamajā koda ceļā. DCL arī novērš sacensību stāvokli, pārbaudot resurss otro reizi sinhronizētajā blokā; tas nodrošina, ka tikai vienu pavedienu mēģinās inicializēt resurss. DCL šķiet gudra optimizācija, bet tas nedarbojas.

Iepazīstieties ar Java atmiņas modeli

Pareizāk sakot, netiek garantēts, ka DCL darbosies. Lai saprastu, kāpēc, mums jāaplūko attiecības starp JVM un datoru vidi, kurā tā darbojas. Jo īpaši mums jāaplūko Java atmiņas modelis (JMM), kas definēts Java valodas specifikācija, Bils Džojs, Gajs Stīls, Džeimss Goslings un Gilads Brača (Addison-Wesley, 2000), kurā sīki aprakstīts, kā Java apstrādā mijiedarbību starp pavedieniem un atmiņu.

Atšķirībā no vairuma citu valodu, Java nosaka savu saistību ar pamatā esošo aparatūru, izmantojot formālu atmiņas modeli, kas, domājams, darbosies visās Java platformās, ļaujot Java solīt "Rakstīt vienreiz, palaist jebkur". Salīdzinājumam - citās valodās, piemēram, C un C ++, trūkst formāla atmiņas modeļa; šādās valodās programmas pārmanto aparatūras platformas, uz kuras darbojas programma, atmiņas modeli.

Darbojoties sinhronā (ar vienu pavedienu) vidē, programmas mijiedarbība ar atmiņu ir diezgan vienkārša vai vismaz tā šķiet. Programmas glabā vienumus atmiņas vietās un sagaida, ka tās joprojām būs nākamreiz, kad tiks pārbaudītas šīs atmiņas vietas.

Patiesībā patiesība ir pavisam cita, taču sarežģīta ilūzija, ko uztur kompilators JVM un aparatūra, to no mums slēpj. Lai gan mēs domājam, ka programmas tiek izpildītas secīgi - programmas kodā norādītajā secībā - tas ne vienmēr notiek. Kompilatori, procesori un kešatmiņas var brīvi izmantot visu veidu brīvības ar mūsu programmām un datiem, ja vien tie neietekmē aprēķina rezultātu. Piemēram, sastādītāji var ģenerēt instrukcijas citā secībā no programmas acīmredzamās interpretācijas un saglabāt mainīgos reģistros, nevis atmiņā; procesori var izpildīt instrukcijas paralēli vai ārpus kārtības; un kešatmiņas var atšķirties secībā, kādā raksti piešķir galveno atmiņu. JMM saka, ka visi šie dažādie pārkārtojumi un optimizācijas ir pieņemami, ja vien vide tiek uzturēta it kā sērijveida semantika - tas ir, ja vien jūs sasniedzat tādu pašu rezultātu, kāds būtu, ja instrukcijas tiktu izpildītas stingri secīgā vidē.

Kompilatori, procesori un kešatmiņas pārkārto programmas darbību secību, lai sasniegtu augstāku veiktspēju. Pēdējos gados mēs esam pieredzējuši milzīgus skaitļošanas veiktspējas uzlabojumus. Kaut arī paaugstināti procesora pulksteņa ātrumi ir ievērojami veicinājuši lielāku veiktspēju, palielināts paralēlisms (cauruļvadu un superskalāru izpildes vienību, dinamiskas instrukciju plānošanas un spekulatīvas izpildes un izsmalcinātu daudzlīmeņu atmiņu kešatmiņu veidā) ir bijis arī galvenais veicinātājs. Tajā pašā laikā kompilatoru rakstīšanas uzdevums ir kļuvis daudz sarežģītāks, jo kompilatoram jāsargā programmētājs no šīm sarežģītībām.

Rakstot programmas ar vienu pavedienu, jūs nevarat redzēt šo dažādo instrukciju vai atmiņas darbību pārkārtojumu sekas. Tomēr ar daudzšķiedru programmām situācija ir pavisam citāda - ar vienu pavedienu var nolasīt atmiņas vietas, kuras uzrakstījis cits pavediens. Ja pavediens A modificē dažus mainīgos noteiktā secībā, tad, ja nav sinhronizācijas, pavediens B var tos neredzēt vienā secībā - vai arī vispār neredz. Tas varētu notikt, jo kompilators pārkārtoja instrukcijas vai īslaicīgi saglabāja mainīgo reģistrā un vēlāk to ierakstīja atmiņā; vai tāpēc, ka procesors izpildīja instrukcijas paralēli vai citā secībā nekā norādītais kompilators; vai tāpēc, ka instrukcijas atradās dažādos atmiņas reģionos, un kešatmiņa atjaunināja atbilstošās galvenās atmiņas vietas citā secībā nekā tajā, kurā tās bija rakstītas. Neatkarīgi no apstākļiem, vairāku pavedienu programmas pēc būtības ir mazāk paredzamas, ja vien jūs, izmantojot sinhronizāciju, nepārprotami nenodrošināt, lai pavedieniem būtu vienots skats uz atmiņu.

Ko patiesībā nozīmē sinhronizācija?

Java izturas pret katru pavedienu tā, it kā tas darbotos ar savu procesoru ar savu vietējo atmiņu, katrs runājot ar sinhronizāciju ar kopīgu galveno atmiņu. Pat viena procesora sistēmā šim modelim ir jēga atmiņas kešatmiņas ietekmes un procesoru reģistru izmantošanas dēļ mainīgo lielumu glabāšanai. Kad pavediens modificē vietu savā lokālajā atmiņā, šai modifikācijai galu galā vajadzētu parādīties arī galvenajā atmiņā, un JMM nosaka noteikumus, kad JVM jāpārsūta dati starp vietējo un galveno atmiņu. Java arhitekti saprata, ka pārāk ierobežojošais atmiņas modelis nopietni grauj programmas veiktspēju. Viņi mēģināja izveidot atmiņas modeli, kas ļautu programmām labi darboties mūsdienu datoru aparatūrā, vienlaikus nodrošinot garantijas, kas ļautu pavedieniem mijiedarboties paredzamā veidā.

Java galvenais rīks mijiedarbības starp pavedieniem paredzamības nodrošināšanai ir sinhronizēts atslēgvārds. Daudzi programmētāji domā sinhronizēts stingri attiecībā uz savstarpējas atstumtības semaforas ieviešanu (mutekss), lai novērstu kritisko sadaļu izpildi ar vairāk nekā vienu pavedienu vienlaikus. Diemžēl šī intuīcija pilnībā neapraksta ko sinhronizēts nozīmē.

Semantika sinhronizēts tie patiešām ietver savstarpēju izpildes izslēgšanu, pamatojoties uz semafora statusu, taču tie ietver arī noteikumus par pavediena sinhronizācijas mijiedarbību ar galveno atmiņu. Jo īpaši slēdzenes iegūšana vai atbrīvošana izraisa a atmiņas barjera - piespiedu sinhronizācija starp pavediena vietējo atmiņu un galveno atmiņu. (Dažiem procesoriem, piemēram, Alfa, ir skaidras mašīnas instrukcijas atmiņas barjeru veikšanai.) Kad pavediens iziet a sinhronizēts bloku, tas veic rakstīšanas barjeru - pirms bloķēšanas atbrīvošanas tai ir jāmaina visi šajā blokā modificētie mainīgie uz galveno atmiņu. Līdzīgi, ievadot a sinhronizēts bloku, tas veic lasīšanas barjeru - it kā vietējā atmiņa būtu nederīga, un tai no galvenās atmiņas jāatgūst visi mainīgie, uz kuriem blokā tiks atsauces.

Pareiza sinhronizācijas izmantošana garantē, ka viens pavediens paredzamā veidā redzēs cita efektu. Tikai tad, kad pavedieni A un B sinhronizējas tajā pašā objektā, JMM garantēs, ka pavediens B redz izmaiņas, ko veic pavediens A, un ka pavediena A veiktās izmaiņas sinhronizēts parādās bloķēšana atomiski lai pavediens B (vai nu viss bloks tiek izpildīts, vai neviens no tiem nedarbojas.) Turklāt JMM to nodrošina sinhronizēts bloki, kas sinhronizējas vienā un tajā pašā objektā, parādīsies izpildāmi tādā pašā secībā kā programmā.

Tātad, kas ir bojāts DCL?

DCL paļaujas uz nesinhronizētu resurss laukā. Šķiet, ka tas ir nekaitīgs, bet tā nav. Lai saprastu, kāpēc, iedomājieties, ka pavediens A ir iekšpusē sinhronizēts bloķēt, izpildot paziņojumu resurss = jauns resurss (); kamēr vītne B tikko ienāk getResource (). Apsveriet šīs inicializācijas ietekmi uz atmiņu. Atmiņa par jauno Resurss objekts tiks piešķirts; projektētājs Resurss tiks izsaukts, inicializējot jaunā objekta dalībnieku laukus; un lauka resurss gada SomeClass tiks piešķirta atsauce uz jaunizveidoto objektu.

Tomēr, tā kā pavediens B netiek izpildīts a iekšpusē sinhronizēts bloku, tas var redzēt šīs atmiņas operācijas citā secībā nekā viena pavediena A izpilde. Var gadīties, ka B šos notikumus redz šādā secībā (un kompilators var arī brīvi pārkārtot šādas instrukcijas): piešķirt atmiņu, piešķirt atsauci uz resurss, zvanu konstruktors. Pieņemsim, ka pavediens B parādās pēc atmiņas piešķiršanas un resurss lauks ir iestatīts, bet pirms tiek izsaukts konstruktors. Tā to redz resurss nav nulle, izlaiž sinhronizēts bloķē un atgriež atsauci uz daļēji izveidotu Resurss! Lieki piebilst, ka rezultāts nav nedz gaidīts, nedz vēlams.

Iesniedzot šo piemēru, daudzi cilvēki sākumā ir skeptiski. Daudzi ļoti inteliģenti programmētāji ir mēģinājuši salabot DCL tā, lai tas darbotos, taču nedarbojas arī neviena no šīm it kā fiksētajām versijām. Jāatzīmē, ka DCL faktiski varētu strādāt ar dažu dažu JVM versijām - jo tikai daži JVM faktiski pareizi ievieš JMM. Tomēr jūs nevēlaties, lai jūsu programmu pareizība balstītos uz ieviešanas detaļām - it īpaši kļūdām -, kas raksturīgas tieši jūsu izmantotā JVM konkrētajai versijai.

Citi vienlaicīguma apdraudējumi ir iestrādāti DCL - un visās nesinhronizētajās atsaucēs uz atmiņu, kuru uzrakstījis cits pavediens, pat nekaitīga izskata lasījumi. Pieņemsim, ka pavediens A ir pabeidzis Resurss un iziet no sinhronizēts bloķēt, kad vītne B ienāk getResource (). Tagad Resurss ir pilnībā inicializēts, un pavediens A vietējo atmiņu izskalo galvenajā atmiņā. The resursslauki var atsaukties uz citiem atmiņā saglabātiem objektiem, izmantojot dalībnieku laukus, kuri arī tiks izdzēsti. Kamēr B pavedienā var būt redzama derīga atsauce uz jaunizveidoto Resurss, jo tā neizturēja lasīšanas barjeru, tā joprojām varēja redzēt novecojušās vērtības resurssdalībnieka lauki.

Arī gaistošais nenozīmē to, ko jūs domājat

Parasti ieteicamais labojums ir deklarēt resurss joma SomeClassgaistošs. Tomēr, lai gan JMM novērš nepastāvīgo mainīgo lielumu rakstīšanas pārkārtošanu attiecībā pret otru un nodrošina to tūlītēju iekļaušanu galvenajā atmiņā, tas tomēr ļauj nepastāvīgo mainīgo lasīšanu un rakstīšanu pārkārtot attiecībā uz nepastāvīgajiem lasījumiem un rakstiem. Tas nozīmē - ja ne visi Resurss lauki ir gaistošs kā arī - vītne B joprojām var uztvert konstruktora efektu, kas notiek pēc tam resurss ir iestatīts kā atsauce uz jaunizveidoto Resurss.

Alternatīvas DCL

Visefektīvākais veids, kā novērst DCL idiomu, ir izvairīšanās no tā. Vienkāršākais veids, kā no tā izvairīties, protams, ir sinhronizācijas izmantošana. Ikreiz, kad mainās, ko rakstījis viens pavediens, lasa cits, jums jāizmanto sinhronizācija, lai garantētu, ka modifikācijas ir paredzamas citiem pavedieniem.

Vēl viena iespēja izvairīties no problēmām ar DCL ir nomest slinku inicializāciju un tā vietā izmantot dedzīga inicializācija. Tā vietā, lai aizkavētu resurss līdz to pirmo reizi izmantos, inicializējiet to būvniecības laikā. Klases iekrāvējs, kas sinhronizē klases Klase objekts, izpilda statiskos inicializācijas blokus klases inicializācijas laikā. Tas nozīmē, ka statisko inicializatoru ietekme ir automātiski redzama visiem pavedieniem, tiklīdz klase tiek ielādēta.

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