Wątki to kolejne bardzo potężne narzędzie jezyka programowania, które równie łatwo może stać się ułatwieniem jak i przeszkodą. Na szczęście Java upraszcza nam pracę z wątkami i pozwala uniknąć wielu problemów.
Programowanie wielowątkowe pozwala nam na realizację kilku rzeczy w jednym czasie. Tak naprawdę już teraz z niego korzystałaś, choć nieświadomie — serwer aplikacji (jak np. Tomcat) korzysta z wątków, aby umożliwić wielu klientom korzystanie z Twojej aplikacji jednocześnie. W tej lekcji nauczysz się sama wykorzystywać wątki do zadań, które wymagają więcej czasu — np. wysłanie maila. Użytkownik strony zamiast czekać, aż system wyśle maila i dopiero wtedy zwróci odpowiedź, dostaje odpowiedź od razu, a mail jest wysyłany w tle. Dotyczy to nie tylko wysyłki maili, ale wszystkich operacji, które wymagają czasu — generowania plików PDF, renderowania grafiki (np. diagramów), wykonywania licznych zmian na bazie danych, szyfrowania itp. Zastosowania tego podejścia są naprawdę niezliczone.
Lekcja
Wstęp
Na początku wyjaśnimy sobie kilka pojęć i czym w rzeczywistości są wątki. Ale wszystko po kolei.
Wątki
Z wątkami mamy do czynienia właściwie każdego dnia — systemy, które nie korzystają z wątków są spotykane właściwie tylko przy kontroli systemów produkcyjnych, gdzie czas i kolejność działania są krytyczne, oraz w prostych układach elektronicznych, gdzie układy są zbyt proste do obsługi wątków.
Wątki to sposób procesora na robienie wielu rzeczy rzeczy jednocześnie. W danym momencie czasu procesor może wykonać tylko tyle instrukcji, ile ma rdzeni (w uproszczeniu, w rzeczywistości jest to bardziej skomplikowane, ale takie uproszczenie pozwoli nam łatwiej wytłumaczyć). To by oznaczało, że jednocześnie mógłbyś mieć uruchomione tylko tyle programów ile masz rdzeni procesora — przeważnie dwa lub cztery we współczesnych komputerach. Aby obejść to ograniczenie, procesor pracuje na wątkach — jeden program to jeden (lub więcej) wątków. Wątek zawiera informacje o tym, jaki program jest uruchomiony, co akurat się dzieje oraz jaka część pamięci RAM do niego należy. Procesor przełącza się pomiędzy wątkami wiele razy w ciągu każdej sekundy, przez co użytkownik komputera ma wrażenie, że wiele programów działa jednocześnie. W rzeczywistości większość z nich jest ‘zamrożonych’ i czeka na swoją kolejkę, aby wznowić działanie.
Wielowątkowość
Wielowątkowość to nic innego jak zdolność systemu do wykorzystania (lub obsługi, w zależności od kontekstu) wielu wątków. O aplikacji mówimy, że jest wielowątkowa, jeśli korzysta z wątków, aby lepiej realizować swoje zadania (np. realizuje czasochłonne zadania ‘w tle’, nie opóźniając interakcji z użytkownikiem). Wielowątkowość wiąże się też z problemami — główne z nich to synchronizacja wątków (nie mamy gwarancji, który wątek dostanie więcej ‘czasu’ od procesora) oraz dostęp do zasobów.
Synchronizacja
Problem synchronizacji możemy sobie wyobrazić np w przypadku algorytmu, który dodaje do jakiejś liczby (wspólnej dla wielu wątków) jedynkę, tylko jeśli jest ona zerem. Dwa wątki, które w tym samym czasie (tzn jeden po drugim, ale zanim wykona się kolejna instrukcja) wykonają warunek uznają, że jest on spełniony, po czym oba wątki zwiększą liczbę o jeden. Taka sytuacja nazywa się sytuacją wyścigu i w uproszczeniu chodzi o to, że pomiędzy sprawdzeniem warunków, a wykonaniem czynności, warunki mogą się zmienić.
Java zapewnia wiele mechanizmów, które pozwalają tej sytuacji uniknąć, jednak nie będziemy ich omawiać w tej lekcji. Rozwiązywanie tego typu problemów powinno się odbywać na etapie projektowania i wymaga dużej wiedzy i doświadczenia, aby zrobić to prawidłowo. Póki co skupimy się na podstawach użycia wątków, bez bardziej zaawansowanych zagadnień :)
Uruchamianie i tworzenie wątków w Javie
Java oferuje trzy sposoby na uruchamianie wątków, z których będzie nas interesował tylko ten ostatni:
- dziedziczenie po klasie Thread — to najgorszy ze sposobów, samodzielnie musimy zarządzać wątkiem i nie mamy prawie żadnych dodatkowych narzędzi; korzystanie z tego sposobu może także negatywnie wpłynąć na działanie aplikacji webowej, narażamy się też na problem związany z nieprawidłowym zakończeniem pracy wątku i niezwolnionymi zasobami
- implementacja interfejsu Runnable i użycie klasy Thread do jego uruchomienia — to trochę lepsza metoda, ale zasadniczo cierpi na takie same problemy jak punkt pierwszy; z drugiej strony dość łatwo możemy ją przerobić na sposób trzeci
- implementacja interfejsu Runnable i użycie implementacji ExecutorService — to najlepsza metoda na uruchamianie własnych wątków — pozwala nam tworzyć pule wątków, delegować im zadania, tworzyć zadania cykliczne, kończyć pracę wątków w kontrolowany sposób i wiele więcej; tą metodą zajmiemy się w dzisiejszej lekcji
Interfejsy Runnable oraz ExecutorService
Te dwa interfejsy są kluczowe do korzystania z wątków w Javie. Omówimy tylko ich podstawowe zastosowanie, ale zachęcam do eksperymentów i testowania innych możliwości.
Na początku interfejs Runnable, który deklaruje jedną metodę — run, która nic nie zwraca i nic nie przyjmuje. To centralny punkt naszego wątku — wątek uruchomi tą metodę i będzie działał, dopóki nie skończy się ona wykonywać. Fakt, że nic nie zwraca i nie przyjmuje jest celowy — jeśli chodzi o przyjmowanie danych, można do tego wykorzystać konstruktor lub np. Springa (tak, możemy zadeklarować beana za pomocą @Component, który implementuje również Runnable i używać w nim np. @Autowired). Jeśli chcemy więc zaimplementować prosty wątek, który np. wyśle maile z bazy danych czekające na wysłanie, będzie on wyglądał mniej więcej tak:
@Component
public class EmailSenderRunnable implements Runnable {
@Autowired
public EmailDao emailDao;
public void run() {
List<Email> emailsToSend = emailDao.fineByStatus(EmailStatus.UNSENT);
for (Email email : emailsToSend) {
//do the actual sending, connect to mail server, handle exceptions etc.
}
}
}
ExecutorService to z kolei interfejs, którego nie musimy implementować samodzielnie, środowisko dostarcza nam gotowe implementacje. Dzięki temu możemy w prosty sposó utworzyć wątek lub pulę wątków, która będzie działała wg naszych oczekiwań. Najważniejsze metody, które udostępnia ten interfejs, to:
- submit(Runnable task) — pozwala przesłać ‘zadanie’ (implementację interfejsu Runnable) do wykonania (uwaga: nie mamy gwarancji, czy zostanie ono od razu uruchomione! To zalezy od aktualnego statusu ExecutorService, kolejki zadań, dostępnych wątków etc)
- shutdown() — pozwala prawidłowo zakończyć pracę wątków, uprzednio kończąc wszystkie zadania i zwalniając wszystkie zasoby. Wywołanie tej metody jest wymagane przed zakończeniem działania aplikacji!
Zobaczmy przykład, jak stworzyć serwis, który korzysta z ExecutorService, który w prawidłowy sposób kończy pracę. Wykorzystamy tutaj interfejs Lifecycle - dzięki niemu, Spring powiadomi naszą klasę o wyłączaniu aplikacji, będziemy więc mogli odpowiednio zamknąć ExecutorService.
Użyliśmy też adnotacji @PostConstruct — wprawdzie mogliśmy to samo zrobić w metodzie start() z interfejsu Lifecycle, ale chciałem przy okazji pokazać, do czego można użyć tej adnotacji. Metoda (publiczna, bez argumentów), którą oznaczymy tą adnotacją, zostanie wywołana w momencie, w którym wszystkie beany Springa zostaną już utworzone i podpięte (tzn. wszystkie pola z adnotacją @Autowired powinny mieć już właściwe obiekty), czyli realnie w momencie, w którym nasza aplikacja jest w pełni uruchomiona.
@Service
public class EmailService implements Lifecycle {
private ScheduledExecutorService threadPool = Executors.newScheduledThreadPool(1);
@Autowired
EmailSenderRunnable emailSenderRunnable;
@PostConstruct //ta adnotacja powoduje, że metoda wykona się po uruchomieniu aplikacji
public void setup() {
threadPool.scheduleAtFixedRate(emailSenderRunnable, 60, 60, TimeUnit.SECONDS)
}
public void sendMessage(Email message) {
//dodaj wiadomość do kolejki wiadomości do wysłania
}
// inne metody mail service
public boolean isRunning() {
return threadPool.isTerminated();
}
public void start() {
//nic nie rób
}
public void stop() {
threadPool.shutdown();
while (isRunning()) {
try {
Thread.sleep(300);
} catch (InterruptedException e) {
//ignoruj wyjątek
}
}
}
}
Tworzenie nowych ExecutorService
W powyższym przykładzie utworzyliśmy pulę wątków za pomocą metody klasy Executors. Przyjrzyjmy się jeszcze jakie inne opcje tworzenia puli mamy dostępne i do czego możemy ich używać:
- newScheduledThreadPool(int corePoolSize) — tworzy pulę wątków ( o rozmiarze, który przekazujemy jako parametr), która pozwala nam wykonywać zadania np. cyklicznie (zobacz deklarację interfejsu ScheduledExecutingService)
- newFixedThreadPool(int nThreads) — tworzy pulę o określonej ilości wątkó, które są cały czas aktywne (aż do zamknięcia Executora)
- newCachedThreadPool() — tworzy pulę, w której wątki są tworzone tylko, jeśli są potrzebne i zamykane po upływie określonego czasu bezczynności, polecam do wykonywania nieczęstych zadań (oszczędza zasoby)
- newSingleThreadExecutor() — pojedynczy wątek do naprostszych zastosowań
Często pojedynczy wątek to wszystko, czego potrzeba. W zależności od konkretnego przypadku, różne pule mogą się sprawdzać w różnych sytuacjach.
Podsumowanie
W tej lekcji dowiedziałaś się, czym są wątki w Javie, do czego można je wykorzystywać i jak używać ich prawidłowo. Nie było akademickiego (bezsensownego) przykładu ze ślimakami (brr, osoby które miały nieprzyjemnosć uczęszczać na zajęcia z programowania na uczelni na pewno wiedzą, o czym mówię) za to trochę praktyki. Pamiętaj tylko, że więcej nie zawsze znaczy lepiej — zbyt duża ilość wątków może ograniczyć wydajność aplikacji zamiast ją poprawić.
Jeśli uważasz powyższą lekcję za przydatną, mamy małą prośbę: polub nasz fanpage. Dzięki temu będziesz zawsze na bieżąco z nowymi treściami na blogu ( i oczywiście, z nowymi częściami kursu Javy). Dzięki!