Duboko razumevanje Java istovremene brave za čitanje/pisanje ReentrantReadWriteLock
ReentrantReadWriteLock je Java brava za čitanje/pisanje koja dozvoljava istovremeni pristup više niti koje čitaju, ali samo jednoj niti koja piše (što blokira sve niti koje čitaju i pišu). Ovakav dizajn brave može da poboljša performanse, posebno kada je broj operacija čitanja znatno veći od broja operacija pisanja.
U istovremenim scenarijima, radi rešavanja problema nit-bezbednosti, obično koristimo ključnu reč synchronized ili ReentrantLock iz JUC paketa koji implementira interfejs Lock. Ali obe dobijaju bravu isključivo, što znači da u istom trenutku samo jedna nit može dobiti bravu.
U nekim poslovnim scenarijima većina je samo čitanje, pisanja ima malo; ako se samo čita, to ne utiče na ispravnost podataka, a ako u takvom scenariju i dalje koristimo isključivu bravu, očigledno će se pojaviti usko grlo u performansama. Za situacije gde se mnogo čita, a malo piše, Java nudi još jednu implementaciju interfejsa Lock — ReentrantReadWriteLock, bravu za čitanje/pisanje.
U tekstu o interfejsu Lock smo spomenuli bravu za čitanje/pisanje; nadamo se da se još sećate.
Brava za čitanje/pisanje dozvoljava istovremeni pristup više niti koje čitaju, ali kad pristupa nit koja piše, sve niti koje čitaju i ostale niti koje pišu se blokiraju.
Pri analizi uzajamnosti WriteLock-a i ReadLock-a, možemo ih uporediti kao WriteLock vs WriteLock, WriteLock vs ReadLock i ReadLock vs ReadLock.
Evo kratkog pregleda osobina brave za čitanje/pisanje:
- Izbor pravednosti: podržava i nepravedni (podrazumevano) i pravedni način dobijanja brave; nepravedni ima veću propusnost od pravednog.
U računarskim naukama i proceni performansi, propusnost (Throughput) je mera procesne moći sistema; opisuje broj transakcija ili operacija koje sistem može da obradi u jedinici vremena. Propusnost se koristi za ocenu efikasnosti i performansi sistema — npr. koliko zahteva ili operacija završi u sekundi.
Nepravedna brava ne garantuje redosled niti koje čekaju na bravu. Kad brava bude puštena, nijedan poseban redosled ne određuje koja nit dobija bravu. Ovaj način je obično efikasniji jer niti ne moraju čekati po redosledu reda, što smanjuje promenu konteksta i troškove raspoređivanja, povećavajući propusnost.
Pravedna brava osigurava da niti koje čekaju na bravu dobijaju po redosledu svojih zahteva. Nit koja prva zatraži brvu biće prva koja je i dobije, itd. Iako je ponašanje pravedne brave predvidivije, održavanje eksplicitnog redosleda može povećati dodatne troškove i tako smanjiti propusnost.
O ovome smo već govorili u tekstu o reentrantnoj bravi ReentrantLock.
- Reentrantnost: podržava reentrantnost; nakon dobijanja brave čitanja, može se ponovo dobiti; nakon dobijanja brave pisanja, može se ponovo dobiti brava pisanja, a može se dobiti i brava čitanja.
I o tome smo detaljno govorili u vezi sa Lock-om.
- Degradacija brave: degradacija brave pisanja je proces u kom se brava pisanja pretvara u bravu čitanja. Uobičajeni redosled je:
- Dobijanje brave pisanja: nit prvo dobija bravu pisanja, čime se osigurava isključiv pristup pri izmeni podataka.
- Dobijanje brave čitanja: uz zadržanu bravu pisanja, nit može ponovo dobiti bravu čitanja.
- Puštanje brave pisanja: nit pušta bravu pisanja, a zadržava bravu čitanja.
- Puštanje brave čitanja: na kraju nit pušta bravu čitanja.
Tako brava pisanja biva degradirana u bravu čitanja, čime se drugim nitima dozvoljava paralelno čitanje, ali se i dalje isključuje pisanje drugih niti. Sledeći kod pokazuje kako se pomoću ReentrantReadWriteLock degradira brava pisanja:
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
writeLock.lock(); // dobij bravu pisanja
try {
// izvrši operacije pisanja
readLock.lock(); // dobij bravu čitanja
} finally {
writeLock.unlock(); // pusti bravu pisanja
}
try {
// izvrši operacije čitanja
} finally {
readLock.unlock(); // pusti bravu čitanja
}Proces degradacije brave pisanja u bravu čitanja pomaže u održavanju konzistencije podataka bez uticaja na performanse paralelnog čitanja. Na ovaj način nit može nastaviti da drži isključiv pristup podacima sve dok ne bude spremna da dozvoli drugim nitima deljenje čitanja. To osigurava konzistenciju podataka između operacije pisanja i naknadne operacije čitanja, a istovremeno dozvoljava paralelan pristup drugim nitima za čitanje.
Da bismo u potpunosti razumeli bravu za čitanje/pisanje, moramo razumeti i sledeća pitanja:
- Kako brava za čitanje/pisanje posebno beleži stanje čitanja i pisanja?
- Kako se dobija i pušta brava pisanja?
- Kako se dobija i pušta brava čitanja?
Sa ta tri pitanja u mislima, nastavimo sa bravom za čitanje/pisanje.
Detalji brave pisanja
Dobijanje brave pisanja
U istom trenutku, bravu pisanja ReentrantReadWriteLock ne može dobiti više niti; očigledno je da je brava pisanja ReentrantReadWriteLock isključiva, a semantika sinhronizacije brave pisanja ostvaruje se prevazilaženjem metode tryAcquire iz AQS. Izvorni kod:
protected final boolean tryAcquire(int acquires) {
/*
* Walkthrough:
* 1. If read count nonzero or write count nonzero
* and owner is a different thread, fail.
* 2. If count would saturate, fail. (This can only
* happen if count is already nonzero.)
* 3. Otherwise, this thread is eligible for lock if
* it is either a reentrant acquire or
* queue policy allows it. If so, update state
* and set owner.
*/
Thread current = Thread.currentThread();
// 1. Dohvati trenutno sinhrono stanje brave pisanja
int c = getState();
// 2. Dohvati broj dobijanja brave pisanja
int w = exclusiveCount(c);
if (c != 0) {
// (Note: if c != 0 and w == 0 then shared count != 0)
// 3.1 Ako je bravu čitanja dobila nit koja čita ili trenutna nit nije ona koja je dobila bravu pisanja
// trenutna nit ne uspeva da dobije bravu pisanja
if (w == 0 || current != getExclusiveOwnerThread())
return false;
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// Reentrant acquire
// 3.2 Trenutna nit dobija bravu pisanja, podržava se višestruko zaključavanje
setState(c + acquires);
return true;
}
// 3.3 Bravu pisanja ne drži nijedna nit; trenutna nit može dobiti bravu pisanja
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}Logiku ovog koda pogledajte u komentarima. Obratite pažnju na metod exclusiveCount(c), čiji je izvorni kod:
static int exclusiveCount(int c) {
return c & EXCLUSIVE_MASK;
}gde je EXCLUSIVE_MASK:
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;EXCLUSIVE_MASK je 1 pomerena levo za 16 bitova, pa umanjena za 1, što daje 0x0000FFFF. Metod exclusiveCount radi AND sinhronog stanja (state je tipa int) sa 0x0000FFFF, odnosno uzima donjih 16 bitova sinhronog stanja.
Šta predstavljaju donjih 16 bitova? Prema komentaru metode exclusiveCount, to je broj isključivih dobijanja — koliko je puta dobijena brava pisanja. Sada možemo zaključiti: donjih 16 bitova sinhronog stanja služi da izrazi broj dobijanja brave pisanja.
Takođe, vredi obratiti pažnju i na ovaj metod:
static int sharedCount(int c) {
return c >>> SHARED_SHIFT;
}Ovaj metod dohvata broj dobijanja brave čitanja tako što sinhrono stanje (int c) pomeri udesno 16 puta — dakle uzima gornjih 16 bitova sinhronog stanja. Sada možemo zaključiti i: gornjih 16 bitova sinhronog stanja služi da izrazi broj dobijanja brave čitanja.
Sećate se pitanja „kako brava za čitanje/pisanje posebno beleži stanje čitanja i pisanja"? Semantički prikaz je na sledećoj slici:

Dobro, sada se vratimo na metod dobijanja brave pisanja tryAcquire, čija je glavna logika: kad je bravu čitanja dobila nit koja čiti ili je bravu pisanja dobila druga nit koja piše, dobijanje brave pisanja ne uspeva; u suprotnom, uspeva i podržava reentrantnost, povećavajući stanje pisanja.
Puštanje brave pisanja
Puštanje brave pisanja ostvaruje se prevazilaženjem metode tryRelease iz AQS; izvorni kod:
protected final boolean tryRelease(int releases) {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
//1. Sinhrono stanje se umanjuje za stanje pisanja
int nextc = getState() - releases;
//2. Da li je tekuće stanje pisanja 0; ako jeste, brava pisanja se pušta
boolean free = exclusiveCount(nextc) == 0;
if (free)
setExclusiveOwnerThread(null);
//3. Ako nije 0, ažurira se sinhrono stanje
setState(nextc);
return free;
}Logiku izvornog koda pogledajte u komentarima; lako je razumeti i u suštini je ista kao kod ReentrantLock-a. Ovde treba obratiti pažnju na to da se pri umanjenju stanja pisanja int nextc = getState() - releases; koristi neposredno umanjenje trenutnog sinhronog stanja za stanje pisanja, jer je stanje pisanja, kao što smo malopre rekli, izraženo donjih 16 bitova sinhronog stanja.
Detalji brave čitanja
Dobijanje brave čitanja
Pošto smo pregledali bravu pisanja, pogledajmo i bravu čitanja. Brava čitanja nije isključiva — u istom trenutku može je dobiti više niti koje čitaju, dakle to je deljiva brava. Prema prethodnom opisu AQS, semantika sinhronizacije deljivih komponenti ostvaruje se prevazilaženjem AQS metoda tryAcquireShared i tryReleaseShared. Metod dobijanja brave čitanja:
protected final int tryAcquireShared(int unused) {
/*
* Walkthrough:
* 1. If write lock held by another thread, fail.
* 2. Otherwise, this thread is eligible for
* lock wrt state, so ask if it should block
* because of queue policy. If not, try
* to grant by CASing state and updating count.
* Note that step does not check for reentrant
* acquires, which is postponed to full version
* to avoid having to check hold count in
* the more typical non-reentrant case.
* 3. If step 2 fails either because thread
* apparently not eligible or CAS fails or count
* saturated, chain to version with full retry loop.
*/
Thread current = Thread.currentThread();
int c = getState();
//1. Ako je bravu pisanja već dobila druga nit, trenutna nit ne uspeva da dobije bravu čitanja; vraća -1
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
int r = sharedCount(c);
if (!readerShouldBlock() &&
r < MAX_COUNT &&
//2. Trenutna nit dobija bravu čitanja
compareAndSetState(c, c + SHARED_UNIT)) {
//3. Sledeći kod uglavnom potiče iz novijih funkcija, npr. metoda getReadHoldCount()
//vraća trenutni broj dobijanja brave čitanja
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
}
return 1;
}
//4. Obrada spinovanja i reentrantnosti u slučaju neuspeha CAS-a u drugom koraku
return fullTryAcquireShared(current);
}Logiku koda pogledajte u komentarima; pazite na to da kad bravu pisanja dobije druga nit, dobijanje brave čitanja ne uspeva; u suprotnom, uspeva i ažurira sinhrono stanje uz pomoć CAS-a.
Uz to, razlog što se trenutno sinhrono stanje povećava za SHARED_UNIT ((1 << SHARED_SHIFT), odnosno 0x00010000) jeste, kao što smo već napomenuli, taj što gornjih 16 bitova sinhronog stanja izražava broj dobijanja brave čitanja.
Ako CAS ne uspe ili nit koja je već dobila bravu čitanja ponovo pokušava da je dobije, taj zadatak obavlja metod fullTryAcquireShared, koji ovde nećemo razrađivati; zainteresovani mogu sami pogledati.
Puštanje brave čitanja
Puštanje brave čitanja uglavnom se ostvaruje metodom tryReleaseShared; izvorni kod je ispod, glavna logika u komentarima:
protected final boolean tryReleaseShared(int unused) {
Thread current = Thread.currentThread();
// I dalje za nove funkcije poput getReadHoldCount
if (firstReader == current) {
// assert firstReaderHoldCount > 0;
if (firstReaderHoldCount == 1)
firstReader = null;
else
firstReaderHoldCount--;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
rh = readHolds.get();
int count = rh.count;
if (count <= 1) {
readHolds.remove();
if (count <= 0)
throw unmatchedUnlockException();
}
--rh.count;
}
for (;;) {
int c = getState();
// Puštanje brave čitanja: sinhrono stanje se umanjuje za stanje čitanja
int nextc = c - SHARED_UNIT;
if (compareAndSetState(c, nextc))
// Releasing the read lock has no effect on readers,
// but it may allow waiting writers to proceed if
// both read and write locks are now free.
return nextc == 0;
}
}Degradacija brave
Brava za čitanje/pisanje podržava degradaciju brave — poštujući redosleb dobijanja brave pisanja, pa brave čitanja, pa puštanja brave pisanja, brava pisanja može se degradirati u bravu čitanja; ne podržava nadgradnju brave. Sledeći primer je preuzet iz izvornog koda ReentrantWriteReadLock-a:
void processCachedData() {
rwl.readLock().lock();
if (!cacheValid) {
// Must release read lock before acquiring write lock
rwl.readLock().unlock();
rwl.writeLock().lock();
try {
// Recheck state because another thread might have
// acquired write lock and changed state before we did.
if (!cacheValid) {
data = ...
cacheValid = true;
}
// Downgrade by acquiring read lock before releasing write lock
rwl.readLock().lock();
} finally {
rwl.writeLock().unlock(); // Unlock write, still hold read
}
}
try {
use(data);
} finally {
rwl.readLock().unlock();
}
}Tok ovde možemo objasniti ovako:
- Dobijanje brave čitanja: prvo se pokušava dobiti brava čitanja da bi se proverilo da li je neki keš važeći.
- Provera keša: ako keš nije važeći, potrebno je pustiti bravu čitanja, jer se pre dobijanja brave pisanja mora pustiti brava čitanja.
- Dobijanje brave pisanja: dobija se brava pisanja radi ažuriranja keša. Tada može biti potrebno ponovo proveriti stanje keša, jer je između puštanja brave čitanja i dobijanja brave pisanja neka druga nit možda promenila stanje.
- Ažuriranje keša: ako se potvrdi da keš nije važeći, ažurira se keš i označava kao važeći.
- Degradacija brave pisanja u bravu čitanja: pre puštanja brave pisanja, dobija se brava čitanja, čime se ostvaruje degradacija brave pisanja u bravu čitanja. Tako, nakon puštanja brave pisanja, druge niti mogu paralelno čitati, ali ne i pisati.
- Korišćenje podataka: sada se podaci iz keša mogu bezbedno koristiti.
- Puštanje brave čitanja: po završetku rada pušta se brava čitanja.
Ovaj tok kombinuje prednosti brave čitanja i brave pisanja, osigurava konzistenciju i dostupnost podataka, a dozvoljava paralelno čitanje kad je to moguće. Kod sa bravom za čitanje/pisanje može delovati složenije nego kod sa običnom mutex bravom, ali pruža finije upravljanje istovremenošću i može poboljšati performanse višenitnih aplikacija.
Korišćenje brave za čitanje/pisanje
Upotreba ReentrantReadWriteLock je vrlo jednostavna; sledeći kod pokazuje kako se pomoću njega implementira nit-bezbedan brojač:
public class Counter {
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private final Lock r = rwl.readLock();
private final Lock w = rwl.writeLock();
private int count = 0;
public int getCount() {
r.lock();
try {
return count;
} finally {
r.unlock();
}
}
public void inc() {
w.lock();
try {
count++;
} finally {
w.unlock();
}
}
}Sada da simuliramo malo složeniji primer — kako pomoću brave za čitanje/pisanje bezbedno čitati i ažurirati deljene podatke.
public class CachedData {
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private Object data;
private boolean cacheValid;
public void processCachedData() {
// Acquire read lock
rwl.readLock().lock();
if (!cacheValid) {
// Must release read lock before acquiring write lock
rwl.readLock().unlock();
rwl.writeLock().lock();
try {
// Recheck state because another thread might have
// acquired write lock and changed state before we did
if (!cacheValid) {
data = fetchDataFromDatabase();
cacheValid = true;
}
// Downgrade by acquiring read lock before releasing write lock
rwl.readLock().lock();
} finally {
rwl.writeLock().unlock(); // Unlock write, still hold read
}
}
try {
use(data);
} finally {
rwl.readLock().unlock();
}
}
private Object fetchDataFromDatabase() {
// Simulate fetching data from a database
return new Object();
}
private void use(Object data) {
// Simulate using the data
System.out.println("Korišćenje podataka: " + data);
}
public static void main(String[] args) {
CachedData cachedData = new CachedData();
cachedData.processCachedData();
}
}Kad keš nije važeći, prvo se pušta brava čitanja, a zatim se dobija brava pisanja radi ažuriranja keša. Pošto keš bude ažuriran, vrši se degradacija iz brave pisanja u bravu čitanja, čime se drugim nitima dozvoljava paralelno čitanje, ali se i dalje isključuje pisanje.
Takva struktura dozvoljava, uz osiguranje konzistencije podataka, iskorišćavanje prednosti paralelnog čitanja, čime se poboljšavaju performanse u višenitnom okruženju.
Zaključak
ReentrantReadWriteLock je Java brava za čitanje/pisanje koja dozvoljava istovremeni pristup više niti koje čitaju, ali samo jednoj niti koja piše, ili blokira sve niti koje čitaju i pišu. Ovakav dizajn brave može da poboljša performanse, posebno u strukturama gde broj operacija čitanja znatno premašuje broj operacija pisanja.
Realizacija brave za čitanje/pisanje uglavnom se oslanja na prevazilaženje metoda tryAcquire i tryRelease iz AQS — dobijanje i puštanje i brave čitanja i brave pisanja ostvaruje se preko te dve metode.
Brava za čitanje/pisanje podržava degradaciju: poštujući redosled dobijanja brave pisanja, pa brave čitanja, pa puštanja brave pisanja, brava pisanja može se degradirati u bravu čitanja; ne podržava nadgradnju brave.
Urednik: Chenmo Wang Er; sadržaj pre uređivanja uglavnom potiče iz GitHub repozitorijuma CL0610 https://github.com/CL0610/Java-concurrency
