Threads #4

Identify code that may not execute correctly in a multi-threaded environment.

Egzamin 1Z0-804 wymaga od nas umiejętności rozpoznawania kodu, który choć jest poprawny i kompiluje się, to w aplikacji wielowątkowej może działać niezgodnie z oczekiwaniami. W poprzednim artykule już otarłem się o ten temat. Teraz czas na jego rozwinięcie.


Na skróty:

Object [API]
Thread [API]

Zmienne [Opis]
Metody [Opis]
Powiadomienia [Opis]
Blokady [Opis]
Happens-before [Opis]

Repozytorium z przykładami [GitHub]


Zmienne

W poszukiwaniu słabych punktów naszej aplikacji wielowątkowej, w pierwszej kolejności powinniśmy przeanalizować zmienne, z którymi pracują wątki. Jak już wspomniałem w trzecim artykule z tej serii, każdy wątek ma swoją lokalną pamięć, w której przechowuje zmienne lokalne, argumenty metod, wartości zwracane przez metody oraz wyrzucone w trakcie działania wątku wyjątki. Są to więc rzeczy, które możemy zignorować- nigdy nie będą współdzielone między wieloma wątkami.

Inaczej ma się sprawa z konkretnymi instancjami obiektów, które są używane przez wiele wątków. Newralgiczne są zarówno „zwykłe” zmienne (ang. instance viariables) jak i zmienne statyczne tych obiektów.

public class PossibleSource {
  public static int globalCounter = 0; //Zasob wrazliwy w aplikacji wielowatkowej
  private int localCounter = 0; //Zasob wrazliwy w aplikacji wielowatkowej
  public String name = "Name"; //String jest obiektem final, nie szkodzi mu wielowatkowosc
  public static final int VALUE = 101; //Zadeklarowalismy int jako final, nie szkodzi mu wielowatkowosc

  public void addToLocalCounter(int value){
    this.localCounter += value;
  }
  
  public int calculate(int value){
    int result = value * localCounter;
    return result; //Zmienna result widoczne jest tylko w obrebie jednego watku, nie szkodzi jej wielowatkowosc
  }
  
  public void doSomething() throws Exception{
    throw new Exception("funny exception");//Exception jest final, nie grozi mu wielowotkowosc
  }
}

Jeżeli przewidujemy, że w naszym systemie, wiele wątków będzie mogło jednocześnie zapisywać i odczytywać z tych zmiennych, to musimy te działania zabezpieczyć jednym z dostępnych mechanizmów synchronizacji. Tym samym dochodzimy do następnego etapu zabezpieczenia aplikacji przed niepożądanymi skutkami wielowątkowości.

Metody

Jeżeli piszemy metodę, która modyfikuje dane używane przez wiele wątków, musimy ją zabezpieczyć słowem kluczowym synchronized lub za pomocą bloku synchronizowanego zabezpieczyć tylko newralgiczny fragment metody.

Naturalnie zająć się trzeba w ten sposób każdą metodą zmieniającą dane. Nie chcemy, aby dwa wątki jednocześnie próbowały nadać różną wartość tej samej zmiennej. Pal licho, jeśli jest to sztywne przypisanie stałej wartości, ale co jeśli przypisywana wartość zależy od aktualnej wartości zmiennej, która zostanie zmieniona przez jeden wątek, w trakcie, gdy drugi będzie jeszcze przeprowadzał obliczenia, na podstawie już nie aktualnej wartości?

Odsyłam tutaj do przykładów z poprzedniego artykułu.

Mniej oczywisty może być fakt, że synchronizację powinniśmy zastosować również w metodach, które tylko odczytują wartość współdzielonej zmiennej.

Przykładem tutaj może być system rezerwacji biletów. Na seans filmowy został ostatni bilet. Pierwszy użytkownik loguje się do systemu i chce kupić bilet. System sprawdza, że zostało jeszcze jedno miejsce i informuje o tym użytkownika, ale ten pozostawił otwarty formularz i poszedł wyłączyć gotującą się wodę. W tym samym momencie ktoś inny zalogował się do systemu, sprawdził, że jest jest jeszcze jeden bilet i od razu go kupił. Pierwszy użytkownik, wraca i wznawia operację z miejsca, w którym skończył, czyli wiedząc, że może kupić ostatni bilet. Źle zabezpieczony system nie wie, że sam przed chwilą sprzedał już ten bilet i drugi raz sprzedaje go kolejnemu użytkownikowi.

Powiadomienia

Czas na obiecany problem producenta i konsumenta.

public class Producer extends Thread{
  private List; warehouse;
  private Consumer consumer;
  
  public Producer(List warehouse){
    this.warehouse = warehouse;
  }
  
  public void setConsumer(Consumer consumer){
    this.consumer=consumer;
  }
  
  public void run(){
    while(true){
      if(warehouse.isEmpty()){
        try {
          Thread.sleep(1000);
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
        warehouse.add("item");
        System.out.println("producing item");
      }else{
        synchronized(consumer){
          consumer.notify();
        }
        synchronized(this){
          try {
            wait();
          } catch (InterruptedException e) {
            e.printStackTrace();
          }
        }
      }
    }
  }
}
public class Consumer extends Thread {
  private List warehouse;
  private Producer producer;
  
  public Consumer(List warehouse){
    this.warehouse = warehouse;
  }
  public void setProducer(Producer producer){
    this.producer = producer;
  }
  
  public void run(){
    while(true){
      if(!warehouse.isEmpty()){
        try {
          Thread.sleep(1000);
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
        warehouse.remove(0);
        System.out.println("consuming item");
      }else{
        synchronized(producer){
          producer.notify();
        }
        synchronized(this){
          try {
            wait();
          } catch (InterruptedException e) {
            e.printStackTrace();
          }
        }
      }
    }
  }
}
public class Main {
  public static void main(String[] args) {

    List warehouse = new ArrayList();
    Producer producer = new Producer(warehouse);
    Consumer consumer = new Consumer(warehouse);
    producer.setConsumer(consumer);
    consumer.setProducer(producer);
    
    producer.start();
    consumer.start();
  }
}

Powyżej widzimy jedną z możliwych implementacji tego problemu. Dwa wątki: producent oraz konsument, współdzielą jeden zasób: magazyn (w tym przypadku lista Stringów).

Oba wątki chodzą w nieskończoność (warunek while(true) ), przy czym konsument cały czas chce pobierać Stringa z listy, a producent dodawać nowego Stringa do listy. Problem polega na tym, że gdybyśmy po prostu zabezpieczyli dostęp do listy blokiem lub metodą synchronizowaną, to osiągnęlibyśmy jedynie to, że na liście jednocześnie będzie mógł operować tylko jeden wątek. W takim przypadku, w zależności od tego, który wątek pierwszy zablokowałby monitor, to albo mielibyśmy producenta zapełniającego listę Stringami, póki nie skończy się pamięć lub konsumenta, który od razu zakończy swój żywot rzucając wyjątkiem NullPointerException, ponieważ lista będzie pusta.

Musimy zmusić oba wątki do współpracy i wzajemnego powiadamiania się. Kiedy konsument widzi, ze magazyn jest pusty- powiadamia o tym producenta i sam wstrzymuje swoją pracę. Producent, otrzymawszy powiadomienie produkuje przedmiot, dostarcza do magazynu i informuje o tym konsumenta, a sam przechodzi w stan uśpienia, czekając na kolejne zamówienie. Służą do tego właśnie metody wait() oraz notify().

Musimy pamiętać, że aby na danym obiekcie wywołać metodę wait() lub notify() wątek musi posiadać monitor tego obiektu. To znaczy, że zawsze używamy tych metod w metodzie lub bloku synchronizowanym! W przeciwnym wypadku zostaniemy potraktowani sympatycznym wyjątkiem IllegalMonitorStateException.

Do dyspozycji mamy jeszcze metodę notifyAll(). Powiadamia ona JEDEN z wątków czekających na dany obiekt.

Na przykład, gdybyśmy mieli wielu konsumentów konkurujących o dostarczony do magazynu produkt, metoda notifyAll() obudziła by tylko jednego z nich, ale nie mamy wpływu na to, którego konkretnie.

Musimy też zwracać uwagę na kolejność w jakiej wywołujemy metody wait() i notify(). Jeśli producent zacząłby czekać na następne zamówienie, zanim powiadomiłby konsumenta o zrealizowanym poprzednim zamówieniu, oba wątki zostałyby w statusie WAITING już „na zawsze”.

Na koniec pierwsze kilka linii wyniku z uruchomienia naszego przykładu:

producing item
consuming item
producing item
consuming item
producing item

Linie te będą się powtarzać na przemian co sekundę, aż do zamknięcia aplikacji.

Blokady i zagłodzenie

Kiedy wiele wątków konkuruje wzajemnie o wiele zasobów pojawiają się przed nami kolejne zagrożenia, które musimy umieć rozpoznawać.

Deadlock

Bardzo popularnym zadaniem dotyczącym synchronizacji procesów jest tak zwany problem pięciu filozofów. Przeanalizujmy sobie jego mocno uproszczoną wersję. Załóżmy, że małżeństwo, które akurat przeżywa ciche dni zasiada do obiadu. Niestety nikomu nie chciało się umyć naczyń i został tylko jeden czysty talerz i jedna łyżka. Mąż natychmiast sięga po talerz, żona zabiera łyżkę. Gdyby ze sobą rozmawiali, mogliby się dogadać, że najpierw zje jedno, a potem drugie z nich (lub ewentualnie ktoś umyje naczynia : ). Są jednak do tego stopnia na siebie obrażeni, że ani myślą o kompromisie i każde siedzi głodne ze swoim elementem zastawy. Przykład może przerysowany, ale tak właśnie wygląda to w przypadku wątków, które wzajemnie nawet nie wiedzą o swoim istnieniu, a korzystają z tych samych zasobów.

Spójrzmy na kod:

public class Person extends Thread{

    private String obj1;
    private String obj2;
    
    public Person(String obj1, String obj2,String name){
        super(name);
        this.obj1 = obj1; 
        this.obj2 = obj2; 
    }
    
    public void run(){
        synchronized(obj1){
            System.out.println(String.format("%s zabiera %s", this.getName(), obj1));
            synchronized(obj2){
                System.out.println(String.format("%s zabiera %s", this.getName(), obj2));
                System.out.println(String.format("%s je obiad", this.getName()));
            }
        }
    }
}
public class Main {
  public static void main(String[] args) {
    String spoon = "lyzke";
    String plate = "talerz";
    
    Person husband = new Person(spoon,plate,"Maz");
    Person wife = new Person(plate,spoon,"Zona");
    
    husband.start();
    wife.start();
  }
}

Wątek Person najpierw blokuje monitor jednego zasobu, a potem cały czas trzymając ten monitor, próbuje zablokować monitor drugiego zasobu. Utworzyliśmy dwa takie wątki, podając każdemu zasoby w innej kolejności. Oto wynik działania kodu:

Maz zabiera lyzke
Zona zabiera talerz

Po wypisaniu tych dwóch linii, aplikacja nie kończy się, oba wątki cały czas czekają, aż zwolni się drugi z zasobów. Niestety przy obecnej implementacji nigdy się nie doczekają.

Starvation

Zagłodzenie trochę przypomina deadlock. Kiedy mamy bardzo dużo wątków korzystających z tych samych zasobów, może się okazać, że któryś z nich nigdy nie zdoła zablokować monitora pożądanego zasobu, bo będzie on ciągle blokowany przez konkurencyjne wątki. Takie ryzyko wzrasta, kiedy wątki mają różne priorytety i długi czas pracy z jednym zasobem, ale ostatecznie wybór wątku, który zostanie dopuszczony do monitora jest dla nas nie do przewidzenia, dlatego sami powinniśmy zadbać o to, aby każdy z wątków miał równą szansę na skorzystanie z zasobu.

Livelock

Jest to dość specyficzny przypadek i w sumie nie mam pomysłu na programistyczny przykład. Livelock jest wtedy, gdy dwa wątki chodzą (nie są wstrzymane, na nic nie czekają, mają status RUNNING) i próbują się ze sobą skomunikować, ale z jakiś przyczyn nie mogą. Szukając przykładu w internecie natknąłem się na porównanie do dwojga ludzi, którzy wchodzą sobie w drogę i chcąc się ominąć ustępują w tą samą stronę, przez co dalej się blokują.

Relacja „Happens-before”

Już wcześniej wspominałem o tym mechanizmie. W Javie, w aplikacji wielowątkowej nie możemy ze stu procentową pewnością wskazać kolejności w jakiej będą się wykonywać operacje. Czasem uda się tak, że na raz wykona się cała metoda run() jednego wątku, a po niej cała metoda run() drugiego wątku. Częściej jednak, poszczególne kroki tych metod będą się miedzy sobą przeplatać, zwłaszcza jeśli nie będą korzystać z monitorów. Możemy jednak wskazać pewne operacje, które na pewno wykonają się wcześniej niż inne. Przykładowo:

  • W metodzie run() jednego wątku na pewno pierwsza linia kodu wykona się wcześniej niż ostatnia (itd. z pozostałymi liniami).
  • Metoda run() nigdy nie wykona się przed metodą start() tego samego wątku.
  • Zapis do zmiennej volatile zawsze będzie przed odczytem z tej zmiennej.
  • Monitor może zostać zablokowany tylko po jego odblokowaniu.
  • Wątek nie wykona żadnej operacji zapisanej po metodzie join(), póki nie zakończy się wątek, na którym te join() wywołano.

Brzmi to wszytko jak oczywista oczywistość, ale to ważne, żebyśmy na egzaminie umieli odróżnić prawdopodobny od pewnego wyniku wykonania kodu. Na przykład jeśli wywołamy start() na dwóch wątkach to PRAWDOPODOBNIE najpierw skończy się metoda run() pierwszego wątku. Ale jeśli w drugim wątku użyjemy metody join() na pierwszym wątku to NA PEWNO najpierw skończy się metoda run() pierwszego wątku.

Tym artykułem kończymy serię o wątkach. Gorąco zachęcam do powtórzenia sobie całego materiału i poćwiczenia z przykładami, które dostępne są na GitHubie.