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 do thisJoinPoint.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.