Teoretyczny wstęp do programowania wielowątkowego. W kolejnych, bardziej praktycznych, częściach będę starał się posługiwać jak największą liczbą praktycznych przykładów.

User space vs Kernel space

Kupujesz system operacyjny. Np.: Windows. Jest to program komputerowy, który składa się z zainstalowanych programów i programu, który umożliwia tym programom działanie. Mam na myśli komunikację z hardware’em Twojego komputera. Nie widać go gołym okiem, nie ma kolorków. Tym ważnym programem, który zarządza między innymi pamięcią i urządzeniami I/O jest kernel. Działa on w przestrzeni zwaną kernel space. Nie mają do niej bezpośredniego dostępu programy działające w user space. User space jest bowiem obszarem pamięci, który jest zarezerwowany dla programów, które uruchamiasz na swoim komputerze

Uwaga: deskryptor procesu (PCB) zawierający istotne dane procesu znajduje się w kernel space, natomiast funkcje programu, jego dane znajdują się w user space. User space może skomunikować się ze strukturą procesu wyłącznie poprzez system calls. Wspomniane powyżej funkcje oraz dane są wspólne dla wątków, działających w ramach procesu. O tym niedługo.

Nie chcielibyśmy przecież, aby jakiś tam program (nasz czy cudzy) mieszał w super ważnej przestrzeni pamięci i nie daj Boże coś popsuł (np.: wywołał instrukcję wyłączenia komputera). User space może jednak się komunikować (np.: w celu odczytania pliku) z kernel space poprzez tzw. system calls. Zatem jeśli Twój program chce wykonać jakąś operację (np.: wspomniana operacja odczytania jakiegoś pliku) to wówczas kernel przyjmuje takie żądanie (system call), sprawdza czy użytkownik ma uprawnienia do takiej operacji plus pewnie masa innych rzeczy, następnie wykonuje (bądź nie) taki task i zwraca odpowiedź.

Specyfikacje obsługi wątków

Jak zajrzysz do klasy Thread to zobaczysz, że jest tam natywna (słówko kluczowe native) metoda start0. Natywna metoda napisana jest np.: w C++. W zależności od implementacji JVMa obsługa wątków będzie wykonana według implementacji jednej z dostępnych specyfikacji (np.: POSIX lub Win32). Np.: Javowa, natywna metoda Thread.currentThread() w zależności czy JVM implementuje POSIX czy Win32 wykona kolejno funkcję pthread_self() lub GetCurrentThread().

Pitu pitu. Kogo to interesi. Jakby co to w poście przytaczam cytaty z książek, gdzie powyższe aspekty i znacznie więcej jest dokładnie opisane.

Do rzeczy.

Sekwencyjne wykonanie procesu

W zasadzie każdy mój poranek wygląda tak samo. Budzę się, ścielę łóżko i zaczyna się: wbijamy do łazienki i ubieram córkę. Przemywam najpierw rączki, buzię, dopiero wtedy ubieram, czeszę. I jest. Potem to samo z synem. Moja żona powiedziałaby, że nadużyciem jest to, że napisałem to wszystko w pierwszej osobie liczby pojedynczej. Tyle że jej tu nie ma i nie ma szansy tego przeczytać. Wykonuję te czynności jedna po drugiej, zachowując kolejność. Wykonuję je zatem sekwencyjnie. Nikt mi nie pomaga. Nie każdy bohater nosi pelerynę.

Procesy i wątki w skrócie

Wszystkie powyżej wymienione czynności tworzą pewien proces. Proces ogarnięcia poranka. Czynności są wykonywane jedna po drugiej, sekwencyjnie. Skrócony kod takiego procesu GoodMorning.exe mógłby wyglądać tak:

main(BabyNumber babyNo) {
   washBaby(babyNo);
   dressBaby(babyNo);
   brushHair(babyNo);
}

Mój program wykonujący sekwencyjnie czynności mógłbym wywołać z linii poleceń pisząc:

GoodMorning.exe 1

Gdy klikamy dwukrotnie na pliku .exe to nasz program zostaje załadowany do pamięci. Taki załadowany do pamięci i działający program nazywa się, w technicznej terminologii, procesem. Każdy proces ma dostęp jedynie do swojego obszaru pamięci, tzn., nie zajrzy i nie zmieni danych w pamięci drugiego procesu. Powyżej uruchomiliśmy proces dla dziecka o numerze 1, jakkolwiek to bezdusznie brzmi. Gdybyśmy chcieli uruchomić teraz realizację poranka dla dziecka nr 2, to musielibyśmy uruchomić drugi proces, pisząc:

GoodMorning.exe 2

Jeśli miałbym 10 dzieci, to byłoby to 10 oddzielnych procesów, które zajmują ileś tam niewspółdzielonej pamięci. Mielibyśmy zatem multitasking ponieważ nasz system operacyjny potrafił uruchomić i wykonać jednocześnie więcej niż jeden proces.

Mała uwaga: większe aplikacje, mogą być stworzone z kilku procesów, które się ze sobą komunikują

Wolałbym, aby był jeden proces, a mimo to żeby cel równoczesnego ogarnięcia dwójki dzieci był osiągnięty. Posłużą nam do tego wątki, które koncepcyjnie są powiązane z procesami. Powstają w ramach procesu i korzystają z zarezerwowanej przez niego pamięci. Multithreading jest właśnie techniką, która pozwala uruchomionemu programowi wykonywać kilka rzeczy w tym samym czasie. Wątki są zwane lekkimi procesami i współdzielą zasoby procesu. Każdy wątek może wykonać te same funkcje i korzystać z tych samych danych. Jeśli jeden wątek pisze do pliku, a drugi zacznie go w tym czasie odczytywać, no to cóż. Może się to skończyć różnie. O tym też będzie. Według książki Operating System Concepts autorstwa Abraham Silberschatz, w przypadku Solarisa koszt stworzenia procesu jest 30 razy większy niż w przypadku stworzenia wątku. Przełączanie między procesami natomiast jest 5 razy wolniejsze niż przełączenie między wątkami.

W naszym przykładzie z życia wziętym mamy do czynienia z pojedynczym procesem oraz jednym wątkiem, który musi sekwencyjnie wykonać te wszystkie czynności. Ja jestem tym jedynym wątkiem, który steruje wykonaniem całego procesu, ponieważ to ja myję, ubieram i czeszę dziecko.

Współbieżność (Concurrency)

1 procesor

“Concurrency means that two or more threads (or traditional processes) can be in the middle of executing code at the same time”Multithreaded Programming with Java Technology, Bil Lewis

Jako samiec alfa każę mojej żonie wziąć się do roboty. Koniec perfum i leżaków rozpustnico. Od dzisiaj ty ogarniasz syna, a ja córkę. Mamy jednak tylko jedną łazienkę.

Jest nas dwoje. Dwa wątki. Możemy współdziałać. I tu pojawia się pojęcie współbieżności. Myję córkę, w tym czasie żona czeka aż oddam jej miejsce przy umywalce, by mogła zacząć myć syna. Zaczynam następnie ubierać córkę, więc żona wreszcie zaczyna myć syna. Na koniec czeka aż skończę czesać córkę, aby móc uczesać synka. Całkiem fajnie, idzie to teraz wszystko szybciej, ale jednak współdzielimy zasoby takie jak umywalka i szczotka. Jedno z nas znajduje się nieraz przez chwilę w stanie bezczynności. Nie działamy równolegle w najczystszej postaci, tzn., nie działamy dokładnie w tym samym czasie. Działamy {quasi,pseudo}równolegle.

To tak jakbyśmy mieli jeden procesor. Uruchamiamy na nim jeden proces, który ma dwa wątki. Tylko jeden wątek w danej chwili może być na chodzie. Jeśli ten wątek zatrzyma się na chwilę, bo będzie zajęty na przykład oczekiwaniem na odpowiedź z dysku twardego, to wówczas drugi wątek wskoczy na jakiś czas na jego miejsce i będzie mógł korzystać z procesora i to przełączanie między wątkami będzie tak trwało w koło Macieju aż do osiągnięcia celu. Te odstępy czasowe mogą być bardzo małe i dlatego tworzy to wrażenie działania równoległego, w tym samym czasie.

> 1 procesorów

“Parallelism means that two or more threads actually run at the same time on different CPUs. On a multiprocessor machine, many different threads can run in parallel. They are, of course, also running concurrently.”Multithreaded Programming with Java Technology, Bil Lewis

Oprzytomniałem i odkrywczo zauważam, że mamy dwie łazienki. Teraz pójdzie to znacznie łatwiej. Dodatkowo kupiłem drugą szczotkę do włosów. Poranki wyglądają inaczej. W jednej łazience ogarniam córkę, a w drugiej łazience żona ogarnia syna. To tak jakbyśmy mieli w naszym komputerze dwa procesory oraz proces składający się z dwóch wątków. I jest to tzw. parallelism.

Możemy podsumować to również cytatem ze wspomnianej książki Operating System Concepts, rozdział 4.1.3 Multicore Programming:

“Consider an application with four threads. On a system with a single computing core, concurrency merely means that the execution of the threads will be interleaved over time, as the processing core is capable of executing only one thread at a time. On a system with multiple cores, however, concurrency means that the threads can run in parallel, as the system can assign a separate thread to each core.”

I jeszcze jeden fajny cytat z książki “Multithreaded Programming with Java Technology“:

“Running an MT (Multithreaded) program on a uniprocessor (UP) does not simplify your programming problems at all. Running on a multiprocessor (MP) doesn’t complicate them. This is a good thing.”

Stwórzmy wreszcie wątek (thread)

Rys. 1 Wątki JVM

Jeśli zrobisz debug prostej aplikacji wyświetlającej Hello world zobaczysz, że jeszcze dobrze nie zacząłeś kodować a już masz kilka wątków, w tym wątek main, który realizuje Twój wymuskany kod. Reszta wątków, które widzisz na powyższym obrazku, to jakieś tam defaultowe wątki maszyny wirtualnej Javy (JVM). Tak btw. fajny opis wątku Finalizer jest tu, a wątku Signal Dispatcher tu.

Jednak z punktu widzenia programisty nasz program składa się z jednego wątku: main.

Zarządzanie wątkami

Według dokumentacji Javy są na zarządzanie wątkami są dwa sposoby. Pierwszy sposób polega na wykorzystaniu klasy Thread, która jest chyba w Javie od pierwszej wersji JDK, a drugi polega na wykorzystaniu bardziej wysokopoziomowego API.

Tworzenie wątku

Aby uruchomić jakiś kod (task) w osobnym wątku, możemy:

Zaimplementować interfejs Runnable

Jeśli utworzysz instancję klasy, która implementuje interfejs Runnable, to kod tego obiektu zostanie wykonany w osobnym wątku. Ale jaki kod? Gdzie go wklikać? Interfejs Runnable jest funkcyjnym interfejsem, który posiada (zgodnie z definicją interfejsu funkcyjnego) jedną metodę run(). Implementując ten interfejs, siłą rzeczy będziesz musiał zaimplementować tę metodę. Zgodnie z Design By Contract wiemy, że każdy interfejs posiada jakieś przeznaczenie i reguły, które programista musi spełnić. Co mówi dokumentacja o kontrakcie odnośnie tego interfejsu:

"The general contract of the method run is that it may take any action whatsoever."

Ok. Wiele tego nie ma, fajnie. Kod, który znajdzie się w tej metodzie zostanie wykonany przez osobny wątek.

Zatem nasza klasa, która implementuje Runnable jest klasą zawierającą zadanie do wykonania. Teraz musimy jakoś powiedzieć maszynie wirtualnej Javy, aby to nasze zadanie zostało wykonane przez wątek. Póki co zdefiniowaliśmy zadanie do wykonania, a teraz czas na utworzenie instancji wątku.

Służy do tego klasa Thread, która posiada między innymi konstruktor

public Thread(Runnable target) { (...) }

gdzie target jest naszym zadaniem do wykonania. Tak też autor klasy Thread napisał w komentarzu:

/* What will be run. */
private Runnable target;

Ok. Uruchamiamy nasz wątek wywołując metodę start(). Po wywołaniu metody start() wątek nasz jest realnie tworzony przez natywną funkcję. Po wywołaniu tej metody pamięć dla wątku jest alokowana i wywoływana jest metoda run() naszego taska.

class MyTask implements Runnable {
   @Override
   public void run() {
       System.out.println("Test");
   }
}

class App {
   public static void main(String[] args) {
       Thread myThread = new Thread(new MyTask());
       myThread.start();
   }
}

Dziedziczyć klasę Thread

Przykład z dokumentacji.

public class HelloThread extends Thread {
     public void run() {
         System.out.println("Hello from a thread!");
     } 
    public static void main(String args[]) {   
      (new HelloThread()).start();
    }
} 

Implementacja Runnable VS Dziedziczenie Thread

Zadanie vs Wątek

Nasza klasa implementująca Runnable to nasze zadanie do wykonania. Task. W zasadzie ten Runnable mógłby się nazywać Task. To nie jest przecież jeszcze wątek. To dwie różne rzeczy. Następnie ten task powinniśmy przekazać do mechanizmu, który go uruchomi. Czyli w naszym przypadku do konstruktora klasy Thread.

Dziedziczenie ble?#1 Coupling#1

Jeśli będziemy dziedziczyć po klasie Thread to oprócz tego, że mieszamy zagadnienia w jednym miejscu, tj.: kod taska oraz kod wykonawcy taska (tworzymy między nimi silną zależność), to nasuwa się pytanie czy nie łamiemy reguły dotyczącej dziedziczenia, tj.: reguły IS A (Circle IS A Shape). Trochę śliska sprawa i pewnie można by się trochę pospierać.

Design By Contract

Dodatkowo robiąc override metod klasy bazowej musimy pamiętać o przestrzeganiu kontraktu. Więcej o tym możesz przeczytać tu.

Dziedziczenie ble#2?

Jeśli nie zamierzasz nadpisywać innych metod niż powyżej nadpisana metoda run() to użyj pierwszego sposobu, tj.: implementacji interfejsu Runnable. No bo jeśli dziedziczysz coś dla samego dziedziczenia, nie modyfikując zachowań (pozostając zgodnym z kontraktem), to na ch#* mi taka klasa. Bruce Eckel w Thinking in Java ujął taką sytuację mniej więcej tak, bardziej dyplomatycznie: “it isn’t particularly interesting“.

IS A vs IS LIKE A

W Javie użyte do dziedziczenia jest słówko extends, co może oznaczać, że będziemy w klasie pochodnej dodawać nowe metody, których klasa bazowa już nie będzie miała. Pojawia się tu temat pure substitution (wspomniane IS A vs IS LIKE A, jak to ujmuje w Thinking in Java Bruce Eckel).

Dziedziczenie ble#3? Coupling#2

Dodatkowo użycie poprzez implementację Runnable umożliwia współpracę ze wspomnianym na początku wysoko poziomowym API, o którym później. Pamiętaj też, że dziedziczenie po klasie Thread zabiera nam możliwość dziedziczenia po jakiejś innej klasie. Więc trochę słabo.

Leave a Reply

Your email address will not be published. Required fields are marked *