Programowanie
wielowątkowe
Wątki a procesy
Jako jeden z niewielu języków programowania Java udostępnia użytkownikowi mechanizmy wspierające programowanie
wielowątkowe. Program wielowątkowy zawiera kilka równoległych ścieżek wykonania. Każda tak ścieżka nazywa się wątkiem.
Wielowątkowość jest specyficzną formą wielozadaniowości. Istnieją dwa typy wielozadaniowości:
Bazujący na procesach: pozwala na równoległe wykonanie kilku programów
Bazujący na wątkach: pojedynczy program może wykonywać kilka zadań równolegle
Typ procesowy występuje we większości systemów operacyjnych. Proces posiada bogate środowisko wykonania, swoją przestrzeń adresową itp. Komunikacja i przełączanie między procesami są kosztowne.
Wątki są tańsze, ponieważ bazują na jednym procesie i współdzielą jego zasoby. Pozwalają maksymalnie wykorzystać procesor.
Cykl życia wątku
new Thread Wątek działa działa czeka wyłączony start sleep wait Koniec metody run Powyższy diagram przedstawia cykl życia wątku. Kodowanieposzczególnych etapów życia wątku przedstawimy w dalszej części wykładu.
Wątki
priorytety
Java z każdym wątkiem programu kojarzy priorytet, informujący o tym jak należy traktować wątek w stosunku do innych wątków programu. Priorytet jest liczbą całkowitą określającą relatywną ważność wątku w stosunku do innych wątków.
Priorytet wątku nie ma wpływu na szybkość wykonywania wątku, a jedynie na pierwszeństwo w wyborze do wykonania (zmiany
kontekstu wykoniania). Reguły rządzące tym kiedy zmieniać kontekst wykoniania są następujące:
Wątek może dobrowolnie odstąpić prawo do wykonania innym wątkom. Sytuacja taka ma miejsce, gdy wątek musi czekać na
zwolnienie zasobu lub, gdy wykonał metodę sleep. Wybierany jest wówczas wątek z oczekujących o najwyższym priorytecie.
Wątek może zostać wyparty przez wątek o wyższym priorytecie.
Tj., gdy tylko wątek o wyższym priorytecie potrzebuje procesor, to go bierze.
Wątek główny
Wraz z uruchomieniem programu napisanego w Java jeden wątek, zwany wątkiem głównym, startuje natychmiast. Wątek główny jest ważny, ponieważ:
Jest wątkiem mogącym zapoczątkowywać inne wątki, zwane wątkami potomnymi
Często musi być ostatnim wątkiem kończącym wykonanie programu Wątek główny niczym nie różni się od innych wątków programu.
By uzyskać nad nim kontrolę musimy utworzyć do niego referencję, korzystając z publicznej statycznej metody currentThread() klasy Thread. Metoda ta oddaje referencję do wątku, w którym została
wywołana.
Tworzenie własnego wątku
By utworzyć nowy wątek trzeba utworzyć obiekt typu Thread. Można to zrobić na dwa sposoby:
Zaimplementować interfejs Runnable Rozszerzyć klasę Thread
Implementacja Runnable
Interfejs Runnable jest abstrakcją wykonywalnego kodu. Można utworzyć wątek bazując na dowolnym obiekcie
implementującym ten interfejs. By implementować ten interfejs, klasa musi jedynie implementować pojedynczą metodę:
public void run()
Ciało tej metody stanowi kod wątku. Wątek taki kończy się wraz z zakończeniem działania metody run(). Wywołanie konstruktora:
Thread(Runnable obWątku, String nazwa);
Tworzy nowy wątek bazując na obiekcie obWątku z nazwą nazwa. Metoda start() uruchamia metodę run() obiektu wątku.
Zobacz:
Rozszerzanie klasy Thread
Kolejnym sposobem tworzenia własnych wątków jest definiowanie
nowych klas dziedziczących po klasie Thread, następnie tworzenie ich instancji. Klasa rozszerzająca musi nadpisać metodę run() klasy
Thread. Podobnie jak w poprzednim przykładzie musimy wykonać metodę start() by rozpocząć wykonanie nowego wątku.
Zobacz: ExtendThreadDemo.java
Co lepsze?
Przedstawiliśmy dwa sposoby tworzenia nowych wątków. Który z nich jest lepszy? Zgodnie z metodologią obiektową dziedziczenie klas ma sens, gdy klasa pochodna zmienia coś istotnego w klasie
dziedziczonej. W naszym przypadku była to tylko metoda run(). Wydaje się, że w takim przypadku bardziej odpowiednia jest
implementacja interfejsu Runnable.
Powyższe rozważania traktują raczej o dobrym stylu, niż o poprawności kodowania.
Metody isAlive() i join()
W poprzednich programach staraliśmy się nie doprowadzać do sytuacji, w której wątek główny kończył by się zanim zakończą się wątki potomne. Efekt ten osiągaliśmy przez odpowiednio długie usypianie wątku głównego. Takie rozwiązanie jest rzadko
satysfakcjonujące w realnych programach.
Skąd więc wątek może wiedzieć, że inny wątek się zakończył? Odpowiedzi na to pytanie mogą udzielić dwie metody klasy Thread:
final boolean isAlive() oddająca true, gdy wątek, dla którego ją wywołujemy działa, false w przeciwnym przypadku
final void join() throws InterruptedException metoda
czeka, aż wątek, dla którego wywołujemy zakończy się; dodatkowe wersje join pozwalają podać maksymalny czas oczekiwania na zakończenie wątku
Priorytety wątków
W praktyce wątki o wyższym priorytecie powinny mieć łatwiejszy dostęp do procesora niż wątki i niższym priorytecie. W rzeczywistości dostęp do procesora zależy od wielu czynników. Najważniejszym z nich jest implementacja w danym systemie wielozadaniowości. Aby zapewnić wszystkim wątkom dostęp do procesora, także w środowiskach bez wywałaszczania, wątki powinny oddawać sterowanie co jakiś czas. Do ustawiania priorytetu wątku służy metoda:
final void setPriority(int poziom)
Parametr poziom, to nowy priorytet wątku. Jego wartości powinny być z zakresu od MIN_PRIORITY (1) do MAX_PRIORITY (10). Domyślnym priorytetem jest NORM_PRIORITY (5).
Kompilator optymalizując kod zakłada, że wykonanie bieżącego kodu zależy jedynie od niego samego. W przypadku wielu wątków często wątki mogą wpływać na siebie. Słowo volatile wstrzymuje
Zobacz:
Synchronizacja
koncepcje
Gdy dwa lub więcej wątków chce korzystać ze wspólnych zasobów, potrzebują pewnego sposobu upewnienia się, że z zasobu będzie
korzystać tylko jeden wątek w jednym czasie. W informatyce taki sposób upewniania się nazywa się synchronizacją. Zwykle synchronizacja
wymaga wykonania pewnego protokołu przez wątki. Java dostarcza własny sposób synchronizacji na poziomie języka.
Synchronizacja jest zapewniana w Java przez tzw. monitory.
Monitor jest obiektem zapewniającym wzajemne wykluczanie wątków. Tylko jeden wątek może być w posiadaniu jednego monitora w jednym czasie.
Wątek wchodzący do tzw. sekcji krytycznej otwiera monitor. Wszystkie inne wątki próbujące otworzyć ten sam monitor są
wstrzymane do czasu aż pierwszy wątek opuści monitor. Wątki takie nazywamy oczekującymi na dostęp.
Synchronizacja
przykład
Synchronizacja w Java nie jest zbyt skomplikowana. Każdy obiekt w Java posiada związany z nim niejawny obiekt monitora. Aby otworzyć monitor obiektu trzeba wywołać jego metodę zdefiniowaną z
modyfikatorem synchronized. Dopóki wątek znajduje się wewnątrz
metody synchronizowanej każdy inny wątek chcący wywołać tę lub
inną metodę synchronizowaną obiektu, musi czekać aż pierwszy wątek opuści metodę synchronizowaną.
Aby lepiej zrozumieć działanie monitorów w Java, prześledźmy przykład programu, w którym wątki mogą dostać się do sekcji krytycznej bez
synchronizacji.
Zobacz: NoSynch.java
Poprawka w poprzednim programie polega na synchronizowaniu
metody call() klasy CallMe. Po poprawce tylko jeden wątek będzie mógł korzystać z metody call() w jednym czasie.
Komenda synchronized
Użycie metod synchronizowanych jest prostym sposobem uzyskania wzajemnego wykluczania wątków. Niestety nie zawsze może być
stosowany.
Wyobraźmy sobie, że chcemy synchronizować dostęp wątków do
obiektu, który nie był zaprojektowany do używania przez wiele wątków. Tj. klasa ta nie używa metod synchronizowanych. Co więcej klasa ta nie była projektowana przez nas i nie mamy dostępu do kodu źródłowego. W jaki sposób możemy synchronizować dostęp do tego obiektu?
Odpowiedź:
Wywołania metod, które powinny być synchronizowane wstawiamy do bloku synchronized:
synchronized (obiekt) {
// synchronizowane komendy }
Powyżej obiekt jest synchronizowanym obiektem. Wywołania metod synchronizowanego obiektu będą możliwe tylko wtedy, gdy obiekt zdoła
Zobacz:
Wątki
komunikacja
Dotychczas wątki jedynie blokowały dostęp do zasobów krytycznych. Po zwolnieniu zasobu wątki konkurują o zwoniony zasób. Polegając na tym mechaniźmie nie możemy wyrazić bardziej subtelnych związków między wątkami, takich jak: uczciwość, żywotność, ... .
Java prócz mechanizmów zapewniających wzajemne wykluczanie
oferuje mechanizm wymiany sygnałów między współbieżnymi wątkami. Służą do tego metody finalne klasy Object: wait(), notify() i
notifyAll(). Metody te mogą być wywołane tylko z metody synchronizowanej. Ich znaczenie:
wait() informuje wątek wywołujący by opuścił monitor i zasnął do czasu aż inny wątek otworzy ten sam monitor i wywoła metodę
notify() lub notifyAll()
notify() budzi wątek, który jako pierwszy wywołał wait() na tym samym obiekcie
notifyAll() budzi wszystkie wątki, które wywołały wait() na tym samym obiekcie; wątek o najwyższym priorytecie działa, reszta śpi
Wątki
producent konsument
Produkcja Bufor Konsumpcja
Producent Konsument
Implementacja powyższego modelu producenta i konsumenta z
buforem jednoelementowym wymaga użycia mechanizmu sygnałów. Niepoprawny sposób implementacji zapewniający wyłącznie wzajemne wykluczanie w dostępie do bufora można znaleźć w pliku:
ProdKonsErr.java
Poprawną implementację można znaleźć w pliku:
ProdKons.java
Wątki
zakleszczenie
Istnieje specjalny typ błędu związanego z wielozadaniowością i wielowątkowością w szególności. Jest nim zakleszczenie.
Z zakleszczeniem mamy do czynienia np. wtedy, gdy jeden wątek
otwiera monitor obiektu X, drugi otwiera monitor obiektu Y, i pierwszy próbuje wywołać synchronizowaną metodę obiektu Y, natomiast drugi próbuje wywołać synchronizowaną metodę obiektu X. Oba wątki będą czekały na zwonienie zasobów blokując je sobie wzajemnie. Taką
sytuację nazywamy zakleszczeniem.
Zakleszczenie jest trudne do wykrycia ponieważ:
W programie, w którym zakleszczenie jest możliwe zwykle rzadko do niego dochodzi; oba wątki muszą w tym samym czasie być w
odpowiednim miejscu kodu
W wielu programach występuje więcej niż dwa wątki i dwie
synchronizowane metody; może dojść do zakleszczeń zespołowych Zobacz: Zakleszczenie.java
Wstrzymaj, wznów, stop
W Java 1.1 wstrzymanie, wznowienie i zatrzymanie wątku odbywało się przez wywołanie metod suspend(), resume() i stop() klasy
Thread. Metody te nie są stosowane w Java 2.
Zobacz: Suspend1_1.java
W Java 2 zrezygnowano z powyższych metod, ponieważ mogły
doprowadzić do poważnych błędów programu, np.: wstrzymanie wątku, gdy ten blokuje zasób krytyczny spowoduje zablokowanie tego zasobu na zawsze, zatrzymanie wątku w trakcie zapisu danych może
spowodować niekompletność zapisywanych danych.
W Java 2 należy tak zaprojektować metodę run() by wątek sam, raz na jakiś czas, sprawdzał czy powinien wstrzymać, wznowić czy
zatrzymać wykonanie. Zwykle osiąga się to za pomocą flag i metod wait() i notify().
Pięciu filozofów
Stó
ł
F1
F2
F3
F4
F5
Filozof myśli. Gdy zgłodnieje podnosi prawy widelec,
następnie lewy i je. Po
zakończonym posiłku odkłada najpierw prawy a potem lewy widelec i zaczyna myśleć, aż zgłodnieje, itd...
Zadanie:
1. Zaimplementować w Java problem pięciu filozofów za pomocą wątków.
2. Rozwiązać zadanie tak by nie dochodziło do zagłodzenia żadnego filozofa.