JavaRush /Blog Java /Random-PL /Podstawy współbieżności: zakleszczenia i monitory obiektó...
Snusmum
Poziom 34
Хабаровск

Podstawy współbieżności: zakleszczenia i monitory obiektów (sekcje 1, 2) (tłumaczenie artykułu)

Opublikowano w grupie Random-PL
Artykuł źródłowy: http://www.javacodegeeks.com/2015/09/concurrency-fundamentals-deadlocks-and-object-monitors.html Opublikowano przez: Martin Mois Ten artykuł jest częścią naszego kursu Podstawy współbieżności w języku Java . Na tym kursie zagłębisz się w magię równoległości. Poznasz podstawy równoległości i kodu równoległego oraz zapoznasz się z takimi pojęciami, jak atomowość, synchronizacja i bezpieczeństwo wątków. Spójrz na to tutaj !

Treść

1. Żywotność  1.1 Zakleszczenie  1.2 Głodzenie 2. Monitory obiektów za pomocą funkcji Wait() i notify()  2.1 Zagnieżdżone zsynchronizowane bloki za pomocą funkcji Wait() i notify()  2.2 Warunki w blokach synchronizowanych 3. Projektowanie wielowątkowości  3.1 Obiekt niezmienny  3.2 Projektowanie API  3.3 Lokalne przechowywanie wątków
1. Witalność
Podczas tworzenia aplikacji korzystających z równoległości do osiągnięcia swoich celów możesz napotkać sytuacje, w których różne wątki mogą się wzajemnie blokować. Jeśli w tej sytuacji aplikacja działa wolniej niż oczekiwano, mówimy, że nie działa ona zgodnie z oczekiwaniami. W tej sekcji przyjrzymy się bliżej problemom, które mogą zagrozić żywotności aplikacji wielowątkowej.
1.1 Wzajemne blokowanie
Termin zakleszczenie jest dobrze znany wśród twórców oprogramowania i nawet większość zwykłych użytkowników używa go od czasu do czasu, choć nie zawsze we właściwym znaczeniu. Ściśle mówiąc, termin ten oznacza, że ​​każdy z dwóch (lub więcej) wątków czeka, aż drugi wątek zwolni zablokowany przez niego zasób, podczas gdy pierwszy wątek sam zablokował zasób, na który czeka drugi: Aby lepiej zrozumieć problemu, spójrz na Thread 1: locks resource A, waits for resource B Thread 2: locks resource B, waits for resource A następujący kod: public class Deadlock implements Runnable { private static final Object resource1 = new Object(); private static final Object resource2 = new Object(); private final Random random = new Random(System.currentTimeMillis()); public static void main(String[] args) { Thread myThread1 = new Thread(new Deadlock(), "thread-1"); Thread myThread2 = new Thread(new Deadlock(), "thread-2"); myThread1.start(); myThread2.start(); } public void run() { for (int i = 0; i < 10000; i++) { boolean b = random.nextBoolean(); if (b) { System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1."); synchronized (resource1) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1."); System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2."); synchronized (resource2) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2."); } } } else { System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2."); synchronized (resource2) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2."); System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1."); synchronized (resource1) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1."); } } } } } } Jak widać z powyższego kodu, uruchamiają się dwa wątki i próbują zablokować dwa zasoby statyczne. Jednak w przypadku zakleszczenia potrzebujemy innej sekwencji dla obu wątków, dlatego używamy instancji obiektu Random, aby wybrać, który zasób wątek chce zablokować jako pierwszy. Jeśli zmienna logiczna b ma wartość true, wówczas zasób1 jest najpierw blokowany, a następnie wątek próbuje uzyskać blokadę dla zasobu2. Jeśli b ma wartość false, wątek blokuje zasób2, a następnie próbuje uzyskać zasób1. Program ten nie musi działać długo, aby osiągnąć pierwszy impas, tj. Program zawiesi się na zawsze, jeśli go nie przerwiemy: [thread-1] Trying to lock resource 1. [thread-1] Locked resource 1. [thread-1] Trying to lock resource 2. [thread-1] Locked resource 2. [thread-2] Trying to lock resource 1. [thread-2] Locked resource 1. [thread-1] Trying to lock resource 2. [thread-1] Locked resource 2. [thread-2] Trying to lock resource 2. [thread-1] Trying to lock resource 1. W tym przebiegu bieżnik-1 uzyskał blokadę zasobu 2 i czeka na blokadę zasobu 1, podczas gdy bieżnik 2 ma blokadę zasobu 1 i czeka na blokadę zasobu 2. Gdybyśmy w powyższym kodzie ustawili wartość zmiennej logicznej b na true, nie bylibyśmy w stanie zaobserwować żadnego zakleszczenia, ponieważ kolejność blokad żądań wątków 1 i 2 byłaby zawsze taka sama. W tej sytuacji jeden z dwóch wątków najpierw uzyska blokadę, a następnie zażąda drugiego, który jest nadal dostępny, ponieważ drugi wątek oczekuje na pierwszą blokadę. Ogólnie rzecz biorąc, możemy rozróżnić następujące warunki konieczne do wystąpienia zakleszczenia: - Wspólne wykonanie: Istnieje zasób, do którego w danym momencie może uzyskać dostęp tylko jeden wątek. - Wstrzymanie zasobu: Podczas zdobywania jednego zasobu wątek próbuje uzyskać kolejną blokadę jakiegoś unikalnego zasobu. - Brak wywłaszczania: nie ma mechanizmu zwalniającego zasób, jeśli jeden wątek utrzymuje blokadę przez określony czas. - Oczekiwanie cykliczne: podczas wykonywania następuje zbiór wątków, w którym dwa (lub więcej) wątki czekają na siebie nawzajem w celu zwolnienia zablokowanego zasobu. Chociaż lista warunków wydaje się długa, nierzadko w dobrze działających aplikacjach wielowątkowych występują problemy z zakleszczeniem. Można jednak temu zapobiec, usuwając jeden z powyższych warunków: - Wspólne wykonanie: tego warunku często nie można usunąć, gdy zasób musi być używany tylko przez jedną osobę. Ale to nie musi być powód. W przypadku korzystania z systemów DBMS możliwym rozwiązaniem zamiast stosowania pesymistycznej blokady w jakimś wierszu tabeli, który wymaga aktualizacji, jest zastosowanie techniki zwanej blokowaniem optymistycznym . - Sposobem na uniknięcie trzymania zasobu podczas oczekiwania na inny ekskluzywny zasób jest zablokowanie wszystkich niezbędnych zasobów na początku algorytmu i zwolnienie ich wszystkich, jeśli nie da się ich wszystkich zablokować na raz. Oczywiście nie zawsze jest to możliwe; być może zasoby wymagające zablokowania nie są z góry znane lub takie podejście po prostu doprowadzi do marnowania zasobów. - Jeżeli nie można natychmiast uzyskać blokady, sposobem na ominięcie możliwego zakleszczenia jest wprowadzenie limitu czasu. Na przykład klasa ReentrantLockz SDK zapewnia możliwość ustawienia daty ważności zamka. - Jak widzieliśmy na powyższym przykładzie, zakleszczenie nie występuje, jeśli sekwencja żądań nie różni się pomiędzy różnymi wątkami. Można to łatwo kontrolować, jeśli można umieścić cały kod blokujący w jednej metodzie, przez którą muszą przejść wszystkie wątki. W bardziej zaawansowanych aplikacjach można nawet rozważyć wdrożenie systemu wykrywania zakleszczeń. Tutaj będziesz musiał zaimplementować pozory monitorowania wątków, w którym każdy wątek raportuje, że pomyślnie uzyskał blokadę i próbuje uzyskać blokadę. Jeśli wątki i blokady są modelowane jako graf skierowany, można wykryć, kiedy dwa różne wątki wstrzymują zasoby, próbując jednocześnie uzyskać dostęp do innych zablokowanych zasobów. Jeśli możesz następnie zmusić wątki blokujące do zwolnienia wymaganych zasobów, możesz automatycznie rozwiązać sytuację zakleszczenia.
1.2 Post
Program planujący decyduje, który wątek w stanie RUNNABLE powinien wykonać jako następny. Decyzja opiera się na priorytecie wątku; dlatego wątki o niższym priorytecie otrzymują mniej czasu procesora w porównaniu do wątków o wyższym priorytecie. To, co wygląda na rozsądne rozwiązanie, może również powodować problemy, jeśli będzie nadużywane. Jeśli przez większość czasu wykonywane są wątki o wysokim priorytecie, wątki o niskim priorytecie wydają się głodować, ponieważ nie mają wystarczająco dużo czasu na prawidłowe wykonanie swojej pracy. Dlatego zaleca się ustawianie priorytetu wątku tylko wtedy, gdy istnieje ku temu ważny powód. Nieoczywistym przykładem głodzenia wątku jest na przykład metoda finalize(). Umożliwia językowi Java wykonanie kodu, zanim obiekt zostanie usunięty. Ale jeśli spojrzysz na priorytet zamykającego wątku, zauważysz, że nie działa on z najwyższym priorytetem. W rezultacie głód wątku występuje, gdy metody finalize() obiektu spędzają zbyt dużo czasu w porównaniu z resztą kodu. Inny problem z czasem wykonania wynika z faktu, że nie jest zdefiniowana kolejność, w jakiej wątki przechodzą przez zsynchronizowany blok. Kiedy wiele równoległych wątków przechodzi przez jakiś kod umieszczony w zsynchronizowanym bloku, może się zdarzyć, że niektóre wątki będą musiały czekać dłużej niż inne, zanim znajdą się w bloku. Teoretycznie mogą nigdy tam nie dotrzeć. Rozwiązaniem tego problemu jest tzw. „uczciwe” blokowanie. Uczciwe blokady uwzględniają czas oczekiwania wątku przy ustalaniu, kto przejść dalej. Przykładowa implementacja sprawiedliwego blokowania jest dostępna w pakiecie Java SDK: java.util.concurrent.locks.ReentrantLock. Jeśli używany jest konstruktor z flagą boolowską ustawioną na true, wówczas ReentrantLock daje dostęp do wątku, który czekał najdłużej. Gwarantuje to brak głodu, ale jednocześnie prowadzi do problemu ignorowania priorytetów. Z tego powodu procesy o niższym priorytecie, które często czekają przy tej barierze, mogą działać częściej. Wreszcie klasa ReentrantLock może uwzględniać tylko wątki oczekujące na blokadę, tj. wątki, które uruchamiały się wystarczająco często i docierały do ​​bariery. Jeśli priorytet wątku jest zbyt niski, nie będzie to zdarzać się często, dlatego wątki o wysokim priorytecie nadal będą częściej przechodzić przez blokadę.
2. Monitory obiektów wraz z funkcjami Wait() i Notify()
W przetwarzaniu wielowątkowym typową sytuacją jest to, że niektóre wątki robocze czekają, aż producent utworzy dla nich jakąś pracę. Jednak, jak się dowiedzieliśmy, aktywne czekanie w pętli podczas sprawdzania określonej wartości nie jest dobrą opcją ze względu na czas procesora. Użycie metody Thread.sleep() w tej sytuacji również nie jest szczególnie odpowiednie, jeśli chcemy rozpocząć pracę od razu po przybyciu. W tym celu język programowania Java ma inną strukturę, którą można zastosować w tym schemacie: wait() i notify(). Metoda wait(), dziedziczona przez wszystkie obiekty z klasy java.lang.Object, może służyć do zawieszenia bieżącego wątku i zaczekania, aż obudzi nas inny wątek za pomocą metody notify(). Aby działać poprawnie, wątek wywołujący metodę Wait() musi posiadać blokadę, którą uzyskał wcześniej za pomocą słowa kluczowego synchronized. Po wywołaniu funkcji Wait() blokada zostaje zwolniona, a wątek czeka, aż inny wątek, który teraz przechowuje blokadę, wywoła funkcję notify() w tej samej instancji obiektu. W aplikacji wielowątkowej może naturalnie istnieć więcej niż jeden wątek oczekujący na powiadomienie o jakimś obiekcie. Dlatego istnieją dwie różne metody budzenia wątków: notify() i notifyAll(). Podczas gdy pierwsza metoda budzi jeden z oczekujących wątków, metoda notifyAll() budzi wszystkie. Należy jednak pamiętać, że podobnie jak w przypadku słowa kluczowego synchronized nie ma reguły określającej, który wątek zostanie obudzony jako następny po wywołaniu funkcji notify(). W prostym przykładzie z producentem i konsumentem nie ma to znaczenia, ponieważ nie obchodzi nas, który wątek zostanie obudzony. Poniższy kod pokazuje, jak można użyć funkcji wait() i notify(), aby spowodować, że wątki konsumenckie będą czekać na umieszczenie nowej pracy w kolejce przez wątek producenta: package a2; import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; public class ConsumerProducer { private static final Queue queue = new ConcurrentLinkedQueue(); private static final long startMillis = System.currentTimeMillis(); public static class Consumer implements Runnable { public void run() { while (System.currentTimeMillis() < (startMillis + 10000)) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } if (!queue.isEmpty()) { Integer integer = queue.poll(); System.out.println("[" + Thread.currentThread().getName() + "]: " + integer); } } } } public static class Producer implements Runnable { public void run() { int i = 0; while (System.currentTimeMillis() < (startMillis + 10000)) { queue.add(i++); synchronized (queue) { queue.notify(); } try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { queue.notifyAll(); } } } public static void main(String[] args) throws InterruptedException { Thread[] consumerThreads = new Thread[5]; for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i] = new Thread(new Consumer(), "consumer-" + i); consumerThreads[i].start(); } Thread producerThread = new Thread(new Producer(), "producer"); producerThread.start(); for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i].join(); } producerThread.join(); } } Metoda main() uruchamia pięć wątków konsumenckich i jeden wątek producenta, a następnie czeka na ich zakończenie. Następnie wątek producenta dodaje nową wartość do kolejki i powiadamia wszystkie oczekujące wątki, że coś się stało. Konsumenci dostają blokadę kolejki (tzn. jeden losowy konsument), a następnie idą spać, aby zostać podniesionym później, gdy kolejka ponownie się zapełni. Gdy producent zakończy pracę, powiadamia wszystkich konsumentów, aby ich obudzili. Gdybyśmy nie wykonali ostatniego kroku, wątki konsumenckie czekałyby w nieskończoność na kolejne powiadomienie, ponieważ nie ustawiliśmy limitu czasu oczekiwania. Zamiast tego możemy użyć metody wait(long timeout), aby obudzić się przynajmniej po pewnym czasie.
2.1 Zagnieżdżone zsynchronizowane bloki z funkcjami Wait() i Notify()
Jak wspomniano w poprzedniej sekcji, wywołanie funkcji Wait() na monitorze obiektu zwalnia blokadę tylko na tym monitorze. Inne blokady utrzymywane przez ten sam wątek nie są zwalniane. Jak łatwo zrozumieć, w codziennej pracy może się zdarzyć, że wątek wywołujący Wait() jeszcze bardziej podtrzyma blokadę. Jeśli inne wątki również czekają na te blokady, może wystąpić sytuacja zakleszczenia. Przyjrzyjmy się blokowaniu w następującym przykładzie: public class SynchronizedAndWait { private static final Queue queue = new ConcurrentLinkedQueue(); public synchronized Integer getNextInt() { Integer retVal = null; while (retVal == null) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } } return retVal; } public synchronized void putInt(Integer value) { synchronized (queue) { queue.add(value); queue.notify(); } } public static void main(String[] args) throws InterruptedException { final SynchronizedAndWait queue = new SynchronizedAndWait(); Thread thread1 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { queue.putInt(i); } } }); Thread thread2 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { Integer nextInt = queue.getNextInt(); System.out.println("Next int: " + nextInt); } } }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); } } Jak dowiedzieliśmy się wcześniej , dodanie synchronized do sygnatury metody jest równoznaczne z utworzeniem bloku synchronized(this){}. W powyższym przykładzie przypadkowo dodaliśmy do metody słowo kluczowe synchronized, a następnie zsynchronizowaliśmy kolejkę z monitorem obiektu kolejki, aby uśpić ten wątek w oczekiwaniu na następną wartość z kolejki. Następnie bieżący wątek zwalnia blokadę kolejki, ale nie tę. Metoda putInt() powiadamia uśpiony wątek o dodaniu nowej wartości. Ale przez przypadek dodaliśmy również słowo kluczowe synchronized do tej metody. Teraz, gdy druga nić zapadła w sen, nadal trzyma zamek. Dlatego pierwszy wątek nie może wejść do metody putInt(), gdy blokada jest utrzymywana przez drugi wątek. W efekcie mamy impas i zamrożony program. Jeśli uruchomisz powyższy kod, stanie się to natychmiast po uruchomieniu programu. Na co dzień sytuacja ta może nie być tak oczywista. Blokady utrzymywane przez wątek mogą zależeć od parametrów i warunków napotkanych w czasie wykonywania, a zsynchronizowany blok powodujący problem może nie znajdować się w kodzie tak blisko miejsca, w którym umieściliśmy wywołanie Wait(). Utrudnia to znalezienie takich problemów, zwłaszcza że mogą one pojawiać się z biegiem czasu lub pod dużym obciążeniem.
2.2 Warunki w blokach synchronicznych
Często przed wykonaniem jakiejkolwiek akcji na synchronizowanym obiekcie trzeba sprawdzić, czy spełniony jest jakiś warunek. Kiedy na przykład masz kolejkę, chcesz poczekać, aż się zapełni. Można zatem napisać metodę sprawdzającą, czy kolejka jest pełna. Jeśli nadal jest pusty, uśpij bieżący wątek do czasu jego wybudzenia: public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { retVal = queue.poll(); if (retVal == null) { System.err.println("retVal is null"); throw new IllegalStateException(); } } return retVal; } Powyższy kod synchronizuje się z kolejką przed wywołaniem funkcji Wait(), a następnie czeka w pętli while, aż przynajmniej jeden element pojawi się w kolejce. Drugi zsynchronizowany blok ponownie wykorzystuje kolejkę jako monitor obiektu. Wywołuje metodę poll() kolejki, aby uzyskać wartość. W celach demonstracyjnych zgłaszany jest wyjątek IllegalStateException, gdy ankieta zwraca wartość null. Dzieje się tak, gdy w kolejce nie ma elementów do pobrania. Po uruchomieniu tego przykładu zobaczysz, że bardzo często zgłaszany jest wyjątek IllegalStateException. Chociaż synchronizacja została wykonana poprawnie przy użyciu monitora kolejek, zgłoszony został wyjątek. Powodem jest to, że mamy dwa różne zsynchronizowane bloki. Wyobraź sobie, że mamy dwa wątki, które dotarły do ​​pierwszego zsynchronizowanego bloku. Pierwszy wątek wszedł do bloku i poszedł spać, bo kolejka była pusta. To samo dotyczy drugiego wątku. Teraz, gdy oba wątki są obudzone (dzięki wywołaniu notifyAll() wywołanemu przez drugi wątek dla monitora), oba widzą wartość (element) w kolejce dodaną przez producenta. Następnie obaj dotarli do drugiej bariery. Tutaj pierwszy wątek wszedł i pobrał wartość z kolejki. Kiedy wchodzi drugi wątek, kolejka jest już pusta. W związku z tym otrzymuje wartość null jako wartość zwróconą z kolejki i zgłasza wyjątek. Aby zapobiec takim sytuacjom, należy wykonać wszystkie operacje zależne od stanu monitora w tym samym zsynchronizowanym bloku: public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } return retVal; } Tutaj wykonujemy metodę poll() w tym samym zsynchronizowanym bloku, co metoda isEmpty(). Dzięki blokowi synchronized mamy pewność, że w danym momencie tylko jeden wątek wykonuje metodę dla tego monitora. Dlatego żaden inny wątek nie może usuwać elementów z kolejki pomiędzy wywołaniami isEmpty() i poll(). Ciąg dalszy tłumaczenia tutaj .
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION