Java. Programowanie obiektowe

Marek Wierzbicki

Rozdział 6. Programowanie wielowątkowe

W początkowej fazie tworzenia różnych języków programowania naturalne było liniowe i jednowątkowe podejście do rozwiązywania problemów stawianych przed generowanym programem. Wiadomo było, że kiedy program oblicza jakieś wartości, nie robi nic innego. W miarę pojawiania się systemów operacyjnych umożliwiających pracę równoległą i wielodostępną przestało to być takie oczywiste. W czasie, gdy jeden program coś liczył, inny mógł się komunikować z użytkownikiem, pobierać dane z dysku, drukować wyniki czy nawet obliczać inne wartości. Skoro dwa różne programy mogły pracować w tym samym czasie, naturalnym wydało się pytanie, czy jedna aplikacja może wykonywać równolegle dwie różne operacje. Próby takie podejmowano już w językach strukturalnych (dobrym przykładem jest MODULA), ale zaawansowana wielowątkowość mogła się pojawić dopiero w językach zorientowanych obiektowo. Należy jednak pamiętać, że pierwsze udane próby obliczeń równoległych prowadzono jeszcze przed pojawieniem się komputerów (z użyciem mechanicznych maszyn liczących), w czasie prac nad konstrukcją bomby atomowej, którym przewodził Richard Feynman.

Aby przybliżyć Ci znaczenie i sposób działania programu wielowątkowego, posłużę się opisanym dalej przykładem. Wyobraźmy sobie, że tworzymy aplet mający na celu zdalne sprawdzanie posiadanych wiadomości (na przykład egzamin przez internet). Aplet ten będzie wybierał w sposób losowy pytania z bazy danych (znajdującej się na serwerze), prezentował je osobie zdającej, odbierał od niej odpowiedź i zapisywał wynik na serwerze. Z niektórymi pytaniami może być związana konieczność przesłania rysunku (w jedną bądź obie strony). Aplet stosujący klasyczne jednowątkowe podejście działałby w pętli opisanej symbolicznie w sposób pokazany na listingu 6.1.

Listing 6.1. Algorytm egzaminu w wersji jednowątkowej

pobierz pytanie z serwera
wyświetl pytanie
czekaj na odpowiedź i odbierz ją od zdającego
zapisz odpowiedź na serwerze
powróć na początek pętli lub zakończ pracę

Osoba zdająca egzamin w każdym cyklu (przy każdym pytaniu) byłaby zmuszona dwukrotnie oczekiwać na przesyłanie danych (raz z serwera i raz do niego). W przypadku telefonicznego połączenia z internetem oczekiwanie takie mogłoby być bardzo denerwujące. Zastosowanie podejścia wielowątkowego może znacząco przyspieszyć techniczną stronę przebiegu tego egzaminu. Jeśli założymy, że w czasie, kiedy osoba zdająca zastanawia się bądź wpisuje odpowiedź (co w znikomy sposób obciąża komputer), możemy przesyłać dane w obu kierunkach (znowu największym ograniczeniem jest tu przepustowość sieci, a nie obciążenie komputera), proces ten będzie wyglądał tak, jak to proponuję na listingu 6.2.

Listing 6.2. Algorytm egzaminu w wersji wielowątkowej

pobierz pierwsze pytanie z serwera
wykonuj równolegle operacje:jeśli koniec pracy, zapisz ostatnią odpowiedź

W przypadku bardzo oczywistych odpowiedzi, które można szybko wprowadzić do komputera, może się zdarzyć, że osoba odpowiadająca będzie zmuszona oczekiwać na kolejne pytanie. Jednak jeśli pytanie będzie właściwie sformułowane (na przykład w taki sposób, aby odpowiedź musiała być podana pełnym zdaniem), można się spodziewać, że kiedy zdający zastanawia się nad odpowiedzią i wpisuje jej treść, komputer zdąży zapisać na serwerze poprzednią i pobrać następne pytanie. Będzie to możliwe wyłącznie w przypadku podziału programu na równoległe wątki, które mogą się wykonywać w tym samym czasie. Oczywiście należy przewidzieć właściwą synchronizację tych wątków, to znaczy zarówno to, że odczyt pytania skończył się wcześniej niż wpisywanie odpowiedzi, jak i odwrotną sytuację. Takie i inne szczegóły implementacyjne zostaną omówione w tym rozdziale.

6.1. Techniczna strona wielowątkowości

Zanim wprowadzę Cię w kwestie programowania współbieżnego i wielowątkowego w Javie, chciałbym zwrócić uwagę na podobieństwa i różnice między wielowątkowością i wielozadaniowością oraz sposobem realizacji tych zadań. Większość współczesnych systemów operacyjnych pretenduje do miana wielozadaniowych. Oznacza to, że są one w stanie podzielić czas procesora oraz zasoby komputera pomiędzy dwa programy lub więcej. Dzieje się tak, ponieważ w przeważającej liczbie przypadków różne programy korzystają z różnych zasobów komputera. Jeśli uruchamiamy jednocześnie skomplikowany program obliczeniowy i edytor tekstu, to w "przerwach" pomiędzy naciskaniem kolejnych klawiszy (które z punktu widzenia systemu operacyjnego wypełniają niemal cały czas pracy edytora) czas procesora może być przeznaczony na obliczenia, a nie na bezczynne czekanie. Jeżeli pracujemy w systemie operacyjnym działającym w trybie wywłaszczania (a tak działają niemal wszystkie współczesne systemy), to to on odpowiada za przełączanie między równolegle działającymi programami. Jeśli komputer wyposażony jest w jeden procesor (tak jak większość domowych komputerów), wtedy czas tego procesora dzielony jest między różne programy sekwencyjnie. Po kolei procesor wykonuje instrukcje należące do różnych programów. Przełączenie między programami polega na dokładnym zapamiętaniu stanu pracy procesora tak, aby możliwy był powrót do niego w chwili, gdy system obdzieli czasem wszystkie programy i powróci do ponownej obsługi pierwszego. Trochę lepiej wygląda sytuacja w komputerach wieloprocesorowych. Program uruchomiony w takim środowisku może być z góry przeznaczony do wykonania na jakimś konkretnym procesorze, co zapewni, że nie będzie się musiał dzielić tym czasem z inną aplikacją. Techniczna strona realizacji tej kwestii zależy od systemu operacyjnego. Główną różnicą między wielozadaniowością a wielowątkowością jest sposób współistnienia różnych procesów. Programy w środowisku wielozadaniowym uruchamiane są jako zupełnie oddzielne procesy w odrębnej przestrzeni adresowej. Jakkolwiek mogą się ze sobą porozumieć, wymaga to skorzystania z pewnych charakterystycznych narzędzi systemu operacyjnego. Inaczej jest z programami wielowątkowymi. Często są one umieszczone w tej samej przestrzeni pamięci, mogą się swobodnie wymieniać informacjami za pomocą mechanizmów dostępnych w samym języku (bez odwoływania się do systemu). Często mogą też korzystać ze wspólnych zmiennych bądź reagować na zdarzenia zachodzące w innym wątku tego samego programu. Podobieństwem jest to, że to system operacyjny rozdziela czas pomiędzy wątki, a jeśli istnieje w nim więcej niż jeden procesor, to dokonuje on podziału pomiędzy nie. Nowością w stosunku do uruchomienia różnych programów w tym samym czasie jest możliwość synchronizacji, czyli wymuszenia dokończenia pracy jednego z procesów, jeśli drugi pospieszył się zbyt mocno.

W przypadku programów uruchamianych współbieżnie za przełączanie między nimi odpowiada system operacyjny. W przypadku programów napisanych w Javie za wielowątkowość odpowiada ich system uruchomieniowy, czyli JVM. Jeśli więc napotyka instrukcje charakterystyczne dla kodu wielowątkowego, wykonuje na przemian fragmenty kodu poszczególnych wątków. Jeśli komputer ma więcej niż jeden procesor, a JVM potrafi sobie z tym poradzić, różne fragmenty wielowątkowego programu w Javie są przetwarzane przez różne wątki JVM, co umożliwia fizyczne podzielenie tych wątków na różne procesory. Ta operacja należy już do systemu operacyjnego i nie jest w żaden sposób widoczna dla programisty Javy. Warto jednak pamiętać, że nawet w przypadku istnienia wielu procesorów przełączanie procesów czy wątków zajmuje systemowi na tyle dużo czasu, że wykorzystanie tego triku w celu przyspieszenia pracy programu, który zużywa większość czasu procesora (na przykład wykonuje dużo obliczeń), najczęściej bywa bezcelowe.

Oczywiście programista nie musi wiedzieć, jak technicznie wykonywany jest program napisany w Javie i czy JVM potrafi podzielić wątki tak, aby obsłużyły je różne procesory. Dzięki ustalonemu standardowi pracy ten sam aplet załadowany do różnych przeglądarek, w różnych systemach operacyjnych powinien działać (z punktu widzenia jego odbiorcy) w jednakowy sposób. Jeśli w czasie pisania aplikacji zachowane zostaną standardy tworzenia programów wielowątkowych w Javie (opisane w tym rozdziale), nie powinno być problemów z tą kwestią. Jedyną różnicą może być szybkość zależna od możliwości technicznych komputera (zegar i liczba procesorów). Warto pamiętać, że głównym powodem podziału programu na wątki jest zwiększenie jego funkcjonalności i ewentualne wykorzystanie pozornych przestojów. Może to subiektywnie zwiększyć szybkość pracy, jednak wyniknie to wyłącznie z lepszej jej "organizacji", a nie z tego, że obliczenia bądź transmisja danych są wykonywane szybciej.

Założenia przyjęte przy tworzeniu Javy bazowały na tym, że zarządzanie wątkami przez JVM będzie przenoszone na system operacyjny. Tak więc samo środowisko uruchomieniowe programów napisanych w Javie oraz wszystkie standardowe biblioteki tego języka były tworzone z przeznaczeniem do środowisk wielozadaniowych. Dzięki temu możliwe stało się stworzenie systemu w pełni sterowanego zdarzeniami. Jeśli uwzględnimy istnienie wątków, programowanie sterowane zdarzeniami może odbywać się w sposób asynchroniczny. Oznacza to na przykład, że program poprawnie reaguje na naciśnięcie kolejnego klawisza klawiatury nawet wtedy, gdy obsługa poprzednio naciśniętego klawisza jeszcze się nie zakończyła. Umożliwia to stworzenie funkcjonalności niedostępnej bez tej cechy. Nie trzeba dodawać, że podobnie jak programowanie sterowane zdarzeniami, programowanie wielowątkowe (co pokażę dalej) najbardziej naturalnie realizuje się w językach obiektowych.

...