Threads #2

Manage and control thread lifecycle.

Z naszego punktu widzenia wątek może chodzić… albo może nie chodzić. W rzeczywistości jednak, każdy wątek może się znaleźć w jednym z aż siedmiu stanów. W tym artykule zajmiemy się opisem tych stanów i możliwych przejść między nimi.


Na Skróty:

Thread [API]

Repozytorium z przykładami [GitHub]


Cykl życia wątku

Poniższy rysunek obrazuje kolejne etapy, przez które może przejść (czasem raz, czasem wielokrotnie) każdy wątek.

threadsLifCycle1
Cykl życia wątku

Kiedy utworzymy nowy obiekt klasy Thread (lub jej dziedzica), odpowiadający jej wątek jest w statusie „NEW”. W tym momencie obiekt wątku już istnieje, ale sam wątek nie został jeszcze uruchomiony. Aby go uruchomić, wywołujemy metodę start(), która fizycznie powołuje do życia nowy wątek i nadaje mu status „READY”, to znaczy, że wątek jest gotowy do pracy i oczekuje na przydział procesora. Jeżeli tzw. „Thread Scheduler” – szczerze mówiąc, nie wiem jak to ładnie przetłumaczyć na polski, przydzieli wątkowi procesor, wątek natychmiast przechodzi w stan „RUNNING” i wykonuje swoją metodę run(). W najprostszym scenariuszu, metoda run() wykona się bez przeszkód, od razu w całości. W takim przypadku wątek przechodzi w status „TERMINATED”.

Wątek już wtedy nie istnieje, ale istnieje obiekt klasy Thread (lub pochodnej), z którego uruchomiliśmy wątek. I możemy odpytać ten obiekt o status wątku.

Załóżmy jednak bardziej skomplikowany scenariusz. Nasz wątek jest w statusie „RUNNING”. Istnieje kilka sposobów, aby spowolnić lub uniemożliwić całkowite wykonanie metody run(). Możemy naszemu wątkowi nakazać aby wstrzymał swoją pracę na określony czas lub do momentu, aż inny wątek każe mu kontynuować- w takim przypadku wątek znajdzie się w jednym ze stanów „WAITING” lub „TIMED WAITING”. W innym przypadku może się okazać, że nasz wątek potrzebuje dostępu do zasobów, które są zablokowane przez pracę innych wątków. Wtedy wątek przechodzi do stanu „BLOCKED” i czeka na przyznanie dostępu do potrzebnych zasobów.

Przejście w dowolny ze stanów: „WAITING”, „TIMED WAITING”, „BLOCKED”, może się zakończyć tym, że wątek „doczeka się” tego na co czekał i znów przejdzie w stan „READY” a następnie „RUNNABLE”- kontynuując wykonywanie metody run(), ale może się też zdarzyć, że z powodu wyjątku wątek zostanie zakończony („TERMINATED”). Trzecią, najgorszą możliwością jest to, że wątek na zawsze (czyli do wymuszonego zamknięcia aplikacji) zostanie w statusie „BLOCKED”, bo np. będzie czekał na zasób, który nigdy nie zostanie mu udostępniony.

Przechodzenie pomiędzy stanami

Wiemy już przez jakie fazy może przejść wątek podczas swojego cyklu życia. Zobaczmy więc jak przejścia od jednego stanu do drugiego mają się do kodu, który kryje się pod spodem.

Przejścia w cyklu życia wątku
Przejścia w cyklu życia wątku

Do kontroli wątku używamy następujących metod:

start
yield
sleep
join
wait
notify
notifyAll

Metoda start()

Metoda start() sprawia, że nasz wątek przechodzi w status „RUNNABLE”- tak zbiorowo nazywane są stany „READY” i „RUNNING”. Wywołanie metody start() powoduje, że ciało metody run() zacznie się wykonywać jak tylko wątek dostanie przydział procesora.

Metodę start() możemy wywołać tylko raz. Próba ponownego wywołania tej metody zakończy się wyjątkiem IllegalThreadStateException.

Poniżej znajduje się kod klasy, która jest wątkiem i która tworzy swoją jedną instancję i dwa razy wywołuje na niej metodę start():

public class StartExample extends Thread{
  
  public static void main(String[] args){
    
    StartExample startExample = new StartExample();
    
    startExample.start();
    startExample.start();
    
  }
  
  public void run(){
    
    System.out.println("To be...");
    System.out.println("Or not to be..");
  }

}

A to dwa z kilku możliwych wyników uruchomienia tego kodu:

To be...
Or not to be..
Exception in thread "main" java.lang.IllegalThreadStateException
    at java.lang.Thread.start(Thread.java:705)
    at com.javathehat.threads2.StartExample.main(StartExample.java:13)

lub

Exception in thread "main" To be...
Or not to be..
java.lang.IllegalThreadStateException
    at java.lang.Thread.start(Thread.java:705)
    at com.javathehat.threads2.StartExample.main(StartExample.java:13)

Jak widać, czasem, zanim maszyna Javy wywoła drugi raz metodę start(), może się zdążyć wykonać całe ciało metody run(). Nie uchroni nas to jednak przed wyjątkiem.

Metoda yield()

Działanie tej metody jest specyficzne. Najczęściej z punktu widzenia użytkownika, nie robi ona dosłownie nic. Wołając metodę yield() PROSIMY Thread Scheduler, aby ROZWAŻYŁ zwolnienie zasobów wykorzystywanych przez ten wątek (zabranie wątkowi przydziału procesora, co czasowo wstrzyma wykonywanie metody run() ). Nie mamy jednak absolutnie żadnego wpływu na to, co Thread Scheduler zrobi w rzeczywistości.

Ze względu na nieprzewidywalność skutków wywołania metody yield() nie potrafię przytoczyć sensownego przykładu, który obrazowałby jej działanie. Chciałbym tylko zwrócić uwagę, że możemy ją wywołać w dowolnym miejscu kodu, nawet jeśli nasza klasa nie jest wątkiem, ponieważ jest to metoda statyczna.

Thread.yield();

Metoda sleep(int milis)

Metoda sleep() jak łatwo odgadnąć po nazwie, „usypia” wątek na podaną w argumencie ilość milisekund. Wstrzymuje to wykonywanie ciała metody run(). Musimy jednak być świadomi dwóch bardzo ważnych niuansów.

Metoda sleep() nie zwalnia zasobów, które zajął wątek. To znaczy, że jeżeli zablokujemy jakiś współdzielony obiekt (pozyskamy jego monitor, o tym później), a następnie wywołamy sleep() bez odblokowywania zajętego obiektu, to nie będzie on dostępny dla innych wątków przez cały czas uśpienia wątku.

Podana w argumencie liczba milisekund to MINIMALNY czas, przez jaki wątek będzie uśpiony. Mamy zagwarantowane, że wątek nie wznowi pracy przed upływem tego czasu, ale nie mamy żadnej gwarancji, że wznowienie pracy nastąpi dokładnie po podanej w argumencie liczbie milisekund. Thread Scheduler może „wybudzić” wątek później.

Zobaczmy kod wątku, który jedenaście razy woła sleep na 1000 milisekund i mierzy ile czasu faktycznie trwał.

public class SleepExample extends Thread{
  
  public static void main(String[] args){
    
    SleepExample sleepExample = new SleepExample();
    
    sleepExample.start();
    
  }
  
  public void run(){
    
    for(int i = 0; i <=10; i++){
      long start = System.currentTimeMillis();
      try {
        sleep(1000);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
      
      System.out.println("sleep trwal: " + new Long(System.currentTimeMillis() - start));
    }
  }

}

Oto jeden z możliwych wyników wykonania kodu:

sleep trwal: 1000
sleep trwal: 1000
sleep trwal: 1000
sleep trwal: 1000
sleep trwal: 1001
sleep trwal: 1000
sleep trwal: 1000
sleep trwal: 1000
sleep trwal: 1000
sleep trwal: 1001
sleep trwal: 1000

Nawet w przypadku, kiedy mamy do czynienia tylko z dwoma wątkami (ten oraz wątek main), mogą się zdarzyć „opoźnienia”. Choć w tym przypadku trwają one jedynie jedną milisekundę, to przy dużej ilości wątków i silnym obłożeniu procesora, opóźnienia te mogą się wydłużyć.

Metoda join(), join(long milis)

Jest to metoda używana do synchronizacji w czasie z innymi wątkami. Stosujemy ją, gdy chcemy aby w trakcie wykonywania metody run() w jednym z wątków zatrzymać przetwarzanie do czasu, aż inny wątek zakończy swoją metodę run(). Musimy w tym celu wywołać join() na obiekcie wątku, na który chcemy czekać. Jeżeli istnieje ryzyko, że wątek, na który czekamy, może się nie wykonać, lub po prostu możemy na niego czekać tylko przez określony czas, to w argumencie metody join() podajemy ile maksymalnie milisekund nasz wątek będzie czekał na zakończenie tamtego wątku. Po upływie tego czasu, nasz wątek będzie kontynuował przetwarzanie, niezależnie od tego czy tamten wątek się zakończył czy nie.

Rozważmy taki kod. Mamy tutaj klasę wątku, która w konstruktorze może przyjąć inny wątek. Jeśli inny wątek zostanie podany w konstruktorze, to obiekt naszego wątku zawsze będzie czekał, aż tamten zakończy swoją pracę.

public class JoinExample extends Thread{
  
  private String msg;
  private Thread toJoin;
  
  public static void main(String[] args){
    
    JoinExample toJoinThread = new JoinExample("Ja zawsze jestem pierwszy");
    JoinExample joiningThread = new JoinExample("A ja muszę czekać aż on skonczy : ( ", toJoinThread);
    
    joiningThread.start();
    toJoinThread.start();
    
  }
  
  public JoinExample(String msg){
    this.msg = msg;
  }
  
  public JoinExample(String msg, Thread toJoin){
    this.msg = msg;
    this.toJoin = toJoin;
  }
  
  public void run(){
    
    if(toJoin != null){
      
      try {
        //Czekamy aż wątek toJoin się zakończy
        toJoin.join();
        
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    }
    
    System.out.println(msg);
  }

}

Istnieje tylko jeden możliwy wynik działania tego kodu:

Ja zawsze jestem pierwszy
A ja muszę czekać aż on skonczy :(

Niezależnie od tego ile razy uruchomimy kod, nie ma możliwości, aby te linie tekstu były wypisane w odwrotnej kolejności.

Metody wait, wait(int), notify() oraz notifyAll()

Opiszę te metody w jednym podpunkcie, ponieważ są ze sobą ściśle powiązane, a na dodatek nie są to metody klasy Thread.

Metody wait, wait(int), notify() oraz notifyAll() są zdefiniowane w klasie Object, a nie w klasie Thread. A ponieważ wszystkie obiekty w Javie niejawnie dziedziczą po klasie Object, wszystkie mają dostęp do tych metod.

W dużym skrócie za ich pomocą możemy kazać wątkowi wstrzymać pracę, do momentu, aż inny wątek powie, że praca ta może być kontynuowana. Brzmi to podobnie to opisu działania metody join(), ale w tamtym przypadku praca będzie kontynuowana tylko jeśli drugi wątek się definitywnie zakończy. W przypadku metod wait() oraz notify() wątki mogą chodzić w nieskończoność, wielokrotnie dając sobie sygnał do wstrzymania lub wznowienia przetwarzania.

Książkowym przykładem jest tutaj problem producenta i konsumenta. Konsument sprawdza czy dostępne są materiały do skonsumowania, jeśli materiałów zabrakło, nakazuje on producentowi wznowienie produkcji (metoda producent.notify() ), a sam wstrzymuje swoją pracę czekając na sygnał, że materiały zostały dostarczone (metoda konsument.wait() ). Producent otrzymawszy sygnał, aby wznowić produkcję (metoda producent.notify() ) dostarcza produkt, daje o tym znać konsumentowi (konsument.notify() ) oraz sam czeka na następne zamówienie (producent.wait() ).

Metody te wykorzystują również tzw. monitory i są ważną częścią zagadnienia synchronizacji wątków- a to jest temat oddzielnego wymagania, a co za tym idzie następnego artykułu. Dlatego też przykładowy kod dla problemu producenta i konsumenta zaprezentuję w następnym artykule.