Programování

Dvojitě zkontrolované zamykání: Chytré, ale poškozené

Z vysoce ceněné Prvky stylu Java na stránky JavaWorld (viz Java Tip 67), mnoho dobře míněných Java guru podporuje použití idiomu dvojitě kontrolovaného zamykání (DCL). Je tu jen jeden problém - tento chytře vypadající idiom nemusí fungovat.

Dvojitě zkontrolované zamykání může být pro váš kód nebezpečné!

Tento týden JavaWorld se zaměřuje na nebezpečí dvojitě kontrolovaného zamykacího idiomu. Přečtěte si více o tom, jak tato zdánlivě neškodná zkratka může způsobit zmatek ve vašem kódu:
  • „Varování! Vlákání ve světě více procesorů,“ Allen Holub
  • Dvojitě zkontrolované zamykání: Chytré, ale zlomené, “Brian Goetz
  • Chcete-li hovořit více o dvojitě zkontrolovaném zamykání, přejděte k Allenovi Holubovi Diskuse o teorii a praxi programování

Co je to DCL?

Idiom DCL byl navržen tak, aby podporoval línou inicializaci, ke které dochází, když třída odloží inicializaci vlastněného objektu, dokud není skutečně potřeba:

třída SomeClass {private Resource resource = null; public Resource getResource () {if (resource == null) resource = new Resource (); vrátit zdroj; }} 

Proč byste chtěli odložit inicializaci? Možná vytvoření Zdroj je nákladná operace a uživatelé SomeClass nemusí ve skutečnosti volat getResource () v daném běhu. V takovém případě se můžete vyhnout vytvoření Zdroj zcela. Bez ohledu na to SomeClass objekt lze vytvořit rychleji, pokud nemusí také vytvářet a Zdroj v době výstavby. Zpoždění některých inicializačních operací, dokud uživatel skutečně nepotřebuje jejich výsledky, může pomoci programům začít rychleji.

Co když se pokusíte použít SomeClass ve vícevláknové aplikaci? Pak vznikne podmínka závodu: dvě vlákna mohla současně provést test a zjistit, zda zdroj je null a ve výsledku se inicializuje zdroj dvakrát. V prostředí s více vlákny byste měli deklarovat getResource () být synchronizované.

Bohužel synchronizované metody běží mnohem pomaleji - až stokrát pomaleji - než běžné nesynchronizované metody. Jednou z motivací líné inicializace je efektivita, ale zdá se, že k dosažení rychlejšího spuštění programu musíte po spuštění programu přijmout pomalejší čas spuštění. To nezní jako skvělý kompromis.

DCL se snaží poskytnout nám to nejlepší z obou světů. Pomocí DCL se getResource () metoda by vypadala takto:

třída SomeClass {private Resource resource = null; public Resource getResource () {if (resource == null) {synchronized {if (resource == null) resource = new Resource (); }} vrátit zdroj; }} 

Po prvním volání na getResource (), zdroj je již inicializován, čímž se zabrání synchronizačnímu zásahu v nejběžnější cestě kódu. DCL také odvrací stav závodu kontrolou zdroj podruhé uvnitř synchronizovaného bloku; což zajišťuje, že se pokusí inicializovat pouze jedno vlákno zdroj. DCL vypadá jako chytrá optimalizace - ale nefunguje.

Seznamte se s paměťovým modelem Java

Přesněji, není zaručeno, že DCL bude fungovat. Abychom pochopili proč, musíme se podívat na vztah mezi JVM a počítačovým prostředím, na kterém běží. Zejména se musíme podívat na Java Memory Model (JMM), definovaný v kapitole 17 dokumentu Specifikace jazyka JavaAutor: Bill Joy, Guy Steele, James Gosling a Gilad Bracha (Addison-Wesley, 2000), který podrobně popisuje, jak Java zpracovává interakci mezi vlákny a pamětí.

Na rozdíl od většiny ostatních jazyků Java definuje svůj vztah k základnímu hardwaru prostřednictvím formálního paměťového modelu, který se předpokládá na všech platformách Java, což umožňuje příslib Javy „Write Once, Run Anywhere“. Pro srovnání, jiným jazykům jako C a C ++ chybí formální paměťový model; v takových jazycích programy dědí paměťový model hardwarové platformy, na které program běží.

Při spuštění v synchronním (jednovláknovém) prostředí je interakce programu s pamětí poměrně jednoduchá, nebo se to alespoň zdá. Programy ukládají položky do paměťových míst a očekávají, že tam budou i při příštím zkoumání těchto paměťových míst.

Pravda je ve skutečnosti úplně jiná, ale komplikovaná iluze, kterou udržuje kompilátor, JVM a hardware, to před námi skrývá. I když si myslíme, že se programy spouští postupně - v pořadí určeném kódem programu - to se ne vždy stane. Kompilátoři, procesory a mezipaměti mohou s našimi programy a daty využívat nejrůznější svobody, pokud neovlivní výsledek výpočtu. Například kompilátoři mohou generovat instrukce v jiném pořadí, než je zřejmá interpretace, kterou program navrhuje, a ukládat proměnné do registrů místo do paměti; procesory mohou provádět pokyny paralelně nebo mimo pořadí; a mezipaměti se mohou lišit v pořadí, ve kterém se zápisy zapisují do hlavní paměti. JMM říká, že všechny tyto různé pořadí a optimalizace jsou přijatelné, pokud to prostředí udržuje as-if-serial sémantika - to znamená, pokud dosáhnete stejného výsledku, jaký byste měli, pokud by byly pokyny prováděny v přísně sekvenčním prostředí.

Kompilátory, procesory a mezipaměti přeskupují pořadí operací programu, aby dosáhly vyššího výkonu. V posledních letech jsme zaznamenali obrovská vylepšení výpočetního výkonu. Zatímco vyšší taktovací frekvence procesoru podstatně přispěly k vyššímu výkonu, významným přispěvatelem byla také zvýšená paralelnost (v podobě zřetězených a superskalárních prováděcích jednotek, dynamického plánování instrukcí a spekulativního provádění a sofistikovaných víceúrovňových mezipamětí). Zároveň se úloha psaní překladačů stala mnohem komplikovanější, protože překladač musí programátora před těmito složitostmi chránit.

Při psaní programů s jedním podprocesem nemůžete vidět účinky těchto různých pořadí instrukcí nebo operací paměti. U programů s více podprocesy je však situace zcela odlišná - jedno vlákno může číst paměťová místa, která napsalo jiné vlákno. Pokud vlákno A upraví některé proměnné v určitém pořadí, při absenci synchronizace je vlákno B nemusí vidět ve stejném pořadí - nebo je vůbec nemusí vidět. To by mohlo nastat, protože kompilátor přeuspořádal pokyny nebo dočasně uložil proměnnou do registru a později ji zapsal do paměti; nebo proto, že procesor provedl instrukce paralelně nebo v jiném pořadí, než jaké specifikoval překladač; nebo proto, že pokyny byly v různých oblastech paměti a mezipaměť aktualizovala odpovídající umístění hlavní paměti v jiném pořadí, než ve kterém byly zapsány. Bez ohledu na okolnosti jsou vícevláknové programy ze své podstaty méně předvídatelné, pokud výslovně nezajistíte, aby vlákna měla konzistentní zobrazení paměti pomocí synchronizace.

Co ve skutečnosti znamená synchronizovaná?

Java zachází s každým vláknem, jako by běželo na svém vlastním procesoru s vlastní lokální pamětí, přičemž každý mluví a synchronizuje se sdílenou hlavní pamětí. I v systému s jedním procesorem má tento model smysl kvůli účinkům mezipaměti paměti a použití procesorových registrů k ukládání proměnných. Když vlákno upravuje umístění ve své místní paměti, měla by se tato změna nakonec zobrazit také v hlavní paměti a JMM definuje pravidla, kdy musí JVM přenášet data mezi místní a hlavní pamětí. Architekti Java si uvědomili, že příliš restriktivní paměťový model by vážně narušil výkon programu. Pokusili se vytvořit paměťový model, který by programům umožňoval dobrý výkon na moderním počítačovém hardwaru a přitom poskytoval záruky, které by vláknům umožňovaly předvídatelné způsoby interakce.

Primárním nástrojem Javy pro předvídatelné vykreslování interakcí mezi vlákny je synchronizované klíčové slovo. Mnoho programátorů myslí na synchronizované striktně z hlediska prosazování semaforu vzájemného vyloučení (mutex) aby se zabránilo spuštění kritických sekcí více než jedním vláknem najednou. Tato intuice bohužel úplně nepopisuje co synchronizované prostředek.

Sémantika synchronizované skutečně zahrnují vzájemné vyloučení provádění na základě stavu semaforu, ale zahrnují také pravidla týkající se interakce synchronizačního vlákna s hlavní pamětí. Zejména získání nebo uvolnění zámku spouští a paměťová bariéra - vynucená synchronizace mezi místní pamětí vlákna a hlavní pamětí. (Některé procesory - například Alpha - mají výslovné strojové pokyny pro provádění paměťových bariér.) Když vlákno končí a synchronizované blok, provede bariéru proti zápisu - před uvolněním zámku musí vyprázdnit všechny proměnné upravené v tomto bloku do hlavní paměti. Podobně při zadávání a synchronizované blok, provede bariéru čtení - je to, jako by byla zneplatněna místní paměť, a musí načíst všechny proměnné, na které se bude v bloku odkazovat z hlavní paměti.

Správné použití synchronizace zaručuje, že jedno vlákno uvidí účinky jiného předvídatelným způsobem. Pouze když se vlákna A a B synchronizují na stejném objektu, zajistí JMM, že vlákno B uvidí změny provedené vláknem A a že změny provedené vláknem A uvnitř synchronizované objeví se blok atomově na vlákno B (buď se provede celý blok, nebo žádný z nich.) Dále to JMM zajišťuje synchronizované bloky, které se synchronizují na stejném objektu, se budou zobrazovat ve stejném pořadí jako v programu.

Co je tedy na DCL rozbité?

DCL se spoléhá na nesynchronizované použití zdroj pole. To se zdá být neškodné, ale není. Abyste pochopili proč, představte si, že vlákno A je uvnitř synchronizované blok, provedení příkazu zdroj = nový zdroj (); zatímco vlákno B právě vstupuje getResource (). Zvažte vliv této inicializace na paměť. Paměť pro nové Zdroj objekt bude přidělen; konstruktor pro Zdroj bude volána a inicializuje pole členů nového objektu; a pole zdroj z SomeClass bude přiřazen odkaz na nově vytvořený objekt.

Protože však vlákno B neprovádí uvnitř a synchronizované bloku, může vidět tyto paměťové operace v jiném pořadí, než jaké provádí jedno vlákno A. Mohlo by se stát, že B uvidí tyto události v následujícím pořadí (a kompilátor také může změnit pořadí těchto pokynů): alokovat paměť, přiřadit odkaz na zdroj, zavolejte konstruktor. Předpokládejme, že vlákno B přijde po alokaci paměti a zdroj pole je nastaveno, ale před voláním konstruktoru. Vidí to zdroj není null, přeskočí synchronizované blok a vrátí odkaz na částečně zkonstruovaný Zdroj! Není nutné říkat, že výsledek není ani očekávaný, ani žádoucí.

Když se představí tento příklad, mnoho lidí je zpočátku skeptických. Mnoho vysoce inteligentních programátorů se pokusilo opravit DCL, aby fungoval, ale žádná z těchto údajně opravených verzí také nefunguje. Je třeba poznamenat, že DCL může ve skutečnosti fungovat na některých verzích některých JVM - protože jen málo JVM skutečně implementuje JMM správně. Nechcete však, aby se správnost vašich programů spoléhala na podrobnosti implementace - zejména chyby - specifické pro konkrétní verzi konkrétního JVM, který používáte.

Další rizika souběžnosti jsou vložena do DCL - a do jakéhokoli nesynchronizovaného odkazu na paměť zapsaného jiným vláknem, dokonce i neškodně vypadající čtení. Předpokládejme, že podproces A dokončil inicializaci Zdroj a opouští synchronizované blok, když vstupuje vlákno B. getResource (). Nyní Zdroj je plně inicializován a podproces A vyprázdní místní paměť ven do hlavní paměti. The zdrojPole polí mohou odkazovat na jiné objekty uložené v paměti prostřednictvím jeho členských polí, která budou také vyprázdněna. Zatímco vlákno B může vidět platný odkaz na nově vytvořené Zdroj, protože neprovádělo bariéru čtení, stále mohlo vidět zatuchlé hodnoty zdrojpole členů.

Volatile neznamená ani to, co si myslíte

Běžně navrhovanou nonfixem je deklarovat zdroj pole SomeClass tak jako nestálý. Přestože JMM brání tomu, aby byly změny pořadí těkavých proměnných vzájemně uspořádány a zajišťuje jejich okamžité vyprázdnění do hlavní paměti, stále umožňuje přeuspořádání čtení a zápisů těkavých proměnných s ohledem na stálá čtení a zápisy. To znamená - pokud všichni Zdroj pole jsou nestálý stejně - vlákno B může stále vnímat efekt konstruktoru tak, jak se to stane po zdroj je nastaven tak, aby odkazoval na nově vytvořené Zdroj.

Alternativy k DCL

Nejúčinnějším způsobem, jak opravit idiom DCL, je vyhnout se mu. Nejjednodušší způsob, jak se tomu vyhnout, je samozřejmě použít synchronizaci. Kdykoli se proměnná zapsaná jedním vláknem čte jiným, měli byste pomocí synchronizace zaručit, že úpravy budou viditelné pro ostatní vlákna předvídatelným způsobem.

Další možností, jak se vyhnout problémům s DCL, je upustit od líné inicializace a místo toho použít dychtivá inicializace. Spíše než odkládat inicializaci zdroj dokud nebude poprvé použit, inicializujte jej při konstrukci. Zavaděč tříd, který se synchronizuje na třídách Třída objekt, provede statické inicializační bloky v době inicializace třídy. To znamená, že účinek statických inicializátorů je automaticky viditelný pro všechna vlákna, jakmile se načte třída.