Extended Lock
Mamy oto taki kod:
Listing 1. Klasyczny przelew
public void sendMoney(Money money, Account sender, Account receiver) {
synchronized (sender) {
synchronized (receiver) {
sender.sub(money);
receiver.add(money);
}
}
}
I teraz, jeżeli w jednym wątku zrobimy:
Listing 2. Pierwszy wątek
sendMoney(someMoney, aliceAccount, bobAccount);
A w drugim:
Listing 3. Drugi wątek
sendMoney(someMoney, bobAccount, aliceAccount);
I odpalimy to równocześnie, to oczywiście jest duża szansa, że doprowadzimy do zakleszczenia wątków. Pierwszy wątek zablokuje obiekt aliceAccount
,
odda sterowanie, drugi wątek zablokuje bobAccount
, odda sterowanie, pierwszy wątek bęzie chciał zablokować bobAccount
, ale to się nie uda, odda
więc sterowanie, po czym drugi wątek będzie chciał zablokować aliceAccount
i też to mu się nie uda, odda sterowanie i… klasycznie jesteśmy w dupie.
Problem
Java nie umożliwia synchronizacji na wielu obiektach na raz. Przy czym na raz oznacza tutaj, że założenie blokady na wiele obiektów jest operacją atomową. Nie można zrobić czegoś w stylu:
Listing 4. To nie zadziała
synchronized (o1,o2){
//…
}
W efekcie jesteśmy skazani na kombinowanie.
Użyj Lock
-a?
Nie :) Lock
nie rozwiązuje problemów. Albo
inaczej, rozwiązuje je w niewielkim stopniu. Przewagą nad klasycznym synchronized
jest szybkość. Lock
jest szybszy. Wykonywane jest mniej
operacji, a i JVM nie lata jak kot z pęcherzem do sysopu.
Klasycznym sposobem na wykorzystanie Lock
-a jest zbudowanie proxy wokół właściwej klasy, która każde wywołanie krytyczne zamyka w
klamrze Lock.tryLock()
» Lock.unlock()
. Tu jednak nadal mamy wiele poziomów blokady, a obiekty są blokowane niezależnie. Będzie szybciej, ale tak
samo niebezpiecznie.
Spróbujmy jednak czegoś odrobinę innego. Wykorzystamy Lock
i programowanie aspektowe oraz przyjmiemy kilka założeń, które co prawda ograniczą nam
rozwiązanie do obiektów z prawidłowo zaimplementowanym hashCode
, ale to wystarczy.
Adnotacja
Zaczniemy od stworzenia prostej adnotacji. Nie będzie miała żadnych pól, bo na razie ich nie potrzebujemy
Listing 4. Adnotacja `ExtendedLock`
@Target(METHOD)
@Retention(RUNTIME)
@Documented
public @interface ExtendedLock {
}
Będziemy jej potrzebować na poziomie runtime-u i można nią oznaczyć tylko metody. Tyle nam wystarczy, bo to tylko PoC, ale zawsze można rozszerzyć użycie do parametrów metod.
Aspekt
Teraz trzeba otoczyć nasz zaadnotowany kod miłością, czyli owinąć całość w aspekt:
Listing 5. Adnotacja `ExtendedLockAspect`
@Aspect
public class ExtendedLockAspect {
ExtendedLockHandler lockHandler = new ExtendedLockHandler();
@Around("@annotation(pl.koziolekweb.extendedlock.ExtendedLock)")
public Object callWithLock(ProceedingJoinPoint thisJoinPoint) throws Throwable {
var args = thisJoinPoint.getArgs();
try {
while (!lockHandler.lock(args)) {
}
final Object proceed = thisJoinPoint.proceed();
while (!lockHandler.unlock(args)) {
}
return proceed;
} catch (Throwable t) {
while (!lockHandler.unlock(args)) {
}
throw t;
} finally {
while (!lockHandler.unlock(args)) {
}
}
}
}
Tu nie ma nic niezwykłego. Całość pracy wydelegujemy do ExtendedLockHandler
, o którym za chwilę. Jednak należy zauważyć dwie rzeczy. Po pierwsze
pętla while
, która tutaj jest bardzo prostą implementacją SpinlockaW. Jeżeli
nie uda nam się obsłużyć parametrów metody, to wątek się poddaje. Drugą rzeczą jest bardzo agresywne zwalnianie blokady. Nadmiarowe? Może, ale inaczej
się nie da.
Handler
Ostatnim elementem jest ExtendedLockHandler
, w którym dzieje się cała „magia”. Zanim przejdziemy do kodu, to przypomnę, że jest to PoC.
Listing 6. `ExtendedLockHandler` i jego „magia”
public class ExtendedLockHandler {
private ReentrantLock lock = new ReentrantLock();
private Map<Object, LockCounter> lockMap = new WeakHashMap<>();
public boolean lock(Object[] elements) {
var currentThread = getCurrentThread();
if (lock.tryLock()) {
for (Object element : elements) {
if (lockMap.containsKey(element)) {
if (!lockMap.get(element).isSameThread(currentThread)) {
free();
return false;
}
}
}
for (Object element : elements) {
if (lockMap.containsKey(element)) {
lockMap.get(element).inc();
} else
lockMap.put(element, new LockCounter(currentThread));
}
free();
return true;
}
return false;
}
public boolean unlock(Object[] elements) {
if (lock.tryLock()) {
for (Object element : elements) {
if (lockMap.containsKey(element)
&& lockMap.get(element).isCurrentThread()
&& lockMap.get(element).dec() == 0) {
lockMap.remove(element);
}
}
free();
return true;
}
return false;
}
private void free() {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
private Thread getCurrentThread() {
return Thread.currentThread();
}
record LockCounter(Thread thread, AtomicInteger counter) {
LockCounter(Thread thread) {
this(thread, new AtomicInteger(1));
}
public int inc() {
return counter.incrementAndGet();
}
public int dec() {
return counter.decrementAndGet();
}
public boolean isSameThread(Thread other) {
return this.thread.equals(other);
}
public boolean isCurrentThread() {
return this.thread.equals(Thread.currentThread());
}
}
}
I już widać, na czym polega „magia”. W metodzie lock
przyjmujemy tablicę, która reprezentuje obiekty – argumenty chronionej tym mechanizmem metody.
Następnie próbujemy założyć ReentrantLock
. Jeżeli NIE uda nam się zająć blokady, to zwracamy false
, sterowanie wraca do aspektu, który robi
kolejny obrót pętli. Co oznacza, że bieżący wątek oddaje sterowanie.
Jeżeli uda nam się zająć blokadę, to najpierw sprawdzamy, czy żaden z obiektów nie jest już zablokowany przez inny wątek. Do śledzenia blokad służy
nam mapa lockMap
. Jeżeli jakikolwiek obiekt jest już zablokowany, to zwalniamy blokadę i zwracamy false
, co oznacza oddanie sterowania.
Jeżeli żaden obiekt nie jest zablokowany przez inny wątek, to:
- Jeżeli to aktualny wątek blokuje obiekt, to zwiększamy licznik o 1, zdejmujemy blokadę i zwracamy
true
. Tym samym sterowanie przechodzi dothisJoinPoint.proceed()
w aspekcie i tym samym do chronionej metody. - Jeżeli obiekt nie jest jeszcze zablokowany, to dodajemy go do mapy, zwalniamy blokadę i zwracamy
true
.
Logika jest „prosta”, ale jest tu pewien haczyk.
LockCounter
i jego rola
Popatrzmy na taki kod:
Listing 7. Blokada „w głąb”
@ExtendedLock
public void m1(Object o) {
//…
m2(o);
//…
}
@ExtendedLock
public void m2(Object o) {
//…
}
Jeżeli wywołamy metodę m1
, to zostanie założona blokada na obiekcie o
. Jeżeli do mapy dodamy jako po prostu Thread
, to oczywiście metoda m2
też się wykona, bo jest w tym samym wątku, ale wyjście z metody m2
spowoduje uwolnienie obiektu. A przecież jeszcze nie opuściliśmy m1
, więc
formalnie musimy nadal mieć nasz obiekt zablokowany.
Tu wchodzi LockCounter
. Przechowuje on informację o tym, ile razy dany wątek założył blokadę na dany obiekt. Jest to ważne, ponieważ w
metodzie unlock
sprawdzamy, czy możemy bezpiecznie usunąć dany obiekt z mapy blokad. Możemy to zrobić tylko wtedy, gdy dany obiekt był zablokowany
przez bieżący wątek i po zdjęciu blokady licznik blokad jest równy 0.
Fakapy
Ten kod nie jest idealny. Na pewno nie będzie działać w rozwiązaniach reaktywnych, gdzie zmienia się wątek wykonujący dane zadanie. Mam też
przeczucie, graniczące z pewnością, że istnieje warunek czasowy, przy którym można zrobić race condition na lock
i w efekcie mieć jakiegoś dziwnego
deadlocka. Choć nie udało mi się tego jeszcze wykazać w testach, to sytuacja, gdy wątek A blokuje w metodzie lock
, a wątek B w metodzie unlock
jest
prawdopodobny.
Można by też trochę poprawić wydajność, chociażby z użyciem patentów z Javolution(otwierasz na własną odpowiedzialność, bo to starsze niż internety).
W końcu nie za bardzo mam jeszcze sensowny pomysł na czyszczenie obiektów zablokowanych przez martwe wątki.
Problem martwych wątków
W mapie blokad przechowuję informację o wątku, który zablokował dany obiekt. Jeżeli po uzyskaniu blokady na obiekcie, ale przed wyjściem z bloku try
w aspekcie wątek zostanie ubity, to nie nastąpi zwolnienie blokady. Można co pewien czas sprawdzać, które wątki sa jeszcze żywe i wtedy czyścić mapę,
ale to bardzo brutalny kod, który przy dużej liczbie zablokowanych obiektów, będzie powolny.
Ale to jest PoC, więc mało mnie to obchodzi ;)
Koniec.