Wątki
Transkrypt
Wątki
Programowanie wielowątkowe Tomasz Borzyszkowski 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. 2 Cykl życia wątku Wątek działa new Thread działa start wyłączony sleep wait czeka Koniec metody run Powyższy diagram przedstawia cykl życia wątku. Kodowanie poszczególnych etapów życia wątku przedstawimy w dalszej części wykładu. 3 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. 4 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. Zobacz: CurrentThreadDemo.java 5 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 Zobacz: Rozszerzyć klasę Thread NewThreadDemo.java 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. 6 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. Co lepsze? Zobacz: ExtendThreadDemo.java 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. Zobacz: MultiThreadDemo.java 7 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 Zobacz: JoinThreadDemo.java 8 Priorytety wątków Zobacz: PriorityDemo.java 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 optymalizację wybranego kodu. 9 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. Większość języków programowania nie implementuje mechanizmów wzajemnego wykluczania (patrz C, C++, Pascal, ...). 10 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. Zobacz: Synch.java 11 Komenda synchronized Zobacz: Synchronized.java 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 otworzyć monitor obiektu. 12 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 13 Wątki producent konsument Produkcja Producent Bufor Konsumpcja 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 Zadanie: zaimplementować model producenta i konsumenta z buforem wieloelementowym. 14 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 15 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(). Zobacz: Suspend2.java 16 Pięciu filozofów F1 F2 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... Stół F4 F3 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. 17