Sünkroneerimine ja tõrkeolukorrad (Race condition)
Sissejuhatus
Sama Java programmi lõimed jagavad ühist mälu. See teeb nendevahelise suhtluse odavaks – üks lõim kirjutab väljale uued andmed, teine loeb seda –, kuid loob ka veaallikaid, mida ühe lõimega koodis ei esine. Kui kaks lõime loevad ja kirjutavad sama andmestikku ilma koordineerimiseta, on tulemus ettearvamatu ja sageli märkamatult vale.
Antud peatükis tutvume, mis läheb valesti, miks see juhtub ning kuidas synchronized märksõna neid probleeme lahendab.
Tõrkeolukord (race condition)
Vaatleme loendurit, kus kaks lõime suurendavad kumbki tuhat korda seal olevat väärtust:
public class Counter {
private int value = 0;
public void increment() {
value++;
}
public int get() {
return value;
}
}
Counter counter = new Counter();
Thread t1 = new Thread(() -> { for (int i = 0; i < 1000; i++) counter.increment(); });
Thread t2 = new Thread(() -> { for (int i = 0; i < 1000; i++) counter.increment(); });
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.get());
Oodatav tulemus on 2000. Tegelik tulemus on aga väiksem kui 2000 ning erineb igal käivitamisel.
Põhjus on selles, et value++ ei koosne ainult ühest tegevusest.
Kompilaator teisendab selle kolmeks sammuks:
- Loetakse
valuepraegune väärtus - Lisatakse
1juurde - Kirjutatakse uus väärtus tagasi
value-sse
Kui kaks lõime täidavad neid samme läbisegi, võivad mõlemad lugeda sama väärtuse, lisada sellele 1 ning kirjutada tagasi sama tulemuse - üks suurendamine kaob märkamatult:
See on tõrkeolukord (race condition): tulemus sõltub lõimede suhtelisest ajastusest ning programm on vigane, kuigi kood ise tundub korrektne olevat.
synchronized märksõna
Lahendus antud probleemile oleks tagada, et kõik operatsioonid kus loetakse, muudetakse või kirjutatakse andmeid viiakse lõpuni enne, kui mõni teine lõim saaks seda alustada.
Java pakub selleks spetsiaalselt synchronized märksõna.
synchronized-i saab kasutada nii meetodi kui ka koodiploki puhul.
synchronized meetod hoiab meetodi täitmise ajal lukku this objekti peal.
Iga teine lõim, mis proovib siseneda sama objekti synchronized meetodisse, peab ootama, kuni esimene lõim oma töö lõpetab:
public class Counter {
private int value = 0;
public synchronized void increment() {
value++;
}
public synchronized int get() {
return value;
}
}
Selle muudatusega jõuab loendur kindlalt väärtuseni 2000.
Esimene increment meetodit kasutav lõim saab luku, teine lõim peatub meetodi piiril ja ootab.
Kui esimene lõim meetodist väljub, vabastatakse lukk ning ootel olev lõim saab jätkata.
Sünkroniseeritud koodiplokid
Kogu meetodi lukustamine on kõige lihtsam, kuid mitte alati parim lahendus.
Kui ainult osa meetodist vajab kaitset, hoiab synchronized plokk lukku lühemat aega ning võimaldab teistel lõimedel samal ajal edasi töötada:
public void increment() {
// expensive work that does not touch shared state
String log = formatLogMessage();
synchronized (this) {
value++;
}
// more work that does not touch shared state
publishMetric(log);
}
synchronized-i argumendiks on objekt, mille lukku kasutatakse.
Meetodite puhul on selleks vaikimisi this.
Sünkroniseeritud plokid võimaldavad valida ka mõne muu lukustatava objekti, sealhulgas spetsiaalselt selleks loodud privaatset objekti:
public class Counter {
private final Object lock = new Object();
private int value = 0;
public void increment() {
synchronized (lock) {
value++;
}
}
}
Privaatse lukustusobjekti kasutamine on üldiselt turvalisem kui this-i lukustamine.
Klassiväline kood ei saa sama lukku kasutada (mis võiks põhjustada ootamatuid vastastikuseid mõjusid) ning alamklassid ei pääse sellele lukule ligi.
Instantsilukk vs klassilukk
Igal Java objektil on oma lukk.
Kaks lõime, mis kutsuvad synchronized meetodeid erinevatel objektidel, ei blokeeri teineteist:
Counter a = new Counter();
Counter b = new Counter();
// these run in parallel - they hold different locks
new Thread(a::increment).start();
new Thread(b::increment).start();
synchronized instantsimeetod kaitseb samaaegse ligipääsu eest sellele konkreetsele objektile.
Kui jagatud olek asub instantsi sees, on see täpselt sobiv lahendus.
Staatilise oleku puhul peab lukk olema klassi objekt ise.
static synchronized meetod lukustab ClassName.class-i:
public class IdGenerator {
private static int next = 0;
public static synchronized int nextId() {
return next++;
}
}
Nähtavus: teine oht
Tõrkeolukorrad on kõige silmatorkavamad vead, kuid on olemas ka varjatum probleem. Ilma sünkroniseerimiseta võib juhtuda, et üks lõim ei näe üldse teise lõime tehtud muudatusi - väärtus jääb kirjutava lõime protsessori vahemällu või registrisse ning seda lugev lõim näeb lõputult vana väärtust:
public class StoppableTask implements Runnable {
private boolean stopped = false;
public void stop() { stopped = true; }
@Override
public void run() {
while (!stopped) {
// do work
}
}
}
Teine lõim võib kutsuda stop() meetodit, kuid tsükkel ei pruugi kunagi lõppeda, isegi kui tõeväärtuste kirjutamine on atomaarne.
Tsükkel loeb stopped-i nii tihti, et JVM võib kontrolli optimeerida või jääb jooksva lõime tuumas olev vahemälu väärtus uuendamata.
synchronized lahendab mõlemad probleemid korraga.
Sünkroniseeritud plokki sisenemine ja sealt väljumine loob happens-before seose: kõik enne luku vabastamist tehtud kirjutamised on nähtavad igale lõimele, mis sama luku hiljem omandab.
Kui on vaja tagada ainult ühe välja nähtavus lõimede vahel (ilma keerukamate atomaarsete operatsioonideta), on kergem kasutada volatile märksõna väljal:
private volatile boolean stopped = false;
volatile tagab nähtavuse, kuid ei tee operatsioone nagu value++ atomaarseks.
Loendurite jaoks tuleks kasutada atomaarseid muutujaid või synchronized märksõna.
Taassisenemine (reentrancy)
Java sisseehitatud lukud on taassisenetavad (reentrant): lõim, mis juba hoiab lukku, saab selle uuesti omandada ilma iseennast blokeerimata. See on oluline olukorras, kus üks sünkroniseeritud meetod kutsub sama objekti teist sünkroniseeritud meetodit:
public class Account {
private int balance = 0;
public synchronized void deposit(int amount) {
balance += amount;
}
public synchronized void depositTwice(int amount) {
deposit(amount); // re-acquires the lock - works because it is reentrant
deposit(amount);
}
}
Ilma taassisenemiseta tekiks siin tupik (deadlock) - teine deposit-kutse jääks ootama lukku, mida hoiab depositTwice, kuid mida see ei saa vabastada enne meetodi lõppu.
Sünkroniseeritud kollektsioonid
Java standardkollektsioonid - ArrayList, HashMap jne - ei ole thread-safe.
Samaaegne muutmine võib rikkuda nende sisemist olekut, isegi kui ükski üksik operatsioon ei tundu ohtlikuna.
Juhusliku samaaegse kasutuse korral saab kasutada Collections.synchronizedList, synchronizedMap jms, mis mähivad kollektsiooni sünkroniseeritud meetoditega:
List<String> list = Collections.synchronizedList(new ArrayList<>());
list.add("a"); // safe
list.add("b"); // safe
Need mähised kaitsevad iga üksikut meetodikutsungit. Liitoperatsioonid - näiteks “kontrolli ja siis tegutse” või iteratsioon - vajavad siiski eraldi sünkroniseerimist sama objekti peal.
Suure läbilaskevõimega jagatud kollektsioonide jaoks pakub java.util.concurrent pakk paremaid lahendusi:
ConcurrentHashMap- suure jõudlusega kujutis samaaegseks lugemiseks ja kirjutamiseksCopyOnWriteArrayList- loend turvalise iteratsiooniga, mis keskendub andmete lugemiseleConcurrentLinkedQueue- lukuvaba järjekord
Need on enamasti eelistatavamad kui tavalise kollektsiooni sünkroniseerimine.
ReentrantLock
synchronized on mugav, kuid piiratud võimalustega.
Sellega ei saa lukku omandada ajapiiranguga, ootamist ei saa katkestada ning see ei toeta mitut tingimust.
Sellisteks juhtudeks on paindlikum alternatiiv java.util.concurrent.locks.ReentrantLock:
private final Lock lock = new ReentrantLock();
public void update() {
lock.lock();
try {
// critical section
} finally {
lock.unlock();
}
}
Antud juhul on try/finally muster kohustuslik - kui unlock() jääb kutsumata, jääb lukk lõplikult kinni.
Enamiku koodi puhul on synchronized siiski lihtsam ja sama kiire.
ReentrantLock-i tasub kasutada ainult siis, kui on vaja selle lisavõimalusi.
Kokkuvõtteks
Mõned põhimõtted, millest peaks kinni hoidma ning katavad enamiku olukordi:
- Kui kaks lõime kirjutavad andmeid samale väljale, peab see väli olema sünkroniseeritud
- Kui üks lõim kirjutab ja teine loeb, vajab väli sünkroniseerimist või
volatile-i - Hoia lukku täpselt nii kaua, kui on vaja järjepidevuse tagamiseks, kuid mitte kauem
- Kasuta alati sama lukku sama andmevälja jaoks - erinevate objektide lukustamine ei kaitse midagi ning lukustamine kaotab oma mõtte
- Eelista muutumatuid objekte: väli, mida pärast loomist ei muudeta, ei vaja üldse sünkroniseerimist