Izbjegavajte zastoje u sinkronizaciji

U mom ranijem članku " Dvoprovjereno zaključavanje: pametno, ali slomljeno" ( JavaWorld,Veljače 2001.), opisao sam kako je nekoliko uobičajenih tehnika za izbjegavanje sinkronizacije zapravo nesigurno, te preporučio strategiju "Kad sumnjate, sinkronizirajte". Općenito, trebali biste se sinkronizirati kad čitate bilo koju varijablu koja je možda prethodno napisana nekom drugom niti, ili kad god pišete bilo koju varijablu koju bi naknadno mogla pročitati druga nit. Osim toga, dok sinkronizacija donosi kaznu izvedbe, kazna povezana s neograničenom sinkronizacijom nije tako velika kao što neki sugeriraju i stalno se smanjuje sa svakom uzastopnom implementacijom JVM-a. Stoga se čini da sada postoji manje razloga nego ikad da se izbjegne sinkronizacija. Međutim, s pretjeranom sinkronizacijom povezan je još jedan rizik: zastoj.

Što je zastoj?

Kažemo da je skup procesa ili niti u zastoju kada svaka nit čeka događaj koji samo drugi proces u skupu može prouzročiti. Drugi način ilustracije mrtve točke je izgradnja usmjerenog grafa čiji su vrhovi niti ili procesi i čiji rubovi predstavljaju relaciju "čeka se". Ako ovaj graf sadrži ciklus, sustav je u ćorsokaku. Ako sustav nije dizajniran za oporavak iz mrtvih mrtvih točaka, program ili sustav prestaju raditi.

Zastoji u sinkronizaciji u Java programima

Zastoji se mogu pojaviti u Javi jer synchronizedključna riječ uzrokuje blokiranje izvršne niti dok čeka zaključavanje ili monitor povezan s navedenim objektom. Budući da nit možda već sadrži brave povezane s drugim objektima, dvije niti mogu čekati da druga otpusti bravu; u takvom će slučaju na kraju zauvijek čekati. Sljedeći primjer prikazuje skup metoda koje imaju potencijal zastoja. Obje metode dobivaju brave na dva predmeta zaključavanja cacheLocki tableLockprije nego što nastave. U ovom primjeru, objekti koji djeluju kao brave su globalne (statičke) varijable, uobičajena tehnika za pojednostavljivanje ponašanja zaključavanja aplikacija izvođenjem zaključavanja na gruboj razini granularnosti:

Popis 1. Potencijalna zastoj sinkronizacije

javni statični objekt cacheLock = novi objekt (); javni statični objekt tableLock = novi objekt (); ... javna void oneMethod () {sinkronizirano (cacheLock) {sinkronizirano (tableLock) {doSomething (); }}} javna void anotherMethod () {sinkronizirano (tableLock) {sinkronizirano (cacheLock) {doSomethingElse (); }}}

Sada, zamislite da nit A poziva oneMethod()dok nit B istovremeno poziva anotherMethod(). Zamislite dalje da nit A stekne bravu cacheLock, a istovremeno nit B dobije bravu tableLock. Sada su niti u blokadi: niti jedna nit neće odustati od brave dok ne nabavi drugu bravu, ali niti će moći dobiti drugu bravu dok je druga nit ne odustane. Kada Java program zaustavi, mrtve niti jednostavno čekaju zauvijek. Iako bi se ostale niti mogle nastaviti izvoditi, morat ćete na kraju morati ubiti program, ponovno ga pokrenuti i nadati se da se ponovno neće zaustaviti.

Testiranje zastoja je teško, jer zastoji ovise o vremenu, opterećenju i okolišu, pa se stoga mogu događati rijetko ili samo u određenim okolnostima. Kôd može imati potencijalno zastoj, poput Popisa 1, ali ne može zastoj prikazati sve dok se ne dogodi neka kombinacija slučajnih i ne slučajnih događaja, poput programa koji je podvrgnut određenoj razini opterećenja, pokrenut na određenoj hardverskoj konfiguraciji ili izložen određenom mješavina korisničkih radnji i uvjeta okoline. Zastoji nalikuju tempiranim bombama koje čekaju da eksplodiraju u našem kodu; kad to učine, naši programi jednostavno vise.

Neusklađeno naručivanje brave uzrokuje zastoje

Srećom, možemo nametnuti relativno jednostavan zahtjev za akvizicijom brave koji može spriječiti sinkronizacijske blokade. Metode s popisa 1 imaju potencijalnu blokadu jer svaka metoda dobiva dvije brave u različitom redoslijedu. Da je Popis 1 napisan tako da je svaka metoda nabavila dvije brave istim redoslijedom, dvije ili više niti koje izvršavaju ove metode ne mogu se zaustaviti, bez obzira na vrijeme ili druge vanjske čimbenike, jer niti jedna nit ne može dobiti drugu bravu, a da već nije držala prvi. Ako možete jamčiti da će se brave uvijek dobiti u dosljednom redoslijedu, tada vaš program neće zabiti.

Zastoji nisu uvijek toliko očiti

Jednom kad se prilagodite važnosti naručivanja brava, lako ćete prepoznati problem s popisa 1. Međutim, analogni bi se problemi mogli pokazati manje očitima: možda se dvije metode nalaze u odvojenim klasama ili se možda uključene brave stječu implicitno pozivanjem sinkroniziranih metoda, umjesto eksplicitno putem sinkroniziranog bloka. Razmotrite ove dvije suradničke klase Modeli View, u pojednostavljenom okviru MVC (Model-View-Controller):

Popis 2. Suptilnija potencijalna zastoj sinkronizacije

model javne klase {private View myView; javna sinkronizirana praznina updateModel (Objekt someArg) {doSomething (someArg); myView.somethingChanged (); } javni sinkronizirani objekt getSomething () {return someMethod (); }} javni prikaz klase {privatni model underlyingModel; javno sinkronizirano void somethingChanged () {doSomething (); } javna sinkronizirana praznina updateView () {Objekt o = mojModel.getSomething (); }}

Popis 2 ima dva suradnička objekta koji imaju sinkronizirane metode; svaki objekt poziva sinkronizirane metode drugog. Ova situacija sliči na popis 1 - dvije metode stječu brave na ista dva objekta, ali različitim redoslijedom. Međutim, nedosljedno naručivanje brave u ovom primjeru mnogo je manje očito od onog u Popisu 1 jer je akvizicija zaključavanja implicitni dio poziva metode. Ako jedna nit poziva Model.updateModel()dok druga nit istovremeno poziva View.updateView(), prva nit može dobiti Model'' bravu '' i pričekati View'' bravu '', dok druga dobije View'' bravu '' i zauvijek čeka na Model'' bravu ''.

Potencijal zastoja u sinkronizaciji možete zakopati još dublje. Razmotrite ovaj primjer: Imate metodu za prijenos sredstava s jednog računa na drugi. Prije izvođenja prijenosa želite nabaviti brave na oba računa kako biste osigurali da je prijenos atomski. Razmotrite ovu primjenu bezazlenog izgleda:

Popis 3. Još suptilniji potencijalni zastoj sinkronizacije

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

Čak i ako sve metode koje djeluju na dva ili više računa koriste isti poredak, Popis 3 sadrži sjeme istog problema s mrtvim kutom kao Popisi 1 i 2, ali na još suptilniji način. Razmotrimo što se događa kada se nit A izvrši:

 transferMoney (accountOne, accountTwo, iznos); 

Dok istodobno nit B izvršava:

 transferMoney (accountTwo, accountOne, anotherAmount); 

Opet, dvije niti pokušavaju dobiti iste dvije brave, ali različitim redoslijedom; rizik mrtve točke i dalje se nazire, ali u puno manje očitom obliku.

Kako izbjeći mrtve točke

Jedan od najboljih načina da se spriječi mogućnost zastoja je izbjegavanje stjecanja više odjednom brave, što je često praktično. Međutim, ako to nije moguće, potrebna vam je strategija koja osigurava stjecanje više brava u dosljednom, definiranom redoslijedu.

Ovisno o tome kako vaš program koristi brave, možda neće biti složeno osigurati upotrebu dosljednog redoslijeda zaključavanja. U nekim programima, kao što je na popisu 1, sve kritične brave koje bi mogle sudjelovati u višestrukom zaključavanju izvučene su iz malog skupa pojedinačnih objekata brave. U tom slučaju možete definirati redoslijed pribavljanja brave na skupu brava i osigurati da brave uvijek nabavljate tim redoslijedom. Jednom kada je redoslijed zaključavanja definiran, jednostavno ga treba dobro dokumentirati kako bi se potaknula dosljedna uporaba tijekom programa.

Smanjite sinkronizirane blokove kako biste izbjegli višestruko zaključavanje

Na popisu 2 problem se zakomplicira jer se kao rezultat pozivanja sinkronizirane metode brave dobivaju implicitno. Obično možete izbjeći vrstu potencijalnih zastoja koji proizlaze iz slučajeva poput popisa 2 sužavanjem opsega sinkronizacije na što manji blok. Da li Model.updateModel()stvarno trebate držati Modelblokadu dok se pozivaView.somethingChanged()? Često nije; cijela je metoda vjerojatno sinkronizirana kao prečac, a ne zato što je cijela metoda trebala biti sinkronizirana. Međutim, ako sinkronizirane metode zamijenite manjim sinkroniziranim blokovima unutar metode, ovo ponašanje zaključavanja morate dokumentirati kao dio Javadoca metode. Pozivatelji moraju znati da metodu mogu nazvati sigurno bez vanjske sinkronizacije. Pozivatelji bi također trebali znati ponašanje zaključavanja metode kako bi mogli osigurati da se brave dobivaju u dosljednom redoslijedu.

Sofisticiranija tehnika naručivanja brava

In other situations, like Listing 3's bank account example, applying the fixed-order rule grows even more complicated; you need to define a total ordering on the set of objects eligible for locking and use this ordering to choose the sequence of lock acquisition. This sounds messy, but is in fact straightforward. Listing 4 illustrates that technique; it uses a numeric account number to induce an ordering on Account objects. (If the object you need to lock lacks a natural identity property like an account number, you can use the Object.identityHashCode() method to generate one instead.)

Listing 4. Use an ordering to acquire locks in a fixed sequence

 public void transferMoney(Account fromAccount, Account toAccount, DollarAmount amountToTransfer) { Account firstLock, secondLock; if (fromAccount.accountNumber() == toAccount.accountNumber()) throw new Exception("Cannot transfer from account to itself"); 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); } } } } 

Now the order in which the accounts are specified in the call to transferMoney() doesn't matter; the locks are always acquired in the same order.

The most important part: Documentation

A critical -- but often overlooked -- element of any locking strategy is documentation. Unfortunately, even in cases where much care is taken to design a locking strategy, often much less effort is spent documenting it. If your program uses a small set of singleton locks, you should document your lock-ordering assumptions as clearly as possible so that future maintainers can meet the lock-ordering requirements. If a method must acquire a lock to perform its function or must be called with a specific lock held, the method's Javadoc should note that fact. That way, future developers will know that calling a given method might entail acquiring a lock.

Few programs or class libraries adequately document their locking use. At a minimum, every method should document the locks that it acquires and whether callers must hold a lock to call the method safely. In addition, classes should document whether or not, or under what conditions, they are thread safe.

Focus on locking behavior at design time

Budući da mrtve točke često nisu očite i javljaju se rijetko i nepredvidivo, one mogu uzrokovati ozbiljne probleme u Java programima. Pazeći na ponašanje zaključavanja vašeg programa u vrijeme dizajniranja i definirajući pravila kada i kako nabaviti više brava, možete znatno smanjiti vjerojatnost zastoja. Ne zaboravite pažljivo dokumentirati pravila za prikupljanje zaključavanja vašeg programa i njegovu upotrebu sinkronizacije; vrijeme provedeno u dokumentiranju jednostavnih pretpostavki zaključavanja isplatit će se znatnim smanjenjem mogućnosti zastoja i drugih paralelnih problema kasnije.

Brian Goetz profesionalni je programer softvera s više od 15 godina iskustva. Glavni je savjetnik u Quiotixu, tvrtki za razvoj softvera i savjetovanju smještenoj u Los Altosu u Kaliforniji.