Krótki przegląd funkcji interakcji wątków. Wcześniej przyglądaliśmy się, jak wątki synchronizują się ze sobą. Tym razem zagłębimy się w problemy, które mogą pojawić się podczas interakcji wątków i porozmawiamy o tym, jak można ich uniknąć. Podamy również kilka przydatnych linków do głębszych badań.
Świetny przykład można znaleźć tutaj: „ Java - Głód i uczciwość wątków ”. Ten przykład pokazuje, jak wątki działają w Starvation i jak jedna mała zmiana z Thread.sleep na Thread.wait może równomiernie rozłożyć obciążenie.
Chyba lepiej, że ten film nic na ten temat nie mówi. Zostawię więc tylko link do filmu. Możesz przeczytać „ Java – Zrozumienie relacji przed relacjami ”.
Wstęp
Wiemy zatem, że w Javie istnieją wątki, o których przeczytacie w recenzji „ Wątek nie zepsuje Javy: Część I - Wątki ” i że wątki można ze sobą synchronizować, o czym pisaliśmy w recenzji „ Wątek nie może zepsuć Java ” Spoil: Część II - Synchronizacja ”. Czas porozmawiać o tym, jak wątki oddziałują na siebie. W jaki sposób dzielą wspólne zasoby? Jakie mogą być z tym związane problemy?Impas
Najgorszym problemem jest Deadlock. Gdy dwa lub więcej wątków czeka na siebie w nieskończoność, nazywa się to zakleszczeniem. Weźmy przykład ze strony Oracle z opisu koncepcji „ Deadlock ”:public class Deadlock {
static class Friend {
private final String name;
public Friend(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
public synchronized void bow(Friend bower) {
System.out.format("%s: %s has bowed to me!%n",
this.name, bower.getName());
bower.bowBack(this);
}
public synchronized void bowBack(Friend bower) {
System.out.format("%s: %s has bowed back to me!%n",
this.name, bower.getName());
}
}
public static void main(String[] args) {
final Friend alphonse = new Friend("Alphonse");
final Friend gaston = new Friend("Gaston");
new Thread(() -> alphonse.bow(gaston)).start();
new Thread(() -> gaston.bow(alphonse)).start();
}
}
Zakleszczenie może nie pojawić się za pierwszym razem, ale jeśli wykonanie programu utknęło w miejscu, czas uruchomić jvisualvm
: Jeśli wtyczka jest zainstalowana w JVisualVM (poprzez Narzędzia -> Wtyczki), możemy zobaczyć, gdzie wystąpił zakleszczenie:
"Thread-1" - Thread t@12
java.lang.Thread.State: BLOCKED
at Deadlock$Friend.bowBack(Deadlock.java:16)
- waiting to lock <33a78231> (a Deadlock$Friend) owned by "Thread-0" t@11
Wątek 1 oczekuje na blokadę z wątku 0. Dlaczego tak się dzieje? Thread-1
rozpoczyna wykonywanie i wykonuje metodę Friend#bow
. Jest ona oznaczona słowem-kluczem synchronized
, czyli monitor odbieramy po this
. Na wejściu do metody otrzymaliśmy link do kolejnego Friend
. Teraz wątek Thread-1
chce wykonać metodę na innym Friend
, uzyskując w ten sposób również od niego blokadę. Jeśli jednak inny wątek (w tym przypadku Thread-0
) zdołał wejść do metody bow
, to zamek jest już zajęty i Thread-1
czeka Thread-0
i odwrotnie. Blokada jest nierozwiązywalna, więc jest martwa, to znaczy martwa. Zarówno uścisk śmierci (którego nie można zwolnić), jak i martwy blok, z którego nie można uciec. Na temat zakleszczenia możesz obejrzeć film: „ Zakleszczenie – współbieżność nr 1 – zaawansowana Java ”.
Blokada życia
Jeśli istnieje zakleszczenie, to czy istnieje blokada na żywo? Tak, jest) Livelock polega na tym, że wątki wydają się żyć na zewnątrz, ale jednocześnie nie mogą nic zrobić, ponieważ ... warunek, pod którym starają się kontynuować swoją pracę, nie może zostać spełniony. W istocie Livelock jest podobny do impasu, ale wątki nie „zawieszają się” w systemie w oczekiwaniu na monitor, ale zawsze coś robią. Na przykład:import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class App {
public static final String ANSI_BLUE = "\u001B[34m";
public static final String ANSI_PURPLE = "\u001B[35m";
public static void log(String text) {
String name = Thread.currentThread().getName(); //like Thread-1 or Thread-0
String color = ANSI_BLUE;
int val = Integer.valueOf(name.substring(name.lastIndexOf("-") + 1)) + 1;
if (val != 0) {
color = ANSI_PURPLE;
}
System.out.println(color + name + ": " + text + color);
try {
System.out.println(color + name + ": wait for " + val + " sec" + color);
Thread.currentThread().sleep(val * 1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
Lock first = new ReentrantLock();
Lock second = new ReentrantLock();
Runnable locker = () -> {
boolean firstLocked = false;
boolean secondLocked = false;
try {
while (!firstLocked || !secondLocked) {
firstLocked = first.tryLock(100, TimeUnit.MILLISECONDS);
log("First Locked: " + firstLocked);
secondLocked = second.tryLock(100, TimeUnit.MILLISECONDS);
log("Second Locked: " + secondLocked);
}
first.unlock();
second.unlock();
} catch (InterruptedException e) {
e.printStackTrace();
}
};
new Thread(locker).start();
new Thread(locker).start();
}
}
Powodzenie tego kodu zależy od kolejności, w jakiej program planujący wątki Java uruchamia wątki. Jeśli zacznie się jako pierwszy Thead-1
, otrzymamy Livelock:
Thread-1: First Locked: true
Thread-1: wait for 2 sec
Thread-0: First Locked: false
Thread-0: wait for 1 sec
Thread-0: Second Locked: true
Thread-0: wait for 1 sec
Thread-1: Second Locked: false
Thread-1: wait for 2 sec
Thread-0: First Locked: false
Thread-0: wait for 1 sec
...
Jak widać na przykładzie, oba wątki na zmianę próbują przechwycić obie blokady, ale kończy się to niepowodzeniem. Co więcej, nie są w impasie, to znaczy wizualnie wszystko jest z nimi w porządku i wykonują swoją pracę. Według JVisualVM widzimy okresy uśpienia i okres parkowania (w tym momencie wątek próbuje zająć blokadę, przechodzi w stan parkowania, co omawialiśmy wcześniej, mówiąc o synchronizacji wątków ). Jeśli chodzi o livelock, możesz zobaczyć przykład: „ Java - Thread Livelock ”.
Głód
Oprócz blokowania (zakleszczenie i blokada na żywo) podczas pracy z wielowątkowością pojawia się inny problem - głód lub „głód”. Zjawisko to różni się od blokowania tym, że wątki nie są blokowane, ale po prostu nie mają wystarczających zasobów dla wszystkich. Dlatego chociaż niektóre wątki przejmują cały czas wykonywania, inne nie mogą zostać wykonane:https://www.logicbig.com/
Warunki wyścigu
Podczas pracy z wielowątkowością istnieje coś takiego jak „warunek wyścigu”. Zjawisko to polega na tym, że wątki współdzielą między sobą pewien zasób, a kod jest napisany w taki sposób, że nie zapewnia w tym przypadku poprawnego działania. Spójrzmy na przykład:public class App {
public static int value = 0;
public static void main(String[] args) {
Runnable task = () -> {
for (int i = 0; i < 10000; i++) {
int oldValue = value;
int newValue = ++value;
if (oldValue + 1 != newValue) {
throw new IllegalStateException(oldValue + " + 1 = " + newValue);
}
}
};
new Thread(task).start();
new Thread(task).start();
new Thread(task).start();
}
}
Ten kod może nie wygenerować błędu za pierwszym razem. A może to wyglądać tak:
Exception in thread "Thread-1" java.lang.IllegalStateException: 7899 + 1 = 7901
at App.lambda$main$0(App.java:13)
at java.lang.Thread.run(Thread.java:745)
Jak widać w trakcie przypisywania newValue
coś poszło nie tak i newValue
było ich więcej. Niektóre wątki w stanie wyścigu udało się zmienić value
między tymi dwoma zespołami. Jak widzimy pojawił się wyścig pomiędzy wątkami. A teraz wyobraź sobie, jak ważne jest, aby nie popełniać podobnych błędów przy transakcjach pieniężnych... Przykłady i diagramy można zobaczyć także tutaj: „ Kod symulujący sytuację wyścigową w wątku Java ”.
Lotny
Mówiąc o współdziałaniu wątków, warto szczególnie zwrócić uwagę na słowo kluczowevolatile
. Spójrzmy na prosty przykład:
public class App {
public static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
Runnable whileFlagFalse = () -> {
while(!flag) {
}
System.out.println("Flag is now TRUE");
};
new Thread(whileFlagFalse).start();
Thread.sleep(1000);
flag = true;
}
}
Najciekawsze jest to, że z dużym prawdopodobieństwem to nie zadziała. Nowy wątek nie zobaczy zmian flag
. Aby to naprawić, flag
musisz określić słowo kluczowe dla pola volatile
. Jak i dlaczego? Wszystkie działania są wykonywane przez procesor. Ale wyniki obliczeń trzeba gdzieś przechowywać. W tym celu na procesorze znajduje się pamięć główna i sprzętowa pamięć podręczna. Te pamięci podręczne procesorów przypominają mały fragment pamięci umożliwiający dostęp do danych szybciej niż dostęp do pamięci głównej. Ale wszystko ma też swoją wadę: dane w pamięci podręcznej mogą być nieaktualne (jak w powyższym przykładzie, gdy wartość flagi nie została zaktualizowana). Zatem słowo kluczowe volatile
mówi maszynie JVM, że nie chcemy buforować naszej zmiennej. Dzięki temu możesz zobaczyć rzeczywisty wynik we wszystkich wątkach. Jest to bardzo uproszczone sformułowanie. Na ten temat volatile
zdecydowanie zaleca się przeczytanie tłumaczenia „ JSR 133 (Java Memory Model) FAQ ”. Radzę także przeczytać więcej o materiałach „ Model pamięci Java ” i „ Java Volatile Keyword ”. Poza tym trzeba pamiętać, że volatile
chodzi tu o widoczność, a nie o atomowość zmian. Jeśli weźmiemy kod z „Race Condition”, zobaczymy podpowiedź w IntelliJ Idea: Ta inspekcja (Inspekcja) została dodana do IntelliJ Idea jako część problemu IDEA-61117 , który został wymieniony w uwagach do wydania w 2010 roku.
Atomowość
Operacje atomowe to operacje, których nie można podzielić. Na przykład operacja przypisania wartości do zmiennej jest niepodzielna. Niestety przyrost nie jest operacją atomową, ponieważ przyrost wymaga aż trzech operacji: pobierz starą wartość, dodaj ją do niej i zapisz wartość. Dlaczego atomowość jest ważna? W przykładzie przyrostu, jeśli wystąpi sytuacja wyścigu, w dowolnym momencie współdzielony zasób (tj. współdzielona wartość) może nagle się zmienić. Ponadto ważne jest, aby struktury 64-bitowe również nie były atomowe, np.long
i double
. Więcej możesz przeczytać tutaj: „ Zapewnij atomowość podczas odczytu i zapisu wartości 64-bitowych ”. Przykład problemów z atomowością można zobaczyć w następującym przykładzie:
public class App {
public static int value = 0;
public static AtomicInteger atomic = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 10000; i++) {
value++;
atomic.incrementAndGet();
}
};
for (int i = 0; i < 3; i++) {
new Thread(task).start();
}
Thread.sleep(300);
System.out.println(value);
System.out.println(atomic.get());
}
}
Specjalna klasa do pracy z atomem Integer
zawsze pokaże nam 30000, ale value
od czasu do czasu będzie się to zmieniać. Istnieje krótki przegląd tego tematu „ Wprowadzenie do zmiennych atomowych w Javie ”. Atomic opiera się na algorytmie Porównaj i Zamień. Więcej na ten temat przeczytacie w artykule na temat Habrégo „ Porównanie algorytmów Lock-free – CAS i FAA na przykładzie JDK 7 i 8 ” lub na Wikipedii w artykule o „ Porównaniu z wymianą ”.
http://jeremymanson.blogspot.com/2008/11/what-volatile-means-in-java.html
Dzieje się wcześniej
Jest ciekawa i tajemnicza rzecz – zdarza się wcześniej. Skoro już mowa o przepływach, warto o tym także poczytać. Relacja Dzieje się przed wskazuje kolejność, w jakiej będą widoczne akcje między wątkami. Interpretacji i interpretacji jest wiele. Jednym z najnowszych raportów na ten temat jest ten raport:Wyniki
W tej recenzji przyjrzeliśmy się funkcjom interakcji wątków. Omówiliśmy problemy, które mogą się pojawić oraz sposoby ich wykrywania i eliminowania. Lista dodatkowych materiałów na ten temat:- Jeszcze raz o podwójnie sprawdzonym zamykaniu
- JSR 133 (model pamięci Java) FAQ (tłumaczenie)
- Zaawansowana Java - współbieżność (Yuri Tkach)
- Koncepcje współbieżności w Javie autorstwa Douglasa Hawkinsa (2017)
GO TO FULL VERSION