JE_48 - Synchronizujte přístup ke sdíleným měnitelným datům

Klíčové slovo synchronized zajišťuje, že nějaký příkaz nebo blok vykoná v jeden okamžik jen jedno vlákno. Mnoho programátorů považuje synchronizaci jen za prostředek vzájemné výlučnosti zabra�ující sledování objektu v nekonzistentním stavu, kdy je upravován jiným vláknem. Při tomto pohledu se objekt vytváří v konzistentním stavu (rada 13) a je uzamčený metodami, které k němu přistupují. Tyto metody sledují stav a volitelně způsobují přechod stavu, řičemž transformují daný objekt z jednoho konzistentního stavu do jiného. Správné použití synchronizace zaručuje, že žádná metoda nespatří daný objekt v nekonzistentním stavu.

Tento názor je správný, ale není úplný. Nejenže synchronizace zabra�uje vláknu v pozorování nějakého objektu v nekonzistentním stavu, ale rovněž zajišťuje, že objekty postupují z jednoho konzistentního stavu do druhého seřazenou sekvencí přechodů stavu, které se zdánlivě vykonávají sekvenčně. Každé vlákno vstupující do synchronizované metody nebo bloku vidí efekty včech předchozích změn stavu řízených stejným zámkem. Jakmile nějaké vlákno opustí synchronizovaný region, pak každé vlákno, jež vstoupí do regionu synchronizovaného stejným zámkem, uvidí přechod stavu způsobený daným vláknem, pokud nějaký existuje.

Jazyk zaručuje, že čtení nebo zápis jedné proměnné je atomický, pokud se nejedná a proměnnou typu long nebo double. Jinými slovy, čtení proměnné jiného typu než long nebo double zaručeně vrátí hodnotu uloženou do této proměnné nějakým vláknem, i když více vláken mění danou proměnnou souběžně bez synchronizace.

Občas můžete slyšet, že chcete-li zvýšit výkonnost, měli byste se vyhýbat používání synchronizace při čtení nebo zápisu atomických dat. Tato rada je nebezpečně špatná. Třebaže atomičnost zaručuje, že vlákno neuvidí náhodnou hodnotu při čtení atomických dat, nezaručuje, že hodnota zapsaná jedním vláknem bude viditelná jiným vláknem: Ke spolehlivé komunikaci mezi vlákny je zapotřebí synchronizace stejně jako ke vzájemnému vyloučení. To je důsledek poměrně technického aspektu programovacího jazyka Java označovaného za paměťový model [JLS, 17]. Třebaže paměťový model pravděpodobně projde zásadní změnou v další verzi [Pugh01a], je téměř jisté, že se tato skutečnost nezmění.

Následky nedostatečné synchronizace přístupu k nějaké sdílené proměnné mohou být nepříjemné, i když je daná proměnná atomicky zapisovatelná a čitelná. Zvažme následující nástroj generování pořadového čísla:

// Chyba - vyžaduje synchronizaci!
private static int nextSerialNumber = 0;

public static int generateSerialNumber() {
	return nextSerialNumber++;
}

Smyslem této metody je zaručit, že každé volání metody generateSerialNumber vygeneruje jiné pořadové číslo, pokud těch volání není více než 232. K ochraně generátoru pořadových čísel není zapotřebí synchronizace, protože žádné invarianty nemá; jeho stav sestává z jediného atomicky zapisovatelného atributu (nextSerialNumber) a všechny možné hodnoty tothot atribut jsou platné. Metoda však přesto bez synchronizace nefunguje. Operátor inkrementace (++) jak čte, tak i zapisuje atribut nextSerialNumber, takže není atomický. Čtení a zápis jsou nezávislé operace vykonávané po sobě. Více souběžných vláken tedy může zpozorovat atribut nextSerialNumber se stejnou hodnotou a vrátit stejné pořadové číslo.

Ještě překvapivější je, že jedno vlákno může opakovaně volat generateSerialNumber a získat tak sekvenci pořadových čísel od nuly do n, načež jiné vlákno zavolá generateSerialNumber a získá pořadové číslo nula. Bez synchronizace nemusí druhé vlákno spatřit žádnou z aktualizací zadaných prvním vláknem. To je důsledek již zmíněného paměťového modelu.

Oprava metody generateSerialNumber je jednoduchá - stačí do její deklarace přidat modifikátor synchronized. Ten zajistí, že se více volání nebude překrývat a že každé volání uvidí efekt všech předchozích volání. Chcete-li metodu dokonale obrnit, může být také vhodné použít typ long namísto int nebo vyvolat výjimku, kdyby mělo dojít k přetečení nextSerialNumber.

Dále zvažme proces zastavení vlákna. Třebaže platforma poskytuje metody nedobrovolného zastavení nějakého vlákna, používání těchto metod se nedoporučuje, protože jsou svou podstatou nezabezpečené - jejich použití může mít za následek poškození objektu. Doporučovanou metodou zastavení vlákna je prostě zajistit, aby se vlákno dotazovalo na nějaký atribut, jehož hodnotu lze změnit a indikovat tak, že se má vlákno samo zastavit. Tento atribut je obvykle typu boolean nebo odkazem na objekt. Protože je čtení takového atributu atomické, někteří programátoři mají tendenci nezabývat se při přístupu k tomuto atribut synchronizací. Proto není nezvyklé setkat se s kódem podobným tomuto:

// Chyba - vyžaduje synchronizaci!
public class StoppableThread extends Thread {
	private boolean stopRequested =  false;

	public void run() {
		boolean done = false;

		while (!stopRequested && !done) {
			. . . // Učirnit co je zapotřebí
		}
	}

	public void requestStop() {
		stopRequested  = true;
	}
}

Problém s tímto kódem spočívá v tom, že bez synchronizace není zaručeno,kdy (pokud vůbec někdy) "uvidí" zastavitelné vlákno změnu hodnoty stopRequested, která byla učiněna jiným vláknem. Výsyledkem je, že metoda requestStop může být naprosto neúčinná. Pokud nepracujete na více procesorovém systému, pak si v praxi tohoto problematického chování asi nevšimenete, nemáte však nic zaručené. Přímočará oprava tohoto problému spočívá v zajištění synchronizace veškerého přístupu k atributu stopRequested:

// �ádně synchronizované kooperativní ukončení vlákna
public class StoppableThread extends Thread {
	private boolean stopRequested = false;

	public void run() {
		boolean done = false;

		while (!stopRequested() && !done) {
			. . . // Učinit co je zapotřebí
		}
	}

	public synchronized void requestStop() {
		stopRequested = true;
	}

	private synchronized boolean stopRequested() {
		return stopRequested;
	}
}

Všimněte si, že akce všech synchronizovaných metod jso atomické: Synchronizace se používá čistě kvůli komunikaci a nikoli kvůli vzájemnému vyloučení. Je zřejmé, že opravený kód funguje, a náklady na synchronizování při každé iteraci smyčky nebudou pravděpodobně postižitelné. Přesto však existuje správná alternativa, která je trochu méně opisná a jejíž výkonnost může být trochu vyšší. Synchronizaci lze vypustit, pkud je metoda stopRequested deklarovaná jako volatile. Modifikátor volatile zaručuje, že každé vlákno čtoucí nějaký atribut uvidí naposledy zapsanou hodnotu.

Pokuta za nezajištění synchronizace přístupu k stopRequested v předchozím příkladu je relativně malá; vliv metody requestStop se může protáhnout do nekonečna. Trest za nezajištění synchronizace přístupu k měnitelným sdíleným datům může být mnohem větší. Zvažme idiom dvojnásobné kontroly při odložené inicializaci:

// idiom vojité kontroly při odložené inicializaci - chybný!
private static Foo foo = null;

public static Foo getFoo() {
	if (foo == null) {
		synchronized (Foo.class) {
			if (foo == null)
				foo = new Foo();
		}
	}
	return foo;
}

Cílem tohoto idiomu je vhnout se nákladům na synchronizaci při běžném přístupu k atributu (foo) po jeho inicializaci. Synchronizace se používá pouze k tomu, aby nemohlo atribut inicializovat více vláken. Tento idiom zaručuje, že daný atribut bude inicializován nejvýše jednou a že všechna vlákna volající getFoo obdrží správnou hodnotu odkazu na objekt. Bohužel však není zaručeno správné fungování odkazu na objekt. Pokud nějaké vlákno přečte odkaz bez synchronizace a pak zavolá metodu odkazovaného objektu, může daná metoda zastihnout objekt v částečně inicializovaném stavu a katastrofálně selhat.

Skutečnost, že nějaké vlákno může zpozorovat odloženě inicializovaný objekt v částečně inicializovaném stavu, je hrubě neintuitivní. Objekt je plně zkonstruován ještě před "publikováním" odkazu do atributu, z něhož se čte dalšími vlákny (foo). Pokud však chybí synchronizace, pak čtení "publikovaného" odkazu na objekt nezaručuje, že nějaké vlákno uvidí všechna data uložená do paměti před publikováním odkazu na daný objekt. Čtení publikovaného odkazu na objekt především nezaručuje, že čtoucí vlákno uvidí nejnovější hodnoty dat, jež tvoří vnitřek odkazovaného objektu. Idiom dvojnásobné kontroly obecně nefunguje, třebaže pracuje v situaci, kdy daná sdílená proměnná obsahuje nějakou primitivní hodnotu namísto odkazu na objekt [Pugh01b].

Tento problém lze vyřešit několika způsoby. Nejsnazší je úplně se zbavit odložené inicializace:

// Normální statická inicializace (nikoli odložená)
private static final Foo foo = new Foo();

public static Foo getFoo() {
	return foo;
}

To jistě funguje a metoda getFoo je tak rychlá jek jen může být. Nic nesynchronizuje, ani nepočítá. Jak bylo řečeno v radě 37, měli byste vytvářet jednoduché, jasné a správné programy a optimalizaci ponechat až na konec a to ještě jen pro případ, kdy měření potvrdí její nezbytnost. Proto je obvykle nejlepší obejít se bez odložených inicializací. Pokud nepoužijete odloženou inicializaci, změříte náklady a zjistíte, že představuje výrazné omezení, pak je vhodné použít k vykonání odložené inicializace nějakou řádně synchronizovanou metodu:

// �ádně synchronizovaná odložená inicializace
private static Foo foo = null;

public static synchronized Foo getFoo() {
	if (foo == null)
		foo = new Foo();
	return foo;
}

Tato metoda bude zaručeně fungovat, každé její volání však zahrnuje náklady na synchronizaci. V moderních implementacích JVM jsou tyto náklady relativně malé. Pokud však měření výkonnosti vašeho systému prokáže, že si nemůžete dovolit ani náklady na normální inicialiazci, ani náklady na synchronizování každého přístupu, pak máte ještě jednu možnost. idiom držitelské třídy inicializace podle potřeby. Ten lze použít, když je inicializace statického atributu náročná a nemusí být zapotřebí. Je-li ale zapotřebí, bude se intenzivně používat. Tento idiom je ukázán dále:

// Idiom držitelské třídy inicializace podle potřeby
private static class FooHolder {
	static final Foo foo = new Foo();
}

public static Foo getFoo() {
	return FooHolder.foo;
}

Tento idiom využívá záruky, že třída nebude inicializovaná, dokud není použita [JLS, 12.4.1]. Když je metoda getFoo volána poprvé, přečte atribut FooHolder.foo, čímž způsobí inicializaci třídy FooHolder. Krása tohto idiomu spočívá v tom, že metoda getFoo není synchronizovaná a zajišťuje pouze přístup k atributu, takže odložená inicializace nepředstavuje prakticky žádné další náklady na přístup. Jedinou nevýhodou tohto idiomu je to, že nefunguje v případě atributů instancí, ale pouze u statických atributů.

Obecně platí, že kdykoli více vláken sdílí měnitelná data, pak musí každé vlákno, které čte nebo zapisuje daná data, obdržet nějaký zámek. NedpousTťe, aby vás záruky atomického čtení a zápisu odradily od azjištění řádné synchronizace. Bez synchronizace nemáte zaručeno, které změny jednoho vlákna budou zpozorované jiným vláknem, pokud vůbec nějaké. Nesynchronizovaný přístup k datům může mít za následek selhání životaschopnosti a zabezpečení. Taková selhání se budou jen velmi těžko reprodukovat. Mohou záviset na časování a výrazně budou záviset na detailech implementace JVM a hardwaru, na kterém běží.

Za určitých podmínek představuje použití modifikátoru volatile rozumnou alternativu normální synchronizace, jená se však o pokročilou techniku. Navíc rozsah její aplikovatelnosti nebude známý až do dokončení probíhajících prací na paměťovém modelu.