Wątki w Javie
Gdy mówimy, że dwa zadania działają współbieżnie, oznacza to, że oba czynią postępy prawie jednocześnie. Do początku XXI wieku zdecydowana większość komputerów miała tylko jeden procesor. Systemy operacyjne takich komputerów wykonywały zadania współbieżnie, szybko przełączając się między nimi, więc wszystkie zadania postępowały naprzód małymi kroczkami. Często komputer osobisty w tym samym czasie kompilował program, wysyłał plik do drukarki, pobierał wiadomości z sieci itp. Java od samego początku obsługuje współbieżność.
Gdy mówimy, że dwa zadania działają równolegle, oznacza to, że oba działają symultanicznie. Można powiedzieć, że zrównoleglenie to podzbiór współbieżności. Ludzki organizm wiele procesów wykonuje równolegle: oddychanie, krążenie krwi, trawienie, myślenie i chodzenie to przykłady działań, które mogą zachodzić w tym samym czasie. Dotyczy to również zmysłów: możemy jednocześnie
widzieć, słyszeć, dotykać, czuć i smakować. Uważa się, że jest to możliwe, bo ludzki mózg składa się miliardów „procesorów”. Dzisiejsze komputery wielordzeniowe mają wiele procesorów, które mogą wykonywać zadania równolegle.
Współbieżność w Javie
Java udostępnia współbieżność poprzez język i API. Programy Javy mogą mieć wiele wątków wykonania, każdy z własnym stosem wywołań metod i licznikiem programu, co pozwala działać współbieżnie do innych wątków, a jednocześnie współdzielić pamięć i uchwyty do plików. To tak zwana wielowątkowość.
Wskazówka poprawiająca wydajność
Problem aplikacji jednowątkowych związany ze słabym czasem reakcji polega na tym, że długie działanie musi się zakończyć, zanim będzie się mogło zacząć następne. W aplikacji wielowątkowej wątki są rozdzielane na wiele rdzeni (jeśli są dostępne), więc wiele zadań może się wykonywać równolegle, co poprawia ogólną wydajność aplikacji. Wielowątkowość może również poprawić wydajność w systemie jednoprocesorowym — gdy wątek nie może pójść dalej ze swoimi działaniami (bo na przykład czeka
na wynik operacji wejścia – wyjścia), oddaje czas procesora innemu wątkowi.
Zastosowania programowania współbieżnego
Omówimy wiele zastosowań programowania współbieżnego. Gdy przykładowo strumieniujemy audio i wideo przez internet, użytkownicy nie chcą czekać, aż pobierze się cały materiał przed rozpoczęciem jego odtwarzania. Aby rozwiązać ten problem, stosuje się wiele wątków — jeden z nich pobiera wideo lub audio (w dalszej części rozdziału nazwiemy go producentem), a drugi ten materiał odtwarza (w dalszej części rozdziału nazwiemy go konsumentem). Oba działania są realizowane współbieżnie. Aby uniknąć przerywanego, skokowego odtwarzania, wątki są synchronizowane (ich akcje są koordynowane), dzięki czemu
wątek odtwarzania nie zaczyna odtwarzania, dopóki nie otrzyma od wątku pobierania wystarczająco dużo danych, by mógł być cały czas zajęty. Wątki producenta i konsumenta współdzielą pamięć — pokażemy, jak koordynować wątki, aby zapewnić im poprawne działanie. Maszyna wirtualna Javy (JVM) tworzy wątki do uruchamiania programów, a także wątki wewnętrzne między innymi
do odśmiecania pamięci.
Programowanie współbieżne jest trudne
Tworzenie programów wielowątkowych nie jest łatwe. Choć człowiek może wykonywać wiele zadań współbieżnie, bardzo często ma problemy z równoległym myśleniem o kilku sprawach. Aby zrozumieć, dlaczego tworzenie programów wielowątkowych może być trudne, wykonaj następujący eksperyment: otwórz trzy książki na pierwszej stronie i staraj się czytać je wszystkie w sposób
współbieżny. Najpierw przeczytaj kilka słów jednej książki, potem kilka słów drugiej, potem kilka słów trzeciej, potem wróć do pierwszej itd. Po tym eksperymencie przekonasz się, jak wielu wyzwaniom wielowątkowości — przełączanie się między książkami, odczytanie kilku słów, zapamiętanie miejsca zakończenia, przejście do następnej książki, przybliżenie jej itp. — trzeba sprostać poza zadaniem głównym, czyli przyswojeniem sobie treści książek!
Wykorzystanie istniejących klas i API współbieżności, gdy tylko to możliwe
Tworzenie aplikacji współbieżnych jest trudne i podatne na błędy. Jeśli musisz w programie użyć synchronizacji, korzystaj z następujących wskazówek:
1. Zdecydowana większość programistów powinna używać istniejących klas kolekcji i interfejsów z API współbieżności, bo zarządzają one synchronizacją za programistę — przykładem jest klasa ArrayBlockingQueue (implementacja interfejsu BlockingQueue). Dwie inne najczęściej używane klasy API dotyczące współbieżności to LinkedBlockingQueue i ConcurrentHashMap. Klasy te są tworzone przez ekspertów, dobrze przemyślane i przetestowane, aby działać sprawnie i unikać większości pułapek i błędów.
2. Zaawansowani programiści, którzy chcą mieć kontrolę nad procesem synchronizacji, mogą korzystać ze słowa kluczowego synchronized oraz metod wait, notify i notifyAll klasy Object.
3. Tylko bardzo zaawansowani programiści powinni używać klas Lock i Conditions w a także klas takich jak LinkedTransferQueue (implementacja interfejsu TransferQueue), których podsumowanie znajdziesz na rysunku 23.22.
Być może zechcesz przeczytać opisy zaawansowanych funkcji wymienionych w punktach 2. i 3., choć nie będziesz z nich korzystać. Dodajemy odpowiednie objaśnienia, ponieważ:
- opisują one podstawy działania synchronizacji dostępu do współdzielonej pamięci;
- dobrze ilustrują złożoność tych niskopoziomowych funkcjonalności, co jeszcze mocniej podkreśla stwierdzenie: używają prostszych, wbudowanych API współbieżności, gdy tylko to możliwe.
Stany i cykl życia wątku
W każdym momencie wątek znajduje się w którymś z kilku stanów — możliwe stany ilustruje diagram UML z rysunku 23.1. Dodajemy ten opis, aby pokazać, co „kryje się pod maską” środowiska wielowątkowego. Java ukrywa większość szczegółów, aby programiści mogli w prostszy sposób tworzyć aplikacje wielowątkowe.
Stan nowy i stan działający
Nowy wątek zaczyna cykl życia od stanu nowy. Pozostaje w tym stanie do momentu rozpoczęcia wątku, który uruchamia stan działający. Wątek w stanie działający to wątek realizujący powierzone mu zadania.
Rysunek 23.1. Diagram stanu UML dla cyklu życia wątku
Stan oczekujący
Czasem działający wątek przechodzi do stanu oczekujący, w którym czeka, aż inny wątek wykona zadanie. Wątek oczekujący przechodzi z powrotem do stanu działający tylko w sytuacji, gdy inny wątek powiadomi go o możliwości kontynuacji.
Stan oczekujący czasowo
Wątek działający może wejść w stan oczekujący czasowo, w którym będzie się znajdował przez wskazany okres czasu. Przechodzi on do stanu działający, gdy upłynie wskazany czas lub gdy zajdzie zdarzenie, na które czeka. Wątki oczekujące i oczekujące czasowo nie mogą używać procesora, nawet jeśli jego czas jest dostępny. Wątek wchodzi w stan oczekujący czasowo, jeśli wskazany został
opcjonalny czas oczekiwania na wykonanie zadania przez inny wątek. W takiej sytuacji przejście do stanu działający odbędzie się w jednym z dwóch przypadków (tym, który nastąpi jako pierwszy): gdy wątek otrzyma informację od innego wątku, że może kontynuować, albo gdy upłynie wyznaczony czas. Innym sposobem, by spowodować wejście działającego wątku w stan oczekujący czasowo, jest jego uśpienie — uśpiony wątek pozostaje w stanie oczekujący czasowo przez wskazany okres czasu (tzw. czas uśpienia), a potem wraca do stanu działający. Wątki usypia się, jeśli nie mają chwilowo nic do roboty. Przykładowo edytor tekstu może mieć wątek, który okresowo zapisuje na dysku twardym kopię wykonywanych prac. Gdyby taki wątek nie był usypiany między poszczególnymi wywołaniami operacji zapisu, musiałby działać w pętli nieskończonej i sprawdzać, czy powinien zapisać dane na dysku. Taka pętla zajmowałaby zasoby procesora, choć nie wykonywałaby żadnej produktywnej pracy, a tym samym obniżałaby wydajność systemu. W takiej sytuacji znacznie lepszym rozwiązaniem jest uśpienie wątku na wskazany czas (odpowiadający odstępowi czasu, w jakim wykonywane są kolejne zapisy), czyli jego wejście w stan oczekujący czasowo. Po upływie wskazanego czasu wątek wróci do stanu działający, dokona zapisu i ponownie wejdzie w stan oczekujący czasowo.
Stan zablokowany
Działający wątek przechodzi do stanu zablokowany, gdy próbuje wykonać zadanie, które nie może zostać ukończone od razu, i musi na nie tymczasowo zaczekać. Na przykład gdy wątek wyśle żądanie wejścia – wyjścia, system operacyjny blokuje wątek do momentu zakończenia takiego żądania — gdy tylko się zakończy, wątek przechodzi ze stanu zablokowany do stanu działający. Wątek w stanie zablokowany nie korzysta z procesora, nawet jeśli jest dostępny.
Stan zakończony
Przejście działającego wątku do stanu zakończony (nazywanego również stanem martwym) dotyczy sytuacji, gdy udało się wykonać wszystkie zadania lub doszło do zakończenia działania z innych powodów (np. wystąpienia błędu). Na diagramie UML z rysunku 23.1 po stanie zakończony pojawia się informacja o stanie końcowym.
Stan działający z punktu widzenia systemu operacyjnego
Na poziomie systemu operacyjnego stan działający w Javie najczęściej dotyczy dwóch osobnych stanów (rysunek 23.2). System operacyjny ukrywa te stany przed JVM, który widzi cały czas stan działający. Gdy wątek po raz pierwszy przechodzi do stanu działający ze stanu nowy, znajduje się w stanie gotowy. Wątek gotowy przechodzi w stan wykonywany (czyli zaczyna być realizowany), kiedy system operacyjny przypisze go do procesora — to tak zwane rozdysponowanie wątku. W większości systemów operacyjnych każdy wątek otrzymuje pewien niewielki fragment czasu procesora — nazywany kwantem lub wycinkiem czasu — w którym może realizować swoje zadania. To, jak duży jest ten wycinek, to często złożony temat poruszany na kursach z systemów operacyjnych. Gdy kwant czasu się skończy, wątek powraca do stanu gotowy, a system operacyjny przypisuje procesor innemu wątkowi. Przejście między stanami jest realizowane tylko i wyłącznie przez system operacyjny. JVM „nie widzi” tego przejścia — dla niego cały czas wątek znajduje się w stanie działający. Proces, którego system operacyjny używa do określania wątku do rozdzielenia, nosi nazwę harmonogramowania wątków. To, który wątek zostanie wybrany do wykonania, zależy często od jego priorytetu.
Rysunek 23.2. Widok stanu działający na poziomie systemu operacyjnego
Priorytety i harmonogramowanie wątków
Każdy wątek Javy ma priorytet pozwalający określić kolejność, w której będą wykonywane wątki. Każdy nowy wątek dziedziczy priorytet po wątku, który go utworzył. Nieformalnie wątki o wyższym priorytecie są ważniejsze dla programu niż wątki o niższym priorytecie. Niemniej priorytety wątków nie gwarantują kolejności, w której wątki będą realizowane.
Zaleca się, aby jawnie nie tworzyć i nie używać obiektów Thread w celu implementacji współbieżności, ale zdać się na interfejs Executor. Większość systemów operacyjnych wykorzystuje podział czasu, czyli daje wątkom o tym samym priorytecie równy czas procesora. Bez podziału czasu każdy wątek o tym samym priorytecie działałby aż do zakończenia (chyba że opuściłby stan działania i przeszedł do stanu oczekiwania lub oczekiwania czasowego), zanim następny wątek mógłby rozpocząć pracę. W podziale czasu, nawet jeśli wątek nie skończył się wykonywać, procesor jest zabierany wątkowi i przyznawany innemu wątkowi o tym samym priorytecie (jeżeli oczywiście istnieje). Mechanizm harmonogramowania wątków (nazywany też dyspozytorem lub planistą) systemu operacyjnego określa, który wątek będzie wykonywał się jako następny. W najprostszej implementacji harmonogramowania wątek o najwyższym priorytecie działa cały czas, a jeśli istnieje więcej wątków o najwyższym priorytecie, są one wykonywane przez taki sam kwant czasu na zasadzie karuzelowej. Proces ten powtarza się aż do zakończenia wszystkich wątków.
Obserwacja z poziomu inżynierii oprogramowania
Java dostarcza narzędzia wysokiego poziomu ukrywające większość tej złożoności i czyni programowanie wielowątkowe mniej podatnym na błędy. Priorytety wątków są używane w tle do interakcji z systemem operacyjnym, ale większość programistów stosujących model wielowątkowy Javy nie będzie się zajmowała zmianą priorytetów.
Wskazówka poprawiająca przenośność kodu
Sposób harmonogramowania wątków zależy od platformy — z tego powodu zachowanie programu wielowątkowego zależy od platformy, na której został uruchomiony.
Odsuwanie wykonania w nieskończoność i blokady wzajemne
Gdy wątek o wyższym priorytecie wejdzie w stan gotowości, system operacyjny najczęściej wywłaszcza działający wątek (to tak zwane harmonogramowanie z wywłaszczaniem). W zależności od systemu operacyjnego stały napływ wątków o wyższych priorytetach może doprowadzić do ciągłego odsuwania w czasie (teoretycznie w nieskończoność) wątków o niższym priorytecie. Takie odsuwanie wykonania w nieskończoność nazywa się dosyć obrazowo zagładzaniem. Systemy operacyjne często stosują technikę nazywaną starzeniem, aby zapobiec zagłodzeniu: im dłużej wątek czeka w stanie gotowości, tym bardziej system
operacyjny podnosi tymczasowo jego priorytet, aby co jakiś czas wykonać przynajmniej jego drobny fragment.
Innym problemem powiązanym często z odsuwaniem wykonania w nieskończoność jest blokada wzajemna. Sytuacja ta ma miejsce, gdy wątek oczekujący (nazwijmy go wątkiem 1) nie może działać, bo czeka (pośrednio lub bezpośrednio) na inny wątek (nazwijmy go wątkiem 2). Jednocześnie wątek 2 nie może działać, bo czeka (pośrednio lub bezpośrednio) na wątek 1. Dwa wątki czekają na siebie nawzajem, więc nie mogą przejść dalej i nigdy się nie zakończą.
