Poniedziałek 28 Kwiecień 2025r. Godz 00:00:00      
Postów: 251      

Wątki (threads) w Java

Programy pisane za pomocą języków C czy Pascal, składają się ogólnie mówiąc z pojedynczego, głównego modułu wykonywanego linia po linii. Java podobnie jak inne nowoczesne systemy operacyjne obsługuje wielozadaniowosć (w przypadku Javy chodzi o wilowątkowosć, multithreading), czyli możliwosć jednoczesnego uruchamiania oddzielnych modułów programu oraz ich dalszego równoległego działania.

Klasyczny, jednowątkowy program wykonywany jest przez procesor w sposób liniowy - instrukcja po insrukcji. W przypadku wystąpienia w nim operacji, które z różnych przyczyn przebigają relatywnie wolno (na przykład proces drukowania rysunku, oczekiwanie na podanie przez użytkownika jakis danych), korzystniej by łoby gdyby zostały one przesunięte na dalszy plan (były wykonywane w tle). W tym czasie procesor zająłby się innymi, nie cierpiącymi zwłoki zadaniami (jak na przykład wykonywaniem obliczeń czy odswierzaniem ekranu).

Najlepszym wyjsciem byłoby jednoczesne wykonywanie dwóch zcy nawet więcecej modułów programu (wątków). Jeden z nich mógłby na przykład przejąć zadanie drukowania, podczas gdy inny byłby odpowiedzialny za przyjmowanie od użytkownika danych, a kolejny za wykonywanie odpowiednich obliczeń. Co prawda w zasadzie taka równoległosć działań jest możliwa do uzyskania bez "wielozadaniowosci", jednak osiągnięcie tego efektu kosztuje dosć dużo pracy. Wielowątkowosć jest jedną z cech Javy dlatego wiele problemów związanych z tym zagadnieniem przestaje istnieć.

Możliwosć równoległego uruchamiania kilku wątków nie oznacza jednak jeszcze, że system wyposażony tylko w jeden procesor rzeczywiscie będzie w stanie wykonywać więcej niż jedno działanie jednoczesnie. De facto chodzi jedynie o wykonywanie kilku linijek kodu danego wątku. Tak zwany scheduling (przełączanie zadań) odpowiedzialny jest za przydzielanie każdemu wątkowi odpowiedniej ilosci czasu obliczeniowego procesora, tak by sprawiać wrażenie rownoległosci wykonywania.

Ponadto poszczególnym wątkom mogą być przypisane różne priorytety, które decydują o kolejnosci przydzielania im czasu obliczeniowego. Służy do tego funkcja "setPriority()", której parametr musi miescić się w przedziale od MIN_PRIORITY do MAX_PRIORITY. Im mniejsza jest jego wartosć, tym dłużej wątek będzie czekałna swoją kolej. Wątki o takim samym priorytecie obsługiwane będą jędnoczenie.

Istnieją dwie możliwosci tworzenia wątków. Po pierwsze poprzez utworzenie klasy pochodnej zawierającej kod wątku od klasy "Thread". W poniższym przykładzie gry w kosci utworzone zostały dwa typy wątków . "WatekGracza" obrazuje zachowanie graczy i "CzasGry" odpowiedzialny za odmierzanie czasu trwania gry - z każdą sekundą będzie zwiększał odpowiedni licznik. Klasa "Gra" zawiera procedurę "main()". W pierwszej kolejnosci utworzone zostaną one uaktywnione (wywolania: "Tomasz.start()" oraz "Jerzy.start()"). Po upływie dziesięciu sekund, gra zostanie zakończona, a otrzymany rezultatpoddany ocenie.

public class Gra
  {
   static boolean GraAktywna=true;
   
   public static void main(String args[])
     {
      // tworzenie wątków (-> stan gry "New" ...)
      WatekGracza Tomasz=new WatekGracza("Tomasz");
      WatekGracza Jerzy=new WatekGracza("Jerzy");

      //... uruchomienie wątków (-> stan gry "Runnable")
      Tomasz.start();
      Jerzy.start();

      // uruchomienie zegara odmierzającego czas trwania gry
      new CzasGry().start();
      while (CzasGry.Sekundy < 10)
        {// gra zakończona
         GraAktywna=false;
        }

      // podanie wyniku
      System.out.println("Punkty uzyskane przez Tomasza: "+
                     Tomasz.Punkty+" / Jerzego: "+Jerzy.Punkty);
      if (Tomasz.Punkty > Jerzy.Punkty)
        System.out.println("Wygrał Tomasz");
      else
        System.out.println("Wygrał Jerzy");
     }
  }

class WatekGracza extends Thread
  {
   // liczba punktów osiągniętych przez gracza
   public int Punkty;

   // konstruktor wątku
   public WatekGracza (String ImieGracza)
     {
      // konstruktor klasy bazowej wywoływany jest przez nazwę wątku
      super(ImieGracza);
     }

   public void run()
     {
      int Rzut;  // wynik rzutu
      while (Gra.GraAktywna)
        {
         // rzut kośćmi
         Rzut=(int)(Math.random()*6); // funkcja random z pakietu java.util

         // zwiększenie liczby punktów o uzyskany wynik rzutu
         Punkty+=Rzut;

         // podanie wyniku rzutu
         System.out.println(getName()+" wyrzucił "+
                      Rzut+"oczek - liczba punktów"+Punkty);
         
         // im gorszy wynik rzutu, tym dłużej gracz musi czekać na swoją kolej
         // (jeden gracz może rzucać kilakrotnie pod rząd)
         try
           {
            sleep ((long)(6-Rzut)*100);
           }
         catch (InterruptedException e)
           {
            // wyjątek ten zostanie usunięty kiedy bieżący potok 
            // zostanie przerwany przez inny.
            // Niniejsza instrukcja catch musi tu zostać zaimplementowana,
            // w przeciwnym razie kompilator zgłosi komunikat o błędzie. 
            System.out.println("Wyjątek");
           }
        }
     }
  }

class CzasGry extends Thread
  {
   static int Sekundy=0;
   
   public void run()
     {
      while (Gra.GraAktywna)
        {
         try {sleep(1000);}
         catch (InterruptedException e) {}
         Sekundy+=1;
        }
     }
  }

Metoda "run()" klasy "WatekGracza" opisuje właściwą część gry: Każdy z graczy może rzucać koscmi. osiągnięta liczba oczek dodawana jest do ogólnej liczby puktów danego gracza. Im jest wyższa, tym krócej dany wątek znajduje się w stanie "uspienia" (instrukcja "sleep()" umożliwia ustawiene potoku w stan bezczynnosci na okreslony, podawany w milisekundach czas). W przypadku uzyskania "6" gracz może natychmiast rzucać dalej. Po dziesięciu sekundach sędzia (funkcja "main()") konczy grę (ustawiając "GraAktywna = false"). Jesli utworzonych zostało kilku graczy, grają oni przeciwko sobie. ponieważ zależnie od uzyskiwanej liczby oczek wzajemnie przenoszą się oni w stan uspienia, każdy z nich, zależnie od uzyskiwanych rezultatów może rzucać kilka razy pod rząd. Za każdym razem podawana jest liczba uzyskiwanych przez gracza oczek oraz ogólna liczba zdobytych przez niegodo tej pory punktów. Użycie funkcji "sleep()" wymusza stworzenie metody obsługi wyjątku "InterruptedException", ponieważ nie jest to wyjątek typu runtime-exception i jesli nie zostanie przechwycony interpreter Javy przerwie wykonanie programu w momencie, w którym się on pojawi. Wyjątek ten występuje w chwili, gdy jeden wątekjest zatrzymywany, a uaktywniany inny (przełączanie zadań).

Identyfikacja gracza odbywa się za posrednictwem nazwy odpowiadającego mu wątku. Konstruktor klasy "WatekGracza" otrzymuje tę nazwę jako parametr. Za posrednictwem wywołania "super()" przewkazuje ją dalej, do konstruktora klasy bazowej. Z kolei "getName()" podaje nazwę potoku, który jest aktualnie aktywny.

Druga możliwosć polega na utworzeniu klasy wątku implementującej interfejs "Runnable". Każda klasa, która implementuje ten interfejs musi implementować własną metodę "run()". W celu uruchomienia takiego wątku należy więc najpierw stworzyć obiekt tej klasy, a potem utworzyć wątek (obiekt klasy "Thread"). Konstruktor klasy "Thread", któremu należy przekazać nasz obiekt i staje sie on odpowiedzialny za przeprowadzenie wszelkich koniecznych inicjalizacji. Na koniec w celu uruchomienia wątku należy wywołać metodę "start()". Definicja przykładowego wątku może więc wyglądać następująco:

class DemoThread implements Runnable
  {
   public void run() // zadaniem wątku jest odliczenie od 1 do 10
     {
      int i;
      for (i=0; i<10; ++i)
        System.out.println(i);
     }
  }

public class Demo
  {
   public static void main (String args [])
     {
      // utworzenie obiektu klasy "DemoThread"
      DemoThread d = new DemoThread();

      // utworzenie wątku z tego właśnie obiektu
      Thread t1 = new Thread (d);
      // a następnie jego wystartowanie
      t1.start();
     }
  }

Oprócz metody "run()" w interfejsie "Runnable" istnieją jeszcze inne -"init()", "start()" czy "stop()", które mogą mieć istotne znaczenie dla uruchomienia bądź zakończenia wątku. Jak widać w podanym przykładzie wątek nie musi ich implementować - wystarczy wywołać odpowiednią metodę klasy bazowej. Metoda "init()" przeprowadza wszystkie konieczne do uruchomienia wątku inicjalizacje (na przykład: otwarcia okna, załadowanie fontu, przypisanie priorytetu itd.). wywołanie metody "start()" uruchamia wątek, a "stop()"zatrzymuje go.

Zależnie od tego, czy wątek został własnie stworzony, czy jest aktywny, zatrzymany, czy też zakończony możemy rozróżnić kilka następujących stanów:

New Wątek, ewentualnie obiekt jest tworzony za pomocą operatora "new", ale nie oznacza to jego natychmiastowego uruchomienia (stan przed wywołaniem metody "start()"). W tym stanie wątek nie zużywa jeszcze żadnnych zasobów systemu.
Runnable    Wywołanie metody "start()" wątku nie oznacza jeszcze, że wykonuje się on ciągle. Jesli (na przykład) w tym samym czasie aktywny jest jakis inny wątek, to inne przechodzą do stanu "Runnable". W każdym momencie może on zostać uaktywniony.
Running Wątek jest wykonywany (otrzymał czas obliczeniowy procesora).
Not Runnable W tym stanie dany wątek nie może zostać wystartowany. W praktyce oznacza to, że albo oczekuje na wykonanie jakiejs wolnej operacji wejscia/wyjscia (na przyklad na podanie przez użytkownika danych z klawiatury) albo też został przeniesiony w ten stan za pomocą odpowiednich metod: "suspend()", "sleep()" lub "wait()". Wątek znajdujący się w tym stanie może zostać przeniesiony do stanu "Runnable" w monencie zakończenia wstrsymującej go operacji wejscia/wyjscia, lub też po wywołaniu metody "notify()" lub "notifyAll()" (jesli zostrał wstrzymany przez "suspend()", "resume()" lub "wait()").
Dead Wątek może zostać zatrzymany za pomocą metody "stop()". Po takiej operacji wątek formalnie może jeszcze istnieć, o ile tylko obiekt klasy Thread jest jeszcze aktywny. Z tego stanu wątek nie może być już wystartowany - jest więc "martwy" ("dead"). W stan ten wątek zostaje także przeniesiony po normalnym jego wykonaniu, a następnie zakończeniu.

Tworzenie programów wspólbieżnych, jednoczesnie korzystających z zasobów systemowych, konkurujących o dostęp do urządzeń zewnętrznych moze powodować wiele nieoczekiwanych i trudnych do rozwiązania problemów. Pierwszy z nich pojawia się już w momencie, kiedy kilka równolegle wykonywanych wątków zechce użyć tych samych pól danych. Wykonywaine watków jest cyklicznie przerywane, ale w nieustalonych i nierównych odstępach czasu. Nie wolno zatem z góry zakładać, że jakis inny wątek nie będzie wykonywany równolegle z naszym i że w czasie potrzebnym do wykonania fragmentu wątku inny nie zmodyfikuje jakichs współdzielonych danych. Problem ten może przybliżyć przedstawiony poniżej przykład:

Klasa "PutGetClass" zajmuje się zarządzaniem współdzielonego przez dwa wątki bufora. Licznik o nazwie "counter" podaje kolejną wolną pozycję w buforze. Metoda "Put()" umieszcza na wskazywanej przez licznik pozycji jego obecną wartosć i zwiększa licznik o jeden. Za pomocą metody "Get()" następuje odczytanie wskazywanej przez licznik pozycji bufora, a on sam jest następnie pomniejszany o jeden. Tak własnie chcielibysmy, aby zachowywała się ta klasa. jesli jednak za pomocą metody "Put()" zostanie wykonany zapis wartosci do bufora, ale scheduler (proces zarządzający zadaniami) przełączy następnie zadania, to stan licznika pozostanie niezmieniony. Zwiększy się o 1 dopiero w chwili, gdy procesor zacznie ponownie wykonywać wątek. Jesli metoda "Put()" umieszczać będzie w buforze zamiast wartosci licznika przekazywany argument, to klasa ta będzie odpowiadać normalnej pamięci FIFO. Jednakże zapisywanie stanu licznika wydatnie pomaga w zobrazowaniu błędów prrzyporządkowania.

Klasa "PutGetUser" definiuje wątki, korzystające intensywnie z operacji zapisu ("Put()") oraz odczytu ("Get()") do bufora. Pętla "for" ma za zadanie symulację wykonywania w międzyczasie innego, krótkiego programu.

class PutGetClass
  {
   static int counter = 0;
   static int buffer[] = new int[32];

   static synchronized void Put()
   // static void Put()
     {
      buffer[counter] = counter;
      ++counter;
     }

   static synchronized int Get()
   // static int Get()
     {
      --counter;
      if (buffer[counter] != counter)
        System.out.println("Błąd przypisania !! "+buffer[counter]+" "+counter);
      return (buffer[counter]);
     }
  }

class PutGetUser extends Thread
  {
   public void run()
     {
      while (true)
        {
         PutGetClass.Put();
         int i;
         for (i=0; i<10; ++i);
         PutGetClass.Get();
        }
     }
  }

public class Demo
  {
   public static void main (String args[])
     {
      new PutGetUser().start();
      new PutGetUser().start();
     }
  }

Jesli klasa "PutGetClass" zostanie zostanie wykorzystana w programie jednowątowym, wszystko będzie przebiegało zgodnie z oczekiwaniami i na ekranie nie pojawi się nic niespodziewanego. Jednak w programie wielowątkowym po jakims czasie ujrzelibymy serię komunikatów o blędach.

Rozwiązaniem powstałego problemu jest "synchronizacja". W momencie wywołania krytycznej metody i uruchomienia procedur, jakie ona zawiera, niedozwolone jest przełączanie na inny wątek, który nawet przypadkowo miałby ją również wywołać. Java wyposarzona jest w mechanizm, który w bardzo dokłądny sposób sytuację taką potrafi rozpoznać. Z tego względu wszystkie metody, które stanowią podobne zagrożenie, Poprzedzone zostają słowem kluczowym "synchronized". Jeżeli teraz wspomniana metoda zostanie wywołana przez którys z wątków, jest automatycznie zablokowana dla innych wątków. Dostęp do niej odblokowywany jest dopiero w momencie, kiedy poprzedni wątek przestaje z niej korzystać. Jeżeli w czasie, kiedy dana metoda zablokowana jest przez jeden wątek, a jakis inny spróbuje ją wywołać, to zostaje przeniesiony w stan oczekiwania - do czasu zwolnienia dostępu do potrzebnej metody. Zarządzaniem blokowania i zwalniania dostępu do metod zajmują się tzw. "monitory". W powyższym przykładzie obie metody: "Get()" oraz "Put()" powinny być zadeklarowane jako synchronizowane.

Oczywistym jest chyba fakt, że opisane powyżej problemy będą występować raczej rzadko.Błędy tego typu są też dosć trudne do zlokalizowania. W przypadku wspólnych obszarów danych, używanych przez więcej niz tylko jeden wątek, powinno się więc postępować dosć ostrożnie (tzn. definiować jako "synchronized" wszystkie metody, które mogą się znaleźć w opisanej powyżej sytuacji) lub unikać ich - w miarę możliwosci.

Synchronizacja nie jest niestety srodkiem doskonałym - kryje ona w sobie niebezpieczeństwo wzajemnego blokowania metod. Jesli jeden wątek zablokuje metodę (nazwijmy ją A), a wykonywany współbierznie wątek inną (o nazwie B), to jesli metoda B (w danej chwili zablokowana) wywoływana jest przez metodę A, a jesli jednoczesnie metoda A próbuje wywołać B, to dochodzi to tzw. zakleszczenia. W takiej sytuacji bowiem oba wątki czekają na siebie nawzajem, czyli na zakończenie korzystania z danej metody przez "swojego przeciwnika". Ponieważ jednak nie dojdzie do tego nigdy (zakończenie metody A wymaga wykonania zablokowanej metody B, a odblokowanie B warunkowane jest wczesniejszym zakończeniem A), program wpada w sytuację bez wyjscia, okreslanej z angielska jako "deadlock". Praktycznie istnieje tylko jedna możliwosć powstawania tego typu sytuacji: należy unikać jak ognia definiowania metod jako "synchronized", korzystających z innych tego typu metod.

Stosowanie metod "synchronized" jest de facto wykroczeniem przeciwko wielozadaniowosci - silnie faworyzuje wykonujący taką metodę wątek. Rozsądne ograniczenie definiowania metod synchronizowanych do niezbędnego minimum pozwala uniknąć faworyzowania któregos z wątków i ustrzec się przed niebezpieczeństwem zakleszczeń. Dlatego też nie powinny być implementowane jako "synchronized"na przykład obliczenia, które zajmują wiele czasu. Ponadto z wątków powinno się robić użytek jedynie wówczas, kiedy faktycznie konieczne jest zastosowanie równoległego wykonywania kodu programu. Sytuacja ma miejsce jedynie wówczas, gdy każdy wątek ma do wykonania scisle okreslone zadanie -wówczas z reguły nie występuje tez i ich wzajemna zależnosć.