Programování

Vyhněte se zablokování synchronizace

V mém dřívějším článku „Double-Checked Locking: Clever, but Broken“ (JavaWorld, Únor 2001), popsal jsem, jak je několik běžných technik, jak se vyhnout synchronizaci, ve skutečnosti nebezpečných, a doporučil jsem strategii „Pokud máte pochybnosti, synchronizujte“. Obecně byste měli synchronizovat, kdykoli čtete libovolnou proměnnou, která mohla být dříve zapsána jiným vláknem, nebo kdykoli píšete libovolnou proměnnou, která by mohla být následně přečtena jiným vláknem. Navíc, zatímco synchronizace přináší výkonnostní trest, pokuta spojená s nekontrolovanou synchronizací není tak velká, jak navrhují některé zdroje, a stabilně se snižuje s každou následnou implementací JVM. Zdá se tedy, že nyní existuje méně důvodů než kdy jindy vyhnout se synchronizaci. S nadměrnou synchronizací je však spojeno další riziko: zablokování.

Co je zablokování?

Říkáme, že sada procesů nebo vláken je zablokovaný když každé vlákno čeká na událost, kterou může způsobit pouze jiný proces v sadě. Dalším způsobem, jak ilustrovat zablokování, je vytvořit směrovaný graf, jehož vrcholy jsou vlákna nebo procesy a jejichž hrany představují vztah „čeká na“. Pokud tento graf obsahuje cyklus, systém se zablokuje. Pokud není systém navržen tak, aby se zotavil ze zablokování, zablokování způsobí zablokování programu nebo systému.

Zablokování synchronizace v programech Java

Zablokování může nastat v Javě, protože synchronizované klíčové slovo způsobí blokování provádějícího vlákna při čekání na zámek nebo monitor spojený se zadaným objektem. Vzhledem k tomu, že vlákno již může obsahovat zámky spojené s jinými objekty, mohla by každá vlákna čekat, až druhá uvolní zámek; v takovém případě nakonec čekají navždy. Následující příklad ukazuje sadu metod, které mají potenciál zablokování. Obě metody získávají zámky na dvou zámkových objektech, cacheLock a stolní zámek, než budou pokračovat. V tomto příkladu jsou objekty fungující jako zámky globální (statické) proměnné, běžná technika pro zjednodušení chování zamykání aplikací prováděním zamykání na hrubší úrovni zrnitosti:

Výpis 1. Potenciální zablokování synchronizace

 public static Object cacheLock = new Object (); public static Object tableLock = new Object (); ... public void oneMethod () {synchronized (cacheLock) {synchronized (tableLock) {doSomething (); }}} public void anotherMethod () {synchronized (tableLock) {synchronized (cacheLock) {doSomethingElse (); }}} 

Nyní si představte, že vlákno A volá oneMethod () zatímco vlákno B současně volá anotherMethod (). Představte si dále, že vlákno A získá zámek cacheLocka současně vlákno B získá zámek stolní zámek. Nyní jsou vlákna zablokovaná: žádné vlákno se nevzdá svého zámku, dokud nezíská druhý zámek, ale ani nebude moci získat další zámek, dokud se ho druhé vlákno nevzdá. Když se program Java zablokuje, vlákna zablokování jednoduše čekají navždy. Zatímco ostatní vlákna mohou pokračovat v běhu, nakonec budete muset program zabít, restartovat a doufat, že se znovu nezablokuje.

Testování zablokování je obtížné, protože zablokování závisí na načasování, zatížení a prostředí, a proto se může stát zřídka nebo pouze za určitých okolností. Kód může mít potenciál zablokování, jako je Výpis 1, ale nemusí vykazovat zablokování, dokud nedojde k nějaké kombinaci náhodných a náhodných událostí, jako je například vystavení programu určité úrovni zatížení, spuštění určité hardwarové konfigurace nebo vystavení určité kombinace akcí uživatelů a podmínek prostředí. Zablokování připomíná časované bomby, které čekají na výbuch v našem kódu; když ano, naše programy prostě visí.

Nekonzistentní řazení zámku způsobí zablokování

Naštěstí můžeme na pořízení zámku uložit relativně jednoduchý požadavek, který může zabránit zablokování synchronizace. Metody seznamu 1 mají potenciál zablokování, protože každá metoda získává dva zámky v jiném pořadí. Pokud byl výpis 1 napsán tak, aby každá metoda získala dva zámky ve stejném pořadí, nemohla by dvě nebo více podprocesů provádějících tyto metody uváznout bez ohledu na načasování nebo jiné externí faktory, protože žádný podproces nemohl získat druhý zámek, aniž by již držel První. Pokud můžete zaručit, že zámky budou vždy získány v konzistentním pořadí, nebude váš program uváznut.

Zablokování není vždy tak zřejmé

Jakmile jste naladěni na důležitost objednávání zámků, můžete snadno rozpoznat problém Výpisu 1. Analogické problémy se však mohou ukázat méně zřejmé: možná jsou tyto dvě metody umístěny v samostatných třídách, nebo se zámky mohou získat implicitně voláním synchronizovaných metod namísto explicitně prostřednictvím synchronizovaného bloku. Zvažte tyto dvě spolupracující třídy, Modelka a Pohledve zjednodušeném rámci MVC (Model-View-Controller):

Výpis 2. Jemnější zablokování potenciální synchronizace

 public class Model {private View myView; public synchronized void updateModel (Object someArg) {doSomething (someArg); myView.somethingChanged (); } veřejný synchronizovaný objekt getSomething () {return someMethod (); }} veřejná třída Zobrazit {soukromý model podkladový model; public synchronized void somethingChanged () {doSomething (); } veřejná synchronizovaná void updateView () {Object o = myModel.getSomething (); }} 

Výpis 2 má dva spolupracující objekty, které mají synchronizované metody; každý objekt volá synchronizované metody toho druhého. Tato situace se podobá výpisu 1 - dvě metody získávají zámky na stejných dvou objektech, ale v různých pořadích. Nekonzistentní uspořádání zámku v tomto příkladu je však mnohem méně zřejmé než v seznamu 1, protože získání zámku je implicitní součástí volání metody. Pokud volá jedno vlákno Model.updateModel () zatímco současně volá jiné vlákno View.updateView (), první vlákno mohlo získat Modelkazámek a počkejte na Pohledzámek, zatímco druhý získá Pohledje zámek a čeká navždy na Modelkazámek.

Potenciál zablokování synchronizace můžete pohřbít ještě hlouběji. Zvažte tento příklad: Máte způsob převodu prostředků z jednoho účtu na druhý. Chcete provést zámky na obou účtech před provedením přenosu, abyste se ujistili, že je přenos atomový. Zvažte tuto neškodně vypadající implementaci:

Výpis 3. Ještě jemnější zablokování potenciální synchronizace

 public void transferMoney (Účet z účtu, Účet na účet, DollarAmount částkaToTransfer) {synchronizovaný (z účtu) {synchronizovaný (na účet) {if (z účtu.hasSufficientBalance (amountToTransfer) {fromAccount.debit (amountToTransfer); toAccount.credit (amountToTransfer); } 

I když všechny metody, které fungují na dvou nebo více účtech, používají stejné řazení, výpis 3 obsahuje zárodky stejného problému zablokování jako výpisy 1 a 2, ale ještě jemnějším způsobem. Zvažte, co se stane, když se vlákno A provede:

 transferMoney (accountOne, accountTwo, částka); 

Ve stejnou dobu provede vlákno B:

 transferMoney (accountTwo, accountOne, anotherAmount); 

Znovu se dvě vlákna pokoušejí získat stejné dva zámky, ale v různých pořadích; riziko zablokování stále existuje, ale v mnohem méně zjevné formě.

Jak se vyhnout zablokování

Jedním z nejlepších způsobů, jak zabránit potenciálnímu zablokování, je vyhnout se získání více než jednoho zámku najednou, což je často praktické. Pokud to však není možné, potřebujete strategii, která zajistí získání více zámků v konzistentním, definovaném pořadí.

V závislosti na tom, jak váš program používá zámky, nemusí být komplikované zajistit, abyste používali konzistentní pořadí uzamčení. V některých programech, například v Seznamu 1, jsou všechny kritické zámky, které se mohou účastnit vícenásobného zamykání, kresleny z malé sady objektů zámku singleton. V takovém případě můžete na sadě zámků definovat řazení pořízení zámku a zajistit, že zámky získáte vždy v tomto pořadí. Jakmile je pořadí zámků definováno, musí být jednoduše dobře zdokumentováno, aby se podpořilo důsledné používání v celém programu.

Zmenšete synchronizované bloky, abyste zabránili vícenásobnému uzamčení

V seznamu 2 se problém komplikuje, protože v důsledku volání synchronizované metody se zámky získávají implicitně. Obvykle se můžete vyhnout druhu potenciálních zablokování, která vyplývají z případů, jako je Výpis 2, zúžením rozsahu synchronizace na co nejmenší blok. Ano Model.updateModel () opravdu musíte držet Modelka zamknout, zatímco volá View.somethingChanged ()? Často tomu tak není; celá metoda byla pravděpodobně synchronizována jako zkratka, spíše než proto, že byla potřeba synchronizovat celou metodu. Pokud však nahradíte synchronizované metody menšími synchronizovanými bloky uvnitř metody, musíte dokumentovat toto chování uzamčení jako součást Javadoc metody. Volající musí vědět, že mohou bezpečně volat metodu bez externí synchronizace. Volající by také měli znát chování zamykání metody, aby mohli zajistit, aby se zámky získávaly v konzistentním pořadí.

Propracovanější technika objednávání zámků

V jiných situacích, jako je příklad bankovního účtu Výpisu 3, je použití pravidla pevné objednávky ještě komplikovanější; musíte definovat celkové pořadí na sadě objektů způsobilých k uzamčení a pomocí tohoto uspořádání zvolit sekvenci získávání zámku. Zní to chaoticky, ale ve skutečnosti je to přímé. Výpis 4 ilustruje tuto techniku; k vyvolání objednávky používá číselné číslo účtu Účet předměty. (Pokud objektu, který potřebujete zamknout, chybí vlastnost přirozené identity, jako je číslo účtu, můžete použít Object.identityHashCode () místo toho vygenerujte jednu.)

Výpis 4. Použijte objednávku k získání zámků v pevném pořadí

 public void transferMoney (Účet z účtu, Účet na účet, DollarAmount částka na převod) {Účet firstLock, secondLock; if (fromAccount.accountNumber () == toAccount.accountNumber ()) vyvolá novou výjimku ("Nelze převést z účtu na sebe"); else if (fromAccount.accountNumber () <toAccount.accountNumber ()) {firstLock = fromAccount; secondLock = toAccount; } else {firstLock = toAccount; secondLock = fromAccount; } synchronized (firstLock) {synchronized (secondLock) {if (fromAccount.hasSufficientBalance (amountToTransfer) {fromAccount.debit (amountToTransfer); toAccount.credit (amountToTransfer);}}} 

Nyní pořadí, ve kterém jsou účty uvedeny ve výzvě Převést peníze() na tom nezáleží; zámky se získávají vždy ve stejném pořadí.

Nejdůležitější část: Dokumentace

Kritickým - ale často přehlíženým - prvkem jakékoli strategie zamykání je dokumentace. Bohužel i v případech, kdy je věnována velká pozornost návrhu strategie zamykání, je často zdokumentováno mnohem menší úsilí. Pokud váš program používá malou sadu singletonových zámků, měli byste co nejjasněji zdokumentovat své předpoklady řazení zámků, aby budoucí správci mohli splnit požadavky na uspořádání zámku. Pokud metoda musí získat zámek, aby mohla vykonávat svou funkci, nebo musí být volána s konkrétním drženým zámkem, Javadoc metody by si měl tuto skutečnost všimnout. Budoucí vývojáři tak budou vědět, že volání dané metody může znamenat získání zámku.

Několik programů nebo knihoven tříd dostatečně dokumentuje jejich použití zamykání. Minimálně by každá metoda měla dokumentovat zámky, které získává, a zda volající musí držet zámek, aby bezpečně volala metodu. Třídy by navíc měly dokumentovat, zda jsou či nejsou, nebo za jakých podmínek jsou bezpečné pro vlákna.

Zaměřte se na chování zamykání v době návrhu

Protože zablokování často není zřejmé a dochází k němu zřídka a nepředvídatelně, mohou v programech Java způsobit vážné problémy. Tím, že budete věnovat pozornost chování zamykání vašeho programu v době návrhu a definujete pravidla, kdy a jak získat více zámků, můžete výrazně snížit pravděpodobnost zablokování. Nezapomeňte pečlivě zdokumentovat pravidla získávání zámku vašeho programu a jeho použití synchronizace; čas strávený dokumentováním jednoduchých předpokladů uzamčení se vyplatí tím, že se později výrazně sníží šance na zablokování a další problémy se souběžností.

Brian Goetz je profesionální vývojář softwaru s více než 15 lety zkušeností. Je hlavním konzultantem ve společnosti Quiotix, softwarové a poradenské společnosti se sídlem v Los Altos v Kalifornii.