Model współbieżności w Javie

Wprowadzenie

W programowaniu sekwencyjnym, każdy program ma początek, sekwencje instrukcji do wykonania i koniec. W każdym momencie działania programu możemy wskazać miejsce, w którym znajduje się sterowanie. Taki program stanowi zatem pojedynczy, sekwencyjny przepływ sterowania. Program może jednak składać się z wielu przepływów sterowania, zwanych wątkami (ang. thread).

Każdy wątek ma początek, sekwencje instrukcji i koniec. Wątek nie jest niezależnym programem, jest wykonywany jako część programu. W programie wiele wątków może być wykonywanych jednocześnie i każdy z nich może wykonywać w tym samym czasie odmienne zadania (ang. tasks).

Jeśli program napisany wielowątkowo (ang. multithreaded), wykonywany jest na maszynie wieloprocesorowej, to różne wątki mogą być wykonywane w tym samym czasie na różnych procesorach. Sterowanie programu w takich przypadkach przebiega współbieżnie (ang. concurrent). Na komputerach jednoprocesorowych wykonanie programów wielowątkowych jest tylko emulowane. Emulacja ta polega na naprzemiennym przydzielaniu czasu procesora poszczególnym wątkom wg. pewnego algorytmu (zaimplementowanego w systemie operacyjnym). To, w jakim stopniu wątek będzie mógł wykorzystywać procesor, zależy od priorytetu wątku (priorytety zostaną omówione dalej w tym rozdziale).

Tak jak w programie sekwencyjnym, każdy wątek ma swoje zarezerwowane zasoby (jak np. licznik instrukcji), lecz oprócz tego może korzystać z zasobów programu, w którym jest wykonywany.

W Javie wątki są obiektami zdefiniowanymi za pomocą specjalnego rodzaju klas.

Program wielowątkowy definiujemy, na dwa sposoby:

Jeśli zdefiniujemy klasę z możliwością pracy jako wątek nie oznacza to automatycznie, że klasa ta będzie wykonana jako taki. Zostanie to wyjaśnione dalej w tym rozdziale.

Przykład prostego programu wielowątkowego

Spójrzmy jak wygląda wielowątkowość w praktyce.

Możemy zdefiniować naszą klasę jako wątek poprzez rozszerzenie klasy java.lang.Thread. Ten sposób daje nam bezpośredni dostęp do wszystkich metod kontrolujących wątek zdefiniowanych w klasie Thread.

W tej przykładowej aplikacji zdefiniowano dwie klasy: Watek i PierwszyWielowatkowy. Klasa Watek definiuje pojedynczy wątek, który będzie wykonywany w aplikacji PierwszyWielowatkowy, klasa ta dziedziczy z klasy Thread będącej częścią pakietu java.lang.

Przykład 2.24 Rozszerzenie klasy Thread

class Watek extends Thread 
{
	String wysun = "";
	public Watek(String str, int numer) 
	{
		super(str);
	// Ustawienie wcięcia z jakim będzie wyświetlana nazwa Watku
		for (int i = 1; i < numer; i++) wysun = wysun + "\t";
	}
	public void run() 
	{
		for (int i = 0; i < 4; i++) 
		{
			System.out.println(wysun + i + " " + getName());
			try 
			{ sleep( (int)(Math.random() * 1000) ); }
			catch ( InterruptedException e ) 
			{ e.printStackTrace(); }      }
		System.out.println(wysun + getName()+ " koniec" );
	}
}

Pierwszą metodą klasy Watek jest konstruktor z dwoma argumentami: nazwa wątku, typu String i parametr określający wcięcie, z jakim będzie wyświetlana na ekranie nazwa wątku. W konstruktorze tym pierwszą instrukcją jest wywołanie konstruktora nadklasy, który ustawia nazwę wątku. Następnie w bloku for ustawiane jest wcięcie z jakim na ekranie

Następną metodą klasy Watek jest metoda run(). Metoda run jest najważniejszą metodą każdego wątku, w której zdefiniowane są wszystkie działania wykonywane przez wątek. Metoda run() klasy Watek zawiera pętlę for wykonywaną cztery razy. W każdej iteracji metoda ta wyświetla na ekranie numer iteracji i nazwę wątku z odpowiednim wcięciem zależnym od parametru numer konstruktora. Po wyświetleniu tego napisu wątek za pomocą metody sleep() jest usypiany na przedział czasu wylosowany przez metodę random klasy Math. Po wykonaniu wszystkich iteracji wyświetlana jest na ekranie nazwa wątku z napisem "koniec".

Klasa PierwszyWielowatkowy definiuje aplikację. W metodzie main() tworzone są cztery wątki o nazwach: Janek, Magda, Wacek i Ola (przypuśćmy, że każda z tych osób ma zadzwonić do czworga znajomych, kto pierwszy zdoła to zrobić, ten wygrywa). Druga metoda tej klasy, znana nam i zdefiniowana już w tej pracy, pauza() zatrzymuje wyniki pracy aplikacji na ekranie.

class PierwszyWielowatkowy 
{
	public static void main (String[] args) throws Exception
	{
		new Watek("Janek",1).start();
		new Watek("Magda",2).start();
		new Watek("Wacek",3).start();
		new Watek("Ola",4).start();
		pauza();  }
	static void pauza() throws Exception
 	{ /* ... Zdefiniowana już wcześniej w tej pracy */  }
}

W metodzie main() wszystkie wątki zaraz po ich utworzeniu są uruchamiane dzięki użyciu metody start().

Po skompilowaniu i uruchomieniu tej aplikacji efekt działania będzie podobny do przedstawionego na ekranie.

Ilustracja 2-9 Przykładowy efekt wykonania aplikacji PierwszyWielowatkowy.

Widać, że wyniki działania wszystkich wątków są na ekranie przemieszane. Dzieje się tak dlatego, że wszystkie wątki typu Watek działają jednocześnie. Wszystkie metody run() wykonywane są jednocześnie i wyprowadzają na ekran efekty swojego działania w tym samym czasie. Prócz czterech wątków utworzonych w metodzie main() wciąż działa wątek główny aplikacji. Widzimy, że pierwszym napisem jaki wyprowadzono na ekran jest:

Nacisnij Enter....

który wyprowadza na ekran metoda pauza(). Jest tak mimo tego, że w kodzie metody main() metoda pauza() jest na samym końcu. To pokazuje nam, że w chwili utworzenia nowych wątków działają one niezależnie od wątku głównego programu.

Ciało wątku

Wszystkie zadania, jakie ma wykonywać wątek umieszczone są w metodzie run wątku. Po utworzeniu i inicjalizacji wątku, środowisko przetwarzania wywołuje metodę run.

W ciele metody run często pojawia się pętla. Na przykład, wątek odpowiedzialny za animację w pętli w metodzie run może wyświetlać serię obrazków. Niekiedy metoda run wątku wykonuje operacje, które zajmują dużo czasu np. ładowanie i odgrywanie dźwięków lub filmów.

Implementacja interfejsu Runnable

W wielu aplikacjach mamy do czynienia z koniecznością implementacji wielodziedziczenia, np. chcemy zdefiniować klasę, która ma własności wątku i jednocześnie rozszerza właściwości jakiejś innej klasy. Ponieważ w Javie nie jest możliwe wielodziedziczenie, rozwiązaniem w takim przypadku jest implementacja interfejsu Runnable.

Definicja interfejsu Runnable przedstwia się następująco:

public  interface  java.lang.Runnable
{  // Metody  
	public abstract void run();	
}

W rzeczywistości klasa Thread także implementuje interfejs Runnable. Interfejs Runnable ma tylko jedną metodę: run. Kiedy definiujemy klasę jako implementującą ten interfejs, musimy zadeklarować metodę run. W metodzie run wykonywane są wszystkie zadania, które mają być realizowane przez dany wątek.

Przykład klasy implementującej interfejs Runnable:

class MojaKlasa extends java.applet.Applet implements Runnable
{
 	public void run()
	{
		//ciało metody run
	}
	//... inne metody klasy MojaKlasa
}

Spójrzmy jak wygląda aplikacja, która wykonuje te same zadania, co przedstawiony wcześniej program , i jednocześnie wykonywana jest wielowątkowo, implementując interfejs Runnable.

Przykład 2.25 Implementacja interfejsu Runnable

Główne zmiany w porównaniu z wcześniejsza wersją zaznaczone są pogrubioną czcionką.

class WatekPodstawowy implements Runnable 
{
	String wysun = "";
	// W polu danych biezacy przechowywana będzie referencja do wątku,
	// w którym wykonana zostanie klasa WatekPodstawowy
	Thread biezacy;  
	public WatekPodstawowy( int numer) 
	{
		// metoda statyczna currentThread() klasy Thread zwaca
		// referencję do bieżącego wątku
		biezacy = Thread.currentThread();
		for (int i = 1; i < numer; i++)
 				wysun = wysun + "\t";  
	}  
	public void run() 
	{
	  for (int i = 0; i < 4; i++) 
	  {
			// dzięki referencji biezacy możemy na rzecz tego 
			// wątku wykonać metodę getName() (z klasy Thread)
			System.out.println(wysun + i + " " + biezacy.getName());
			try 
			{
				biezacy.sleep((int)(Math.random() * 1000));
			} 
			catch (InterruptedException e) {}
	  }
	  System.out.println(wysun + biezacy.getName()+ " koniec" );
	}
}

Klasa DrugiWielowatkowy różni się od klasy PierwszyWielowatkowy o wiele bardziej niż klasa WatekPodstawowy od jej poprzedniczki. W tamtej klasie tworzyliśmy nowe wątki i uruchamialiśmy je. W tym przypadku mamy tablice wątków watki[], której elementy są wątkami kontrolującymi wykonanie obiektów klasy WatekPodstawowy.

Przykład 2.26 Definicja klasy DrugiWielowatkowy

public class DrugiWielowatkowy
{
	// deklaracja tablicy wątków, deklarujemy ją jako static, bowiem
	// tylko do pól statycznych klasy możemy się odwołać w statycznej 
	// metodzie main()
	static Thread watki[];
	public static void main (String[] args) throws Exception
	{
		// przypisanie do pola danych watki tablicy referencji 
		// do obiektów typu Thread
		watki = new Thread[4];
		// inicjalizacja elementów tablicy watki,
		// utworzenie nowych wątków
		watki[0] = new Thread( new WatekPodstawowy(1),"Janek");
		watki[1] = new Thread( new WatekPodstawowy(2),"Magda");
		watki[2] = new Thread( new WatekPodstawowy(3),"Wacek");
		watki[3] = new Thread( new WatekPodstawowy(4),"Ola");
		// uruchomienie wątków
		for (int i=0; i<4; i++)
			watki[i].start();
		pauza();  }
	static void pauza() throws Exception
	{ /* ... Zdefiniowana już wcześniej w tej pracy */ }
}

Stany wątku

W czasie swego istnienia wątek może znajdować się w jednym z kilku stanów.

Poniższy rysunek przedstawia stany, w jakich może znajdować się wątek podczas swego życia oraz metody, których wywołanie powoduje przejście wątku do następnego stanu. Diagram ten nie jest kompletnym, skończonym diagramem stanów wątku ale obejmuje najbardziej interesujące i najczęściej występujące stany, w jakich wątek może się znaleźć.

Rysunek 2-5 Stany wątków.

Omówmy teraz poszczególne stany:

Poniższa instrukcja tworzy nowy wątek ale nie uruchamia go, lecz pozostawia wątek w stanie "nowy wątek".

Thread mojWatek = new MojaKlasaWatku();

Po wykonaniu tej instrukcji mamy zaledwie pusty obiekt Thread. Żadne zasoby systemowe nie zostały jeszcze alokowane dla tego wątku. Kiedy wątek znajduje się w tym stanie, możemy jedynie wykonać metodę start, uruchamiającą wątek, lub stop, kończącą działania wątku. Wszelkie próby wywołania innych metod dla wątków w tym stanie nie mają sensu i powodują wystąpienie wyjątku IllegalThreadStateException.

Przeanalizujmy poniższe dwie linie kodu:

Thread mojWatek = new MojaKlasaWatku();
mojWatek.start();

Metoda start tworzy zasoby systemowe potrzebne do wykonania wątku, przygotowuje wątek do uruchomienia, oraz woła metodę run. Od tego momentu wątek jest w stanie "wykonywany". Nie oznacza to jednak automatycznie, że wątek zostaje uruchomiony. Wiele komputerów ma tylko jeden procesor, co powoduje, że niemożliwe jest uruchomienie wielu wątków w tym samym momencie. Środowisko przetwarzania Javy musi implementować system przydziału czasu procesora (ang. scheduler), który dzieli czas procesora między wszystkie wątki będące w stanie "wykonywany". Więcej informacji o przydzielaniu czasu procesora wątkom znajduje się w rozdziale poświęconym priorytetom wątków.

Wątek przechodzi do stanu "nie wykonywany" gdy zachodzi jedno z poniższych zdarzeń:

Przykładowo, wykonanie metody sleep(10000) w poniższym kodzie powoduje uśpienie bieżącego wątku na 10 sekund (10 000 milisekund):

try 
{ Thread.sleep(10000); }
catch (InterruptedException e)
{ }

W czasie tych 10 sekund gdy wątek jest uśpiony, nawet gdy procesor staje się dostępny dla tego wątku, wątek ten nie zostaje uruchomiony. Po upływie 10 sekund wątek przechodzi do stanu "wykonywany" i jeśli procesor jest dostępny, jest on uruchamiany. Dla każdego przypadku przejścia wątku do stanu "nie wykonywany" istnieją specyficzne warunki, jakie muszą być spełnione, aby nastąpił powrót do stanu "wykonywany".

Poniżej przedstawiono warunki, jakie muszą być spełnione, aby nastąpił powrót do stanu "wykonywany":

 

Wątek może zakończyć działanie z dwu powodów: albo naturalnie zakończy swe działanie albo zostanie zabity (ang. kill). Wątek naturalnie kończy swoje działanie wtedy, gdy jego metoda run kończy się normalnie. W poniższym przykładzie pętla while wykonywana jest 50 razy i metoda run naturalnie kończy swoje działanie:

public void run() 
{
	int i = 1;
	while (i < 51) 
	{
		System.out.println( i + " iteracja");
		i++;
	}
}

Możemy także zabić wątek w każdym momencie poprzez wywołanie jego metody stop. W poniższym przykładzie tworzony i uruchamiany jest wątek mojWatek, następnie bieżący wątek jest usypiany na 10 sekund. Kiedy bieżący wątek się budzi, w przedostatniej linii przykładu wątek mojWatek zostaje zabity:

Thread mojWatek = new MojaKlasaWatku();
mojWatek.start();
try 
{  
	Thread.sleep(10000);
} catch (InterruptedException e){}
mojWatek.stop();

Metoda stop generuje w wątku obiekt (wyjątek) ThreadDeath, służący do zabicia go. Oznacza to, że wątek w takim przypadku zabijany jest asynchronicznie. Wątek zostaje zabity wtedy, gdy rzeczywiście odbierze wyjątek ThreadDeath.

Metoda stop oznacza nagłe zakończenie wykonania metody run wątku. Jeśli metoda run wykonuje jakieś ważne obliczenia, metoda stop może spowodować przerwanie wykonywania programu w stanie niespójnym. Dlatego nie powinno się wołać metody stop wtedy, gdy chcemy zakończyć wątek, lecz zrobić to w łagodniejszy sposób np. poprzez ustawienie flagi, która informuje metodę run, że powinna zakończyć swoje wykonanie.

Wyjątek IllegalThreadStateException

Środowisko przetwarzania generuje wyjątek IllegalThreadStateException, gdy próbujemy wywołać metodę wątku a wątek znajduje się w takim stanie, który nie pozwala na wywołanie tej metody. Przykładowo w stanie "nie wykonywany" wyjątek ten występuje, gdy próbujemy wywołać metodę suspend.

Metoda isAlive

Interfejs programistyczny dla klasy Thread zawiera metodę isAlive. Wynikiem wykonania metody isAlive jest wartość true, gdy wątek został uruchomiony a nie został jeszcze zakończony. Gdy wynikiem wykonania metody isAlive jest wartość false oznacza to, że wątek jest albo w stanie "nowy wątek" lub "zakończony". Gdy wynikiem jest wartość true wiemy, że jest albo w stanie "wykonywany" lub "nie wykonywany".

Priorytet wątku

Priorytet wątku informuje program szeregujący wątki Javy (ang. Java thread scheduler), kiedy nasz wątek powinien być wykonywany w odniesieniu do innych wątków.

Wcześniej wspomniano, że wątki wykonywane są równolegle. Jeśli konceptualnie jest to prawda, w praktyce zazwyczaj tak nie jest. Większość komputerów posiada tylko jeden procesor, więc w danej chwili czasu wykonywany może być tylko jeden wątek i wielowątkowość jest emulowana. Wykonanie wielu wątków na pojedynczym procesorze w jakiejś kolejności nazywane jest szeregowaniem (ang. scheduling). Środowisko przetwarzania Javy implementuje bardzo prosty, deterministyczny algorytm szeregowania znany jako "planowanie priorytetowe" (ang. fixed priority scheduling). Każdemu wątkowi przypisuje się pewien priorytet, po czym przydziela się procesor temu wątkowi, którego priorytet jest najwyższy.

Gdy nowy wątek jest tworzony, dziedziczy priorytet z wątku, który go utworzył. Priorytety wątku mogą być modyfikowane w każdej chwili po utworzeniu wątku poprzez użycie metody setPriority. Priorytet wątku jest liczbą typu integer o wartości większej lub równej MIN_PRIORITY i niewiększej niż MAX_PRIORITY (są to stałe zdefiniowane w klasie Thread o wartości odpowiednio 1 i 10). Im większa wartość liczby określającej priorytet, tym priorytet wątku jest większy. W momencie, gdy wiele wątków jest gotowych do wykonania, środowisko przetwarzania wybiera do wykonania wątek z najwyższym priorytetem. Tylko w przypadku, gdy wątek zatrzymuje się, ustępuje czas procesora (metoda yield) lub przechodzi do stanu "nie wykonywany", wątek o niższym priorytecie zaczyna być wykonywany. Gdy dwa wątki o tym samym priorytecie czekają na przydzielenie im czasu procesora, program szeregujący wybiera jeden z nich i przydziela czas według algorytmu "planowania rotacyjnego" (ang. round-robin). W algorytmie tym ustala się małą jednostkę czasu, nazywaną kwantem czasu lub odcinkiem czasu. Kolejka procesów gotowych do wykonania jest traktowana jako kolejka cykliczna. Planista przydziału procesora przegląda tę kolejkę i każdemu wątkowi przydziela odcinek czasu nie dłuższy od jednego kwantu czasu. Gdy wątek ma czas wykonania dłuższy, niż kwant czasu, to nastąpi przerwanie wykonywania wątku i zostanie on odłożony na koniec kolejki.
Wybrany wątek będzie wykonywany do momentu, gdy jeden z poniższych warunków będzie spełniony:

Jeśli w jakimś momencie wątek z większym priorytetem, niż inne wątki w stanie "wykonywany" znajdzie się tym w stanie, to środowisko przetwarzania Javy wybiera do wykonania wątek z nowym najwyższym priorytetem.

Uwaga:

W danym momencie wątek o najwyższym priorytecie jest wykonywany. Jednakowoż nie jest to gwarantowane. Program szeregujący wątki może wybrać do wykonania wątek z niższym priorytetem, aby uniknąć zagłodzenia. Z tych powodów, poleganie na priorytetach nie gwarantuje nam poprawności algorytmu.

Demony

Każdy wątek Javy może zostać demonem (ang. daemon thread). Wątek będący demonem zajmuje się obsługiwaniem innych wątków uruchomionych w tym samym procesie, co wątek demona. Metoda run wątku demona jest przeważnie nieskończoną pętlą, w której demon czeka na zgłoszenia zapotrzebowania na usługi dostarczane przez ten wątek.

Przykładowo, przeglądarka HotJava używa do czterech demonów nazwanych "Image Fetcher", które dostarczają obrazków z dysku lub sieci dla wątków, które tego potrzebują.

Wykonanie programu kończy się z chwilą zakończenia ostatniego wątku, który nie jest demonem. Dzieje się tak dlatego, że gdy w programie nie istnieją już inne wątki prócz demonów, demony nie mają już dla kogo dostarczać usług i ich dalsze istnienie nie ma sensu.

Aby wątek został demonem używany metody setDaemon z argumentem równym true.

W celu sprawdzenia, czy wątek jest demonem używana jest metoda isDaemon.

Grupowanie wątków

Każdy wątek Javy jest członkiem grupy wątków (ang. thread group). Grupowanie wątków w jednym obiekcie pozwala na jednoczesne manipulowanie wszystkimi zgrupowanymi wątkami. Przykładowo, możemy uruchomić (metoda start ) lub zawiesić (suspend) wszystkie zgrupowane wątki dzięki wykonaniu jednej metody. Grupowanie wątków w Javie zaimplementowano w klasie java.lang.ThreadGroup.

Środowisko przetwarzania Javy umieszcza nowy wątek w grupie wątków podczas jego tworzenia. Kiedy tworzymy nowy wątek, możemy środowisku przetwarzania pozwolić na umieszczenie wątku w domyślnej grupie wątków lub możemy explicite zadeklarować nową grupę wątków i dodać do niej nasz wątek. Po tym, jak wątek stał się członkiem jakiejś grupy wątków podczas tworzenia, nie można przenosić wątku do innej grupy.

Jeśli tworzymy nowy wątek bez specyfikacji jego grupy w konstruktorze, środowisko przetwarzania automatycznie umieszcza nowy wątek w tej samej grupie co wątek, który go utworzył. Gdy aplikacja Javy zostaje uruchomiona, środowisko przetwarzania tworzy automatycznie obiekt ThreadGroup o nazwie 'main' (nasz wątek główny należy do grupy wątków 'main'). I gdy nie zadeklarujemy tego inaczej, wszystkie utworzone wątki staną się członkami grupy wątków 'main'.

Uwaga:

Gdy tworzymy wątek w aplecie, nowe wątki mogą być członkami innych grup wątków niż 'main', zależnie od przeglądarki, w której aplet jest uruchamiany. Wątki w aplecie omówione zostaną w rozdziale: Wielowątkowość w apletach .

Jeśli chcemy utworzony wątek umieścić w grupie wątków innej niż domyślna, musimy tę grupę wyspecyfikować w momencie, gdy nowy wątek jest tworzony. Klasa Thread ma trzy konstruktory pozwalające ustawić nową grupę wątków:

public Thread(ThreadGroup grupaWatkow, Runnable target)  
public Thread(ThreadGroup grupaWatkow, String name)  
public Thread(ThreadGroup grupaWatkow, Runnable target, String name)

Każdy z tych konstruktorów tworzy nowy wątek, który jest członkiem wyspecyfikowanej grupy wątków. Przykładowo, poniższy kawałek kodu tworzy nową grupę wątków (mojaGrupaW) a następnie tworzy nowy wątek mojWatek należący do tej grupy:

ThreadGroup mojaGrupaW = new ThreadGroup("Moja grupa watkow");  
Thread mojWatek = new Thread(mojaGrupaW, "watek w mojej grupie");

Grupa wątków, do której dołączamy nasz wątek nie musi być grupą zadeklarowaną przez nas, może to być grupa stworzona przez środowisko wykonawcze Javy lub grupa wątków stworzona przez program (np. przeglądarkę), w którym nasz aplet uruchomiono.

Jeśli chcemy sprawdzić, do jakiej grupy należy nasz wątek, możemy w tym celu użyć metody getThreadGroup():

grupa = mojWatek.getThreadGroup();

Wynikiem wykonania tej metody jest referencja do obiektu reprezentującego grupę wątków.

Gdy mamy dostęp do obiektu reprezentującego grupę wątków, możemy uzyskać różnego rodzaju informacje np. jakie jeszcze inne wątki należą do tej grupy, możemy także modyfikować wątki należące do tej grupy np. możemy je uśpić, zatrzymać lub zakończyć w pojedynczym wywołaniu metody np. użycie metody:

grupa.suspend();

w tym przypadku powoduje, że wszystkie wątki należące do grupy wątków grupa zostają uśpione.

Grupa wątków może zawierać dowolną liczbę wątków. Wątki należące do jednej grupy przeważnie są jakoś powiązane ze sobą np. wspólnym wątkiem, który je utworzył, zadaniami jakie wykonują w programie, lub momentem, w którym powinny zostać uruchomione i zatrzymane.

Obiekt klasy ThreadGroup może zawierać nie tylko wątki ale także inne obiekty klasy ThreadGroups. Najwyżej w hierarchii wątków aplikacji Javy znajduje się grupa wątków o nazwie 'main'. W grupie wątków 'main' możemy tworzyć nowe wątki lub grupy wątków. W grupach wątków można z kolei tworzyć następne wątki lub grupy wątków. W rezultacie hierarchia wątków może przybrać wygląd jak na poniższym rysunku:

Rysunek 2-6 Przykładowa hierarchia grup wątków i wątków w aplikacji Javy.

Klasa ThreadGroup zawiera metody, które możemy następująco posegregować:

Synchronizacja wątków

Rozważmy teraz przypadek, gdy wykonywane są dwa niezależne wątki, które współdzielą dane i stan każdego z nich zależy od stanu drugiego wątku. Jedną z takich sytuacji jest problem typu Producent/Konsument (ang. producer/consumer), gdzie producent generuje strumień danych, które są wykorzystywane (konsumowane) przez konsumenta. Strumień ten stanowi wspólny zasób, wątki muszą być zatem synchronizowane.

Załóżmy, że producent generuje liczby od 0 do 9, które są następnie składowane w obiekcie typu Pudelko. Producent, po włożeniu do pudełka liczby i wydrukowaniu jej na ekranie, zostaje uśpiony na losowo wybrany czas między 0 a 100 milisekund, zanim przejdzie do następnego cyklu produkcji liczby.

Przykład 2.27 Aplikacja Producent/Konsument

class Producent extends Thread 
{
	private Pudelko pudelko;
 	private int m_nLiczba;
 	public Producent(Pudelko c, int liczba) 
	{
  		pudelko = c;
  		this.m_nLiczba = liczba;
	}
	public void run() 
	{
		for (int i = 0; i < 10; i++) 
		{
			pudelko.wloz(i);
			System.out.println("Producent #" + this.m_nLiczba + " wlozyl: " + i);
			try 
			{
				sleep((int)(Math.random() * 100));
			} 
			catch (InterruptedException e) {  }
		}
	}
}

Konsument podczas swego działania konsumuje wszystkie liczby złożone w pudełku, wyprodukowane przez Producenta, tak szybko, jak staną się one dostępne.

class Konsument extends Thread 
{
	private Pudelko pudelko;
	private int m_nLiczba;
	public Konsument(Pudelko c, int Liczba) 
	{
		pudelko = c;
		this.m_nLiczba = Liczba;
	}
	public void run() 
	{
     		int wartosc = 0;
    	 	for (int i = 0; i < 10; i++) 
		{
			wartosc = pudelko.wez();
       			System.out.println("Konsument #" + this.m_nLiczba + " wyjal: " + wartosc);
    		}
	}
}

Producent i konsument w tym przykładzie współdzielą dane przez wspólny obiekt typu Pudelko. Konsument ma prawo pobrać każdą wyprodukowaną liczbę tylko raz. Synchronizacja między tymi dwoma wątkami występuje w metodach wez() i wloz() obiektu Pudelko.

Klasa Pudelko wygląda następująco:

class Pudelko 
{
	private int m_nZawartosc;   // to jest znienna warunkowa
	// do której dostęp synchronizujemy, (omówione później)
	private boolean m_bDostepne = false;
	public synchronized int wez() 
	{
		while (m_bDostepne == false) 
		{
			try 
			{	wait(); } 
			catch (InterruptedException e) { }
		}
		m_bDostepne = false;
		notifyAll();
		return m_nZawartosc;
	}
	public synchronized void wloz(int wartosc) 
	{
		while (m_bDostepne == true) 
		{
			try 
			{ wait();	} 
			catch (InterruptedException e) { }
		}
		m_nZawartosc = wartosc;
		m_bDostepne = true;
		notifyAll();
	}
}

Klasa Pudelko zostanie dokładnie omówiona później w tym rozdziale.

Po wykonaniu aplikacji ProdKonsTest, która tworzy obiekty typu Pudelko oraz Producent i Konsument, a następnie uruchamia wątki Producenta i Konsumenta (współdzielące obiekt typu Pudelko) :

class ProdKonsTest 
{
	public static void main(String[] args) throws Exception
	{
		Pudelko c = new Pudelko();
		Producent p1 = new Producent(c, 1);
		Konsument c1 = new Konsument(c, 1);
		p1.start();
		c1.start();
		pauza(); 
	}
	static void pauza() throws java.io.IOException
	{
		System.out.println("Nacisnij Enter...");
		System.in.read();
	}
}

Na ekranie otrzymamy:

Ilustracja 2-10 Wynik działania aplikacji KonsProdTest.

W naszym programie użyto dwóch mechanizmów synchronizacji wątków Producenta i Konsumenta: monitora i dwóch metod: wait oraz notifyAll.

Monitory

Obiekty takie, jak Pudelko, które są współdzielone pomiędzy dwa wątki, i do których dostęp musi być synchronizowany nazywane są zmiennymi warunkowymi (ang. condition variable). Java pozwala synchronizować wątki pracujące ze zmiennymi warunkowymi dzięki użyciu monitorów. Monitory związane są ze specyficznymi danymi (nazywanymi zmiennymi warunkowymi) i działają jako blokada zakładana na tych danych. Gdy wątek zajmie monitor dla jakiejś danej, inne wątki do czasu zwolnienia monitora zostają zablokowane i nie mogą odczytywać lub modyfikować danych.

Segment kodu w programie, w którym następuje dostęp do tej samej danej z różnych wątków nazywany jest sekcją krytyczną (ang. critical section). W Javie sekcję krytyczną oznaczmy przy użyciu słowa kluczowego synchronized.

Generalnie, w programach Javy, sekcją krytyczną są metody. Można oznaczyć mniejszy kawałek kodu jako sekcję krytyczną. Jednak taki sposób programowania nie jest zgodny z paradygmatem programowania obiektowego i prowadzi do zaciemnienia kodu, który staje się przez to trudny do zrozumienia i sprawdzenia poprawności (ang. debug). Najlepszym rozwiązaniem jest używanie synchronizacji tylko na poziomie metod.

W Javie każdy obiekt, który ma metody synchroniczne posiada swój monitor. Przedstawiona wcześniej klasa Pudelko ma dwie metody synchroniczne: metodę wloz(), używaną do zmiany wartości w obiekcie typu Pudelko i metodę wez(), która jest używana do pobrania liczby przechowywanej w obiekcie Pudelko. Oznacza to, że system skojarzy z każdym obiektem typu Pudelko unikalny monitor. W przedstawionym poniżej kodzie klasy Pudelko elementy wyróżnione pogrubioną czcionką służą do synchronizacji wątków:

class Pudelko 
{
	private int m_nZawartosc;   
	private boolean m_bDostepne = false;
	public synchronized int wez() 
	{
		// monitor zostaje zajęty przez Konsumenta
		while (m_bDostepne == false) 
		{
			try 
			{	wait(); } // metoda wait() tymczasowo zwalnia monitor, 
				// (dokładny opis w rozdziale poświęconym metodzie wait )
			catch (InterruptedException e) { }
		}
		m_bDostepne = false;
		notifyAll();
		return m_nZawartosc;
		// monitor zostaje zwolniony przez Konsumenta
	}
	public synchronized void wloz(int wartosc) 
	{
		// monitor zostaje zajęty przez Producenta
		while (m_bDostepne == true) 
		{
			try 
			{ wait();	} // metoda wait() tymczasowo zwalnia monitor 
			catch (InterruptedException e) { }
		}
		m_nZawartosc = wartosc;
		m_bDostepne = true;
		notifyAll();
		// monitor zostaje zwolniony przez Producenta
	}
}

Klasa Pudelko posiada dwa pola danych: m_nZawartosc, które stanowi bieżącą zawartość pudełka oraz pole danych m_bDostepne typu boolean, które określa, czy zawartość pudełka może być pobrana. Gdy zmienna m_bDostepne jest równa true oznacza to, że Producent właśnie umieścił nową wartość w Pudełku, a Konsument jeszcze jej nie pobrał. Konsument może pobrać daną z Pudełka tylko wtedy, gdy zmienna m_bDostępne jest równa true.

Jeśli usuniemy z metod wez() i wloz() klasy Pudelko elementy kodu odpowiedzialne za synchronizacje (oznaczone pogrubioną czcionką). Wynik działania aplikacji ProdKonsTest będzie następujący (lub podobny):

Ilustracja 2-11 Wynik działania aplikacji ProdKonsTest bez mechanizmów synchronizacji.

Jak widać, Konsument pobiera liczby z Pudełka nie czekając na wyprodukowanie przez Producenta kolejnej liczby.

Wróćmy jednak do naszego przykładu, gdzie synchronizacja jest zapewniona. Ponieważ klasa Pudelko ma dwie metody synchroniczne, dla każdego obiektu klasy Pudelko tworzony jest oddzielny monitor. W momencie gdy sterowanie znajdzie się w metodzie synchronicznej, wątek, który wywołał tę metodę zajmuje monitor obiektu, którego metodę wywołano. Inne wątki nie mogą wołać metod synchronicznych tego obiektu do czasu zwolnienia monitora. Monitory w Javie są wielodostępne (ang. reentrant). Oznacza to, że wątek, który zajął monitor jakiegoś obiektu może wołać inne metody synchroniczne obiektu.

Zawsze wtedy, gdy Producent woła metodę wloz, Producent zajmuje monitor obiektu Pudelko, co powoduje, że Konsument nie może wywołać metody wez do czasu zwolnienia monitora. Gdy metoda wloz kończy działanie, Producent zwalnia monitor i odblokowuje obiekt typu Pudelko.

Podobnie gdy Konsument woła metodę wez klasy Pudelko, Konsument zajmuje monitor obiektu typu Pudelko, co powoduje, że Producent nie może wywołać metody wloz do czasu zwolnienia monitora.

Operacje zajmowania i zwalniania monitora są wykonywane automatycznie przez środowisko wykonawcze Javy, co zapewnia integralność danych i chroni przed wystąpieniem sytuacji wyjątkowych spowodowanych operacjami na monitorach.

Metody notifyAll i wait

Metody wait i notifyAll należą do klasy java.lang.Object i mogą być wywoływane tylko przez wątki, które założyły blokadę.

Metoda notifyAll informuje wszystkie wątki oczekujące na monitor zajęty przez bieżący wątek o zwolnieniu tego monitora i budzi te wątki. Przeważnie jeden z oczekujących wątków zajmuje monitor i wykonuje swoje zadanie.

W naszym przykładzie, metody wait i notifyAll służą do koordynacji wkładania i wyjmowania liczb z Pudełka. Wątek Konsumenta woła metodę wez i zajmuje monitor obiektu Pudelko na czas wykonania metody wez. Na końcu metody wez, wywołanie metody notifyAll budzi wątek Producenta oczekujący na monitor obiektu Pudelko. W tym momencie wątek Producenta zajmuje monitor i wykonuje swoje zadanie.

public synchronized int wez() 
	{
		while (m_bDostepne == false) 
		{
			try 
			{	wait(); } 
			catch (InterruptedException e) { }
		}
		m_bDostepne = false;
		notifyAll(); // zawiadomienie Producenta
		return m_nZawartosc;
	}

Gdy wiele wątków oczekuje na monitor, system wykonawczy Javy wybiera jeden z oczekujących wątków do wykonania, nie jest jednak określone, który z oczekujących wątków zostanie wybrany.

W metodzie wloz, notifyAll działa podobnie jak w metodzie wez, budzi wątek Konsumenta, oczekujący na zwolnienie monitora przez Producenta.

W klasie Object jest zadeklarowana także metoda notify, która budzi jeden wybrany wątek oczekujący na monitor. W tej sytuacji, każdy z pozostałych wątków oczekuje dalej do czasu odstąpienia monitora i wybrania go przez system wykonawczy. Użycie notify może być źle uwarunkowane, to jest, może zawieść, gdy warunki zmienią się trochę. W programowaniu współbieżnym ustalenie wszystkich zdarzeń, jakie mogą zajść podczas wykonania programu jest bardzo trudne. Rozwiązanie wykorzystujące metodę notifyAll jest stabilniejsze niż wykorzystujące metodę notify. W przypadku, gdy nie zamierzamy dokładnie analizować programu wielowątkowego lepiej będzie, gdy zastosujemy metodę notifyAll.

Wątek wołający metodę wait musi posiadać monitor. Metoda wait powoduje zwolnienie posiadanego monitora i przejście w stan oczekiwania do czasu, aż inny wątek powiadomi (ang. notify) go o zwolnieniu monitora obiektu. Wtedy wątek ten przejmuje monitor i kontynuuje swoje działanie. Metody wait używamy wraz z metodami notify lub notifyAll do koordynacji działania wielu wątków używających tych samych zasobów.

W metodzie wez znajduje się pętla while. Gdy zmienna m_bDostepne jest równa false, Producent nie wyprodukował jeszcze nowej liczby i Konsument musi czekać, więc wywoływana jest metoda wait. Powoduje to zwolnienie monitora obiektu Pudelko i Producent zajmuje ten monitor i może wyprodukować liczbę. Gdy Producent w metodzie wloz woła metodę notifyAll, Konsument budzi się i kontynuuje pętlę while. Jeśli liczba jest już wyprodukowana (m_bDostępne == true), pętla while zostaje opuszczona i kontynuowane jest wykonanie metody wez.

public synchronized int wez() 
	{
		while (m_bDostepne == false) 
		{
			try 
			{	
				// czeka na wywołanie przez Producenta notifyAll()
				wait(); 
			}
			catch (InterruptedException e) { }
		}
		m_bDostepne = false;
		notifyAll(); 
		return m_nZawartosc;
	}

Zasada działania metody wloz jest podobna, implementuje oczekiwanie na wątek Konsumenta aż skonsumuje on wyprodukowaną liczbę, gdy to nastąpi oczekujący wątek Producenta może włożyć następną liczbę do Pudełka.

Poza użytą przez nas wersją metody wait w klasie Object mamy zadeklarowane jeszcze dwie inne wersje metody wait:

wait(long timeout) - gdzie parametr timeout oznacza maksymalny czas oczekiwania na zwolnienie monitora (metody notify lub notifyAll) w milisekundach;

wait(long timeout, int nanos) - gdzie parametr timeout podobnie, jak wyżej oznacza maksymalny czas oczekiwania na zwolnienie monitora w milisekundach a nanos dodatkowy czas w nanosekundach o zakresie od 0 do 999999.

Metody wait i notify powinny być wołane przez wątki, które zajmują monitor danego obiektu. Wątek może stać się właścicielem monitora obiektu na trzy sposoby:

Gdy piszemy program, w którym kilka wątków pracuje współbieżnie¸ musimy pamiętać, że mogą wystąpić sytuacje, w których nastąpi zagłodzenie (ang. starvation) lub zakleszczenie wątków (ang. deadlock). Zagłodzenie występuje wtedy, gdy jeden lub więcej wątków zostaje zablokowanych wtedy, gdy próbują uzyskać dostęp do zasobu. Zakleszczenie jest krańcową formą zagłodzenia, występuje, gdy wątki oczekują na spełnienie warunku, który nigdy nie może być spełniony. Zakleszczenie najczęściej występuje wtedy, gdy dwa lub więcej wątków oczekuje, aż inny (inne) wątek coś zrobi.

Aby uniknąć tych sytuacji należy prawidłowo zaprojektować synchronizację.