Избягвайте блокировките при синхронизация

В по-ранната ми статия „Заключване с двойна проверка: умно, но счупено“ ( JavaWorld,Февруари 2001 г.), описах как няколко често срещани техники за избягване на синхронизирането всъщност са опасни и препоръчах стратегия на „Когато се съмнявате, синхронизирайте“. По принцип трябва да синхронизирате всеки път, когато четете която и да е променлива, която може да е била написана преди това от друга нишка, или когато пишете някаква променлива, която впоследствие може да бъде прочетена от друга нишка. Освен това, докато синхронизацията носи наказание за изпълнение, наказанието, свързано с неограничената синхронизация, не е толкова голямо, както предполагат някои източници, и намалява постоянно с всяко следващо внедряване на JVM. Така че изглежда, че сега има по-малко причини от всякога да се избягва синхронизирането. Обаче друг риск е свързан с прекомерна синхронизация: блокиране.

Какво е задънена улица?

Казваме, че набор от процеси или нишки е блокиран, когато всяка нишка чака събитие, което само друг процес в набора може да причини. Друг начин за илюстриране на задънена улица е да се изгради насочена графика, чиито върхове са нишки или процеси и чиито ръбове представляват връзката „чака се“. Ако тази графика съдържа цикъл, системата е блокирана. Освен ако системата не е проектирана да се възстанови от блокировки, блокирането кара програмата или системата да увисне.

Блокирания при синхронизация в Java програми

В Java могат да възникнат synchronizedблокировки, тъй като ключовата дума кара блокиращата изпълняваща нишка, докато чака заключването или монитора, свързани с посочения обект. Тъй като нишката може вече да съдържа ключалки, свързани с други обекти, всяка от двете нишки може да чака другата да освободи ключалка; в такъв случай те в крайна сметка ще чакат вечно. Следващият пример показва набор от методи, които имат потенциал за блокиране. И двата метода придобиват ключалки на два обекта на заключване cacheLockи tableLockпреди да продължат. В този пример обектите, действащи като ключалки, са глобални (статични) променливи, обща техника за опростяване на поведението при заключване на приложения чрез извършване на заключване на по-грубо ниво на детайлност:

Листинг 1. Потенциално блокиране на синхронизацията

публичен статичен обект cacheLock = нов обект (); публичен статичен обект tableLock = нов обект (); ... публична невалидна oneMethod () {синхронизирано (cacheLock) {синхронизирано (tableLock) {doSomething (); }}} public void anotherMethod () {синхронизирано (tableLock) {синхронизирано (cacheLock) {doSomethingElse (); }}}

Сега, представете си, че нишка A се обажда, oneMethod()докато нишка B едновременно се обажда anotherMethod(). Представете си по-нататък, че нишка A придобива ключалката cacheLockи, в същото време, нишка B получава ключалката tableLock. Сега нишките са блокирани: нито една нишка няма да се откаже от ключалката си, докато не придобие другата ключалка, но нито една от тях няма да може да придобие другата ключалка, докато другата нишка не се откаже от нея. Когато Java програма блокира, блокиращите нишки просто чакат вечно. Докато други нишки може да продължат да се изпълняват, в крайна сметка ще трябва да убиете програмата, да я рестартирате и да се надявате, че тя отново няма блокиране.

Тестването за блокировки е трудно, тъй като блокировките зависят от времето, натоварването и околната среда и поради това могат да се случват рядко или само при определени обстоятелства. Кодът може да има потенциал за блокиране, като Листинг 1, но да не показва блокиране, докато настъпи някаква комбинация от случайни и неслучайни събития, като например програмата да бъде подложена на определено ниво на натоварване, да се изпълни на определена хардуерна конфигурация или да бъде изложена на определена комбинация от действия на потребителя и условия на околната среда. Безизходиците приличат на бомби със закъснител, които чакат да експлодират в нашия код; когато го направят, нашите програми просто увисват.

Непоследователното подреждане на заключване причинява блокировки

За щастие можем да наложим сравнително просто изискване за придобиване на заключване, което може да предотврати блокирането на синхронизацията. Методите от Листинг 1 имат потенциал за блокиране, тъй като всеки метод придобива двете ключалки в различен ред. Ако Листинг 1 беше написан така, че всеки метод да придобие двете ключалки в един и същ ред, две или повече нишки, изпълняващи тези методи, не можеха да блокират, независимо от времето или други външни фактори, тъй като никоя нишка не можеше да придобие втората ключалка, без вече да държи първо. Ако можете да гарантирате, че ключалките винаги ще бъдат получени в последователен ред, тогава вашата програма няма да блокира.

Безизходиците не винаги са толкова очевидни

След като се приспособите към важността на поръчката за заключване, можете лесно да разпознаете проблема в Листинг 1. Аналогичните проблеми обаче могат да се окажат по-малко очевидни: може би двата метода се намират в отделни класове, или може би засегнатите ключалки се придобиват неявно чрез извикване на синхронизирани методи, вместо изрично чрез синхронизиран блок. Помислете за тези два взаимодействащи класа Modelи Viewв опростена MVC (Model-View-Controller) рамка:

Листинг 2. По-фино потенциално блокиране на синхронизацията

модел от публичен клас {private View myView; публично синхронизиран празен updateModel (Object someArg) {doSomething (someArg); myView.somethingChanged (); } публично синхронизиран обект getSomething () {return someMethod (); }} публичен клас Изглед {частен модел underlyingModel; публично синхронизирано void somethingChanged () {doSomething (); } публично синхронизирана void updateView () {Обект o = myModel.getSomething (); }}

Листинг 2 има два съдействащи си обекта, които имат синхронизирани методи; всеки обект извиква синхронизираните методи на другия. Тази ситуация прилича на Листинг 1 - два метода придобиват ключалки на едни и същи два обекта, но в различни подредби. Въпреки това, непоследователното подреждане на заключване в този пример е много по-очевидно от това в Листинг 1, тъй като придобиването на заключване е неявна част от извикването на метода. Ако една нишка се обажда, Model.updateModel()докато друга нишка се обажда едновременно View.updateView(), първата нишка може да получи Modelзаключването на 'и да изчака заключването на View', докато другата получава Viewключалката и чака завинаги Modelзаключването на '.

Можете да погребате потенциала за блокиране на синхронизацията още по-дълбоко. Помислете за този пример: Имате метод за прехвърляне на средства от една сметка в друга. Искате да придобиете ключалки на двата акаунта, преди да извършите прехвърлянето, за да сте сигурни, че прехвърлянето е атомно. Помислете за това безобидно изпълнение:

Листинг 3. Още по-фино потенциално блокиране на синхронизацията

 public void transferMoney (Account fromAccount, Account toAccount, DollarAmount amountToTransfer) {synchronized (fromAccount) {synchronized (toAccount) {if (fromAccount.hasSufficientBalance (amountToTransfer) {fromAccount.debit (amountToTransfer); } 

Дори ако всички методи, които работят с два или повече акаунта, използват една и съща поръчка, Листинг 3 съдържа семената на същия проблем с безизходица като Листинги 1 и 2, но по още по-фин начин. Помислете какво се случва, когато нишката А се изпълни:

 transferMoney (accountOne, accountTwo, сума); 

Докато едновременно с това нишката B изпълнява:

 transferMoney (accountTwo, accountOne, anotherAmount); 

Отново двете нишки се опитват да придобият едни и същи две ключалки, но в различен ред; рискът от задънена улица все още се очертава, но в много по-малко очевидна форма.

Как да избегнем блокировки

Един от най-добрите начини да се предотврати възможността за блокиране е да се избегне придобиването на повече от една ключалка наведнъж, което често е практично. Ако обаче това не е възможно, имате нужда от стратегия, която гарантира, че придобивате множество ключалки в последователен, дефиниран ред.

В зависимост от това как вашата програма използва ключалки, може да не е сложно да се гарантира, че използвате последователен ред за заключване. В някои програми, като например в Листинг 1, всички критични ключалки, които могат да участват в множество заключвания, се извличат от малък набор от единични обекти за заключване. В този случай можете да дефинирате подреждането на придобиване на ключалки в набора от брави и да се уверите, че винаги получавате ключалки в този ред. След като редът на заключване е дефиниран, той просто трябва да бъде добре документиран, за да насърчи последователната употреба в цялата програма.

Свийте синхронизираните блокове, за да избегнете многократно заключване

В листинг 2 проблемът се усложнява, тъй като в резултат на извикване на синхронизиран метод, заключванията се придобиват имплицитно. Обикновено можете да избегнете вида потенциални блокировки, които произтичат от случаи като Листинг 2, като стесните обхвата на синхронизацията до възможно най-малък блок. Дали Model.updateModel()наистина трябва да държи Modelключалката, докато го наричаView.somethingChanged()? Често не го прави; целият метод вероятно е бил синхронизиран като пряк път, а не защото целият метод е трябвало да бъде синхронизиран. Ако обаче замените синхронизираните методи с по-малки синхронизирани блокове вътре в метода, трябва да документирате това поведение на заключване като част от Javadoc на метода. Обаждащите се трябва да знаят, че могат безопасно да извикат метода без външна синхронизация. Повикващите също трябва да знаят поведението на метода за заключване, за да могат да гарантират, че ключалките се получават в последователен ред.

По-сложна техника за подреждане на брави

В други ситуации, като примера за банкова сметка в Листинг 3, прилагането на правилото с фиксирана поръчка става още по-сложно; трябва да определите обща поръчка за набора от обекти, допустими за заключване, и да използвате тази поръчка, за да изберете последователността на придобиване на заключване. Това звучи разхвърляно, но всъщност е просто. Листинг 4 илюстрира тази техника; той използва цифров номер на сметката, за да предизвика подреждане на Accountобекти. (Ако в обекта, който трябва да заключите, липсва естествено свойство за идентичност като номер на акаунт, Object.identityHashCode()вместо това можете да използвате метода, за да генерирате такъв.)

Листинг 4. Използвайте поръчка за придобиване на брави във фиксирана последователност

public void transferMoney (Account fromAccount, Account toAccount, DollarAmount amountToTransfer) {Account firstLock, secondLock; if (fromAccount.accountNumber () == toAccount.accountNumber ()) хвърли ново изключение ("Не може да се прехвърли от акаунт към себе си"); иначе ако (отAccount.accountNumber () <toAccount.accountNumber ()) {firstLock = отAccount; secondLock = toAccount; } else {firstLock = toAccount; secondLock = отAccount; } синхронизиран (firstLock) {синхронизиран (secondLock) {if (fromAccount.hasSufficientBalance (amountToTransfer) {fromAccount.debit (amountToTransfer); toAccount.credit (amountToTransfer);}}}}

Сега редът, в който сметките са посочени в повикването, transferMoney()няма значение; ключалките винаги се придобиват в същия ред.

Най-важната част: Документация

Критичен - но често пренебрегван - елемент от всяка стратегия за заключване е документацията. За съжаление, дори в случаите, когато се полагат много грижи за разработване на стратегия за заключване, често се отделят много по-малко усилия за документирането ѝ. Ако вашата програма използва малък набор от единични брави, трябва да документирате вашите предположения за подреждане на заключване възможно най-ясно, така че бъдещите поддържащи да могат да отговорят на изискванията за подреждане на заключванията. Ако методът трябва да получи заключване, за да изпълнява функцията си, или трябва да бъде извикан със специфично заключване, Javadoc на метода трябва да отбележи този факт. По този начин бъдещите разработчици ще знаят, че извикването на даден метод може да доведе до придобиване на заключване.

Малко програми или библиотеки на класове документират адекватно използването им за заключване. Най-малко всеки метод трябва да документира ключалките, които придобива и дали повикващите трябва да държат ключалка, за да извикат метода безопасно. Освен това класовете трябва да документират дали те не са безопасни или при какви условия.

Фокусирайте се върху поведението на заключване по време на проектиране

Тъй като блокировките често не са очевидни и се случват рядко и непредсказуемо, те могат да причинят сериозни проблеми в Java програмите. Като обръщате внимание на поведението на заключване на вашата програма по време на проектиране и дефиниране на правила за това кога и как да придобиете множество ключалки, можете значително да намалите вероятността от блокировки. Не забравяйте да документирате внимателно правилата за придобиване на заключване на вашата програма и използването на синхронизация внимателно; времето, прекарано в документиране на прости заключения за заключване, ще се изплати, като значително намали вероятността от блокиране и други проблеми, свързани с паралелността по-късно.

Брайън Гьотц е професионален разработчик на софтуер с повече от 15 години опит. Той е главен консултант в Quiotix, фирма за разработка на софтуер и консултации, разположена в Лос Алтос, Калифорния.