Wielowątkowość w Java – stany wątków

To już trzeci artykuł z serii o wielowątkowości w Java. Jeśli dopiero zaczynasz przygodę z wątkami to zacznij od pierwszego artykułu. Następnie przejdź do metody tworzenia wątków. A w tym artykule poznasz mechanizm przerwań w wątkach.

Przykłady z tego artykułu możesz zobaczyć na GitHubie: https://github.com/Finanteq/java-multithreading-examples

Stan wątku w Javie

Jak już wiesz, wątki są widoczne przez system i działają podobnie jak podprocesy. Głównym procesem, do którego będą przypisane wątki, jest proces java, czyli maszyna wirtualna Javy (JVM).

Każdy wątek uruchomiony w programie Javy działa „pod skrzydłami” JVM. Z tego powodu JVM wpływa na wątki, tzn. maszyna wirtualna Javy kontroluje stan wątków.

Obecnie Java (w wersji 17) obsługuje poniższe stany wątków:

  • NEW – wątek, który jeszcze nie został uruchomiony,
  • RUNNABLE – wątek, który został uruchomiony i obecnie wykonuje instrukcje (czyli wątek, który został uruchomiony za pomocą thread.start()),
  • BLOCKED – wątek, który został zablokowany (zatrzymany) i czeka, aż monitor pozwoli mu kontynuować działanie,
  • WAITING – wątek, który czeka, aż inny wątek pozwoli mu na kontynuowanie działania,
  • TIMED_WAITING – wątek, który czeka na inny wątek, ale tylko przez ustalony czas,
  • TERMINATED – wątek, który zakończył działanie.

Powyższe stany wątków są charakterystyczne dla JVM i nie mają nic wspólnego z systemem operacyjnym. System operacyjny może zatrzymać lub zablokować wątek, ale dla Javy ten wątek będzie miał nadal stan RUNNABLE. Te stany opisują tylko to, co dzieje się z wątkami wewnątrz maszyny wirtualnej.

Badanie stanu wątków

Aktualny stan wątku można pobrać za pomocą metody thread.getState().

Do pokazania kilku możliwych stanów wątku przygotowałem prosty przykład, który pokazuje kilka typowych sytuacji.

public class StateInspection {

    public static void main(String[] args) throws Exception {
        Thread thread = new Thread(StateInspection::someJob);
        System.out.println(thread.getState());

        thread.start();
        System.out.println(thread.getState());

        Thread.sleep(100); // wait until while loop ends
        System.out.println(thread.getState());

        thread.join(); // wait until thread finishes
        System.out.println(thread.getState());
    }

    private static void someJob() {
        // Thread is RUNNABLE, let it run for 100 ms
        long start = System.currentTimeMillis();
        long end = start + 100;
        while (System.currentTimeMillis() < end) {
            // pretend we're doing something for 100 ms
        }

        // let it sleep
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            // do nothing
        }
    }
}

W stworzonym wątku uruchamiam zadanie, które zdefiniowałem w metodzie someJob. Wątek po uruchomieniu będzie wykonywał pętlę while przez ok. 100 ms. To znaczy – wątek będzie zajęty powtarzaniem instrukcji, aż System.currentTimeMillis() pokaże upływ 100 ms od czasu rozpoczęcia.

Następnie „usypiam” wątek na 100 ms (za pomocą Thread.sleep(100)). Metoda sleep może wyrzucić oznaczony wyjątek (ang. checked exception) InterruptedException, który omówię później.

Uruchomienie programu daje następujący rezultat w konsoli:

NEW
RUNNABLE
TIMED_WAITING
TERMINATED

Wątek jest w stanie:

  • NEW, kiedy stworzymy instancję Thread, ale nie wywołamy metody start(),
  • RUNNABLE, kiedy jest zajęty wykonywaniem instrukcji – w tym przypadku pętli while,
  • TIMED_WAITING kiedy został uśpiony za pomocą Thread.sleep(),
  • TERMINATED po zakończeniu działania wątku.

Przerwanie

Przerwanie (ang. interrupt) to mechanizm typowy dla JVM, który pozwala „wybudzić” wątek, gdy ten jest zajęty oczekiwaniem (WAITING lub TIMED_WAITING). Jest to sygnał do drugiego wątku, aby przerwał to, co robi i zrobił coś innego.

Wywołanie przerwania powoduje wystąpienie wyjątku InterruptedException, który nie jest błędem – jest informacją o tym, że wątek powinien zrobić coś innego niż robił dotychczas. Przy okazji niesie ze sobą informację, że obecna czynność została zakończona wcześniej.

InterruptedException wystąpi tylko w przypadku operacji, które wprowadzają wątek w stan WAITING lub TIMED_WAITING. W innym wypadku wątek może zignorować przerwanie.

import java.time.LocalDateTime;

public class InterruptExample {

    public static void main(String[] args) throws Exception {
        Thread thread = new Thread(InterruptExample::sleepWell);
        thread.start();
        System.out.println(thread.getState());
        thread.interrupt();
    }

    private static void sleepWell() {
        try {
            System.out.println("Sleep started at " + LocalDateTime.now());
            Thread.sleep(100);
        } catch (InterruptedException e) {
            System.out.println("Thread was interrupted - sleep was shorter than 100 ms.");
        } finally {
            System.out.println("Sleep finished at " + LocalDateTime.now());
        }
    }
}

Powyższy program pokazuje, jak metoda interrupt() „przerywa” sen wątku thread.

Po uruchomieniu programu w terminalu pojawi się tekst podobny do poniższego:

RUNNABLE
Sleep started at 2023-06-21T14:02:48.689868
Thread was interrupted - sleep was shorter than 100 ms.
Sleep finished at 2023-06-21T14:02:48.692371

Jak widzisz, różnica czasu jest mniejsza niż 100 ms (ok. 3 ms). Spróbuj zakomentować linijkę 8 (thread.interrupt()) i zobacz, jak zmieni się zachowanie programu.

Długofalowe efekty przerwania

Weźmy pod uwagę następujący przykład – jaki powinien być rezultat tego programu?

import java.time.LocalDateTime;

public class EarlyInterruptExample {

    public static void main(String[] args) throws Exception {
        Thread thread = new Thread(EarlyInterruptExample::doSomethingAndSleep);
        thread.start();
        System.out.println(thread.getState());
        thread.interrupt(); // interrupt when it's busy

        Thread.sleep(120);
        System.out.println(thread.getState());
        thread.interrupt(); // interrupt when it's sleeping
    }

    private static void doSomethingAndSleep() {
        long start = System.currentTimeMillis();
        long end = start + 100;
        while (System.currentTimeMillis() < end) {
            // I'm busy!
        }

        try {
            System.out.println("Sleep started at " + LocalDateTime.now());
            Thread.sleep(100);
        } catch (InterruptedException e) {
            System.out.println("Thread was interrupted - sleep was shorter than 100 ms.");
        } finally {
            System.out.println("Sleep finished at " + LocalDateTime.now());
        }
    }
}

Przerwania działają tylko wtedy, gdy wątek jest w stanie WAITING lub TIMED_WAITING. Intuicyjnie, program powinien zignorować pierwszą instrukcję interrupt(), bo wątek jest w niekompatybilnym stanie (RUNNABLE).

Po uruchomieniu programu pojawi się tekst podobny do poniższego:

RUNNABLE
Sleep started at 2023-06-22T10:43:17.529221
Thread was interrupted - sleep was shorter than 100 ms.
Sleep finished at 2023-06-22T10:43:17.532850
TERMINATED

Pomimo, że wątek zignorował przerwanie za pierwszym razem, to wątek nie „zapomniał” o przerwaniu. Wątek odebrał informację o przerwaniu i gdy tylko wszedł w stan TIMED_WAITING, to sygnał przerwania został obsłużony.

Dzięki temu, że wątek zapamiętuje, że odebrał przerwanie, to możesz być spokojny, że obsłuży je kiedy przejdzie w stan TIME_WAITING lub WAITING. Nie musisz wymyślać sposobów na wysłanie przerwania w odpowiedniej chwili. Próba odgadnięcia, co drugi wątek robi w danej chwili jest naprawdę ciężka, a nawet niemożliwa.

Być może ta obsługa jest mniej intuicyjna, ale za to bardziej wygodna. Wysyłamy sygnał przerwania raz i nie musimy „celować” w okienko czasowe – wątek obsłuży sygnał przy pierwszej okazji.

Sprawdzanie, czy nastąpiło przerwanie

Java pozwala na sprawdzenie, czy do wątku zostało wysłane przerwanie. Do tego celu służy metoda Thread.interrupted(). Zwraca ona flagę (wartość boolean), która wskazuje na to, czy w danym momencie obecny wątek ma oczekujące przerwanie.

Najczęściej w Javie przerwanie pojawia się w kontekście takich metod jak sleep(), które sygnalizują przerwania wyjątkiem InterruptedException.

Dzięki metodzie Thread.interrupted() możesz napisać własną funkcję reagującą na przerwanie, nawet jeśli nie używasz metod, które wyrzucą wyjątek InterruptedException.

Ta metoda umożliwia napisanie programu, który da się „pospieszyć” za pomocą przerwania, pomimo, że ten program nie używa metody sleep() (i stanu TIMED_WAITING). Zamiast polegać na wyjątku, wystarczy, że dodasz logikę opierającą się na fladze Thread.interrupted().

Uwaga: Jak możesz się domyślić, efektem ubocznym tej metody jest to, że przerwanie zostanie obsłużone. Wywołanie tej metody robi dwie rzeczy – sprawdza, czy wątek odebrał przerwanie i jeśli tak, to usuwa to przerwanie.

Poniższy program pokazuje, jak działa ta metoda w praktyce:

import java.time.LocalDateTime;

public class InterruptCheckExample {

    public static void main(String[] args) throws Exception {
        Thread thread = new Thread(InterruptCheckExample::doSomethingAndSleep);
        thread.start();
        System.out.println(thread.getState());
        thread.interrupt(); // interrupt when it's busy

        Thread.sleep(120);
        System.out.println(thread.getState());
        thread.interrupt(); // interrupt when it's sleeping
    }

    private static void doSomethingAndSleep() {
        long start = System.currentTimeMillis();
        long end = start + 100;
        while (System.currentTimeMillis() < end) {
            if (Thread.interrupted()) {
                System.out.println("Why do you interrupt my work?");
            }
        }

        try {
            System.out.println("Sleep started at " + LocalDateTime.now());
            Thread.sleep(100);
        } catch (InterruptedException e) {
            System.out.println("Thread was interrupted - sleep was shorter than 100 ms.");
        } finally {
            System.out.println("Sleep finished at " + LocalDateTime.now());
        }
    }
}

Efekt uruchomienia programu:

RUNNABLE
Why do you interrupt my work?
Sleep started at 2023-06-22T10:58:09.726119
TIMED_WAITING
Thread was interrupted - sleep was shorter than 100 ms.
Sleep finished at 2023-06-22T10:58:09.736254

Pytanie „Why do you interrupt my work?” jest efektem obsłużenia pierwszego przerwania w pętli. Zwróć uwagę na to, że obsługa wyjątku InterruptedException wykona się przy drugim przerwaniu.

Przykład zastosowania przerwań

Mechanizm przerwań możesz zastosować w sytuacjach, gdzie jakiś proces będzie trwał długo, ale może zostać „pospieszony”. Na przykład, gdy wątek śpi (sleep()), to możesz go wcześniej wybudzić.

Mechanizm przerwań możemy też wykorzystać do sygnalizowania, że wątek powinien wcześniej skończyć pracę. Przerwanie to tylko sygnał, więc daje szansę na to, aby wątek dokończył pracę lub „posprzątał” po swoim zadaniu.

Jako praktyczny przykład tego mechanizmu pokażę program, który nieustannie wylicza kolejne elementy ciągu Fibonacciego.

Implementacja funkcji wyliczającej elementy ciągu jest dosyć prosta, więc będziemy mogli łatwo skupić się na zrozumieniu mechanizmu przerwań w tym przykładzie.

Program po włączeniu uruchomi nowy wątek, który rozpocznie nieustanne wyliczanie kolejnych elementów ciągu. W momencie, gdy otrzyma przerwanie, to dokończy ostatnie obliczenie i pokaże czas działania.

Wątek czasami może uruchomić się z opóźnieniem kilku milisekund, dlatego czas musi być mierzony w wątku, który wykonuje obliczenia, aby zmniejszyć margines błędu. Dzięki temu, że przerwanie nie zmusza wątku do zakończenia pracy, jesteśmy w stanie wykonać pomiar po zakończeniu obliczeń.

Poniższy program w głównym wątku będzie czekał na sygnał „STOP” od użytkownika, a wtedy wyśle przerwanie do drugiego wątku, który zajmuje się obliczeniami i pomiarem czasu.

import java.util.Scanner;

public class InterruptionFibonacci {

    public static void main(String[] args) {
        var scanner = new Scanner(System.in);
        System.out.println("To stop Fibonacci sequence generator, type STOP or just S.");

        var fibThread = new Thread(InterruptionFibonacci::generateFib);
        fibThread.start();

        while (scanner.hasNextLine()) {
            String line = scanner.nextLine().toUpperCase();
            if (line.startsWith("S")) {
                System.out.println("Generator will stop after next result.");
                fibThread.interrupt();
                break;
            }
        }

    }

    private static void generateFib() {
        long start = System.currentTimeMillis();
        for (int i = 1; i < Integer.MAX_VALUE; i++) {
            if (Thread.interrupted()) {
                // don't do any more calculations
                break;
            }
            System.out.println("fib(" + i + ") = " + fib(i));
        }
        long end = System.currentTimeMillis();
        long elapsed = end - start;
        System.out.println("Thank you for using Fibonacci sequence generator.");
        System.out.println("The generator was running for " + elapsed + "ms.");
    }

    // recursive implementation of Fibonacci's sequence
    private static long fib(long n) {
        if (n <= 0) {
            return 0;
        } else if (n == 1) {
            return 1;
        } else {
            return fib(n - 1) + fib(n - 2);
        }
    }
}

Program po uruchomieniu i wpisaniu litery S (jak STOP) może wyświetlić tekst podobny do poniższego:

To stop Fibonacci sequence generator, type STOP or just S.
fib(1) = 1
fib(2) = 1
fib(3) = 2
fib(4) = 3
fib(5) = 5
fib(6) = 8
fib(7) = 13
fib(8) = 21
fib(9) = 34
fib(10) = 55
fib(11) = 89
fib(12) = 144
fib(13) = 233
fib(14) = 377
fib(15) = 610
fib(16) = 987
fib(17) = 1597
fib(18) = 2584
fib(19) = 4181
fib(20) = 6765
fib(21) = 10946
fib(22) = 17711
fib(23) = 28657
fib(24) = 46368
fib(25) = 75025
fib(26) = 121393
fib(27) = 196418
fib(28) = 317811
fib(29) = 514229
fib(30) = 832040
fib(31) = 1346269
fib(32) = 2178309
fib(33) = 3524578
fib(34) = 5702887
s
Generator will stop after next result.
fib(35) = 9227465
Thank you for using Fibonacci sequence generator.
The generator was running for 223ms.

Co dalej?

W następnym artykule w ramach praktycznego projektu krok po kroku zbudujemy razem program do „pilnowania” plików. Zobaczysz stan BLOCKED i to, jak wątki mogą na siebie wpływać oraz jakie są mechanizmy do ograniczanie negatywnych efektów takich sytuacji. Pokażę różne strategie zabezpieczania danych w pamięci, a także wbudowane w Javę klasy, które ułatwiają to zadanie.

Następny artykuł: Wielowątkowość w Java – program obserwujący pliki

Autor
Cezary Regec
Cezary Regec
Software Engineer w FINANTEQ
Chcesz dowiedzieć się więcej o wielowątkowości w Java?