Wielowątkowość w Java – program obserwujący pliki

Czwarty artykuł z serii o wielowątkowości w Java. Jeśli dopiero zaczynasz przygodę z wątkami to zacznij od pierwszego artykułu. W tym artykule połączymy wiedzę ze wszystkich poprzednich artykułów, aby napisać projekt wykorzystujący wielowątkowość.

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

Projekt – program obserwujący pliki

Zobacz praktyczny przykład jak krok po kroku zrobić program do obserwowania plików. W końcu praktyka czyni mistrza.

Poniżej zapisałem kilka wymagań, jakie ma spełniać program. Te wymagania napisałem tak, aby brzmiały podobnie jak wymagania, które możemy dostać od prawdziwego klienta. Naszym zadaniem będzie napisanie takiego programu, aby spełniał to, czego życzy sobie klient.

Zadaniem programu będzie obserwowanie, czy plik został usunięty.
Chciałbym móc wprowadzić nazwy plików, które program będzie obserwował.
Chciałbym mieć możliwość przerwania obserwacji wybranego pliku.
Obserwacja plików ma się odbywać non-stop, a w międzyczasie chciałbym móc do woli dodawać i usuwać nowe pliki z listy obserwowanych plików.
Gdy program zauważy, że plik został usunięty, chciałbym zobaczyć komunikat „File x was deleted.”. W komunikacie zamiast „x” powinna znajdować się nazwa pliku.

Pamiętaj, że jako programiści możemy wybrać dowolny sposób implementacji programu. Najważniejsze jest to, aby działał zgodnie ze zdefiniowanymi wymaganiami.

Do realizacji projektu podejdziemy krok po kroku – implementując po jednej funkcji, aż stworzymy gotowe rozwiązanie. Pamiętaj, że implementacja może wyglądać inaczej, jeśli napiszesz ją sam, ponieważ przedstawiony projekt jest tylko jednym z wielu możliwych sposobów rozwiązania tego problemu.

Pierwsze kroki – obserwacja plików

Zaczniemy od podstawowej funkcji – sprawdzenia, czy plik istnieje. Program może w kółko sprawdzać, czy plik istnieje i gdy nagle plik przestanie istnieć – oznacza to, że został usunięty.

Do sprawdzenia faktu istnienia pliku wykorzystamy klasę File, która posiada metodę exists().

Poniższy program pokazuje, jak w najprostszy sposób sprawdzić, czy plik o nazwie „test.txt” istnieje.

import java.io.File;

public class FileWatcher {

    public static void main(String[] args) {
        String fileName = "test.txt";
        File file = new File(fileName);
        if (file.exists()) {
            System.out.println("File " + file.getName() + " exists.");
        } else {
            System.out.println("File " + file.getName() + " does not exist.");
        }
    }
}

Powyższy program jeszcze nie spełnia wymagań, ale to dobry start. Dzięki niemu jesteśmy w stanie rozpocząć pracę nad mechanizmem sprawdzania plików, który potem dostosujemy do naszych potrzeb.

Dzięki temu, że podchodzimy do problemu powoli, małymi krokami, jesteśmy w stanie najpierw sprawdzić podstawowe mechanizmy zanim rozbudujemy kod. Gdybyśmy próbowali zrobić wszystko na raz, to potem badanie, gdzie popełniliśmy błąd, byłoby utrudnione.

Zmodyfikuję program tak, aby:

  1. Program sprawdzał, czy plik istnieje.
  2. Jeśli plik przestanie istnieć – to poinformuje użytkownika o tym fakcie.

Wykorzystam do tego pętlę while, która będzie działać tak długo, dopóki plik istnieje. Będzie stanowiła pewnego rodzaju „blokadę”, która nie przepuści programu dalej, dopóki plik nie przestanie istnieć.

import java.io.File;

public class FileWatcher {

    public static void main(String[] args) {
        String fileName = "test.txt";
        File file = new File(fileName);
        while (file.exists()) {
            // waiting for file deletion
        }
        System.out.println("File " + file.getName() + " was deleted.");
    }
}

Obecnie minusem programu jest fakt, że komunikat „File test.txt was deleted.” pojawia się również wtedy, gdy plik nigdy nie istniał. Zabezpieczę się na ten wypadek prostym ifem przed pętlą.

Przy okazji wydzielę metodę, aby można było używać tego kodu do plików o różnej nazwie.

import java.io.File;

public class FileWatcher {

    public static void main(String[] args) {
        String fileName = "test.txt";
        waitForDeletion(fileName);
    }

    private static void waitForDeletion(String fileName) {
        File file = new File(fileName);
        if (!file.exists()) {
            System.out.println("File " + file.getName() + " does not exist!");
            return;
        }
        
        while (file.exists()) {
            // waiting for file deletion
        }
        System.out.println("File " + file.getName() + " was deleted.");
    }
}

Ten program realizuje jedno wymaganie – Zadaniem programu będzie obserwowanie, czy plik został usunięty.

Wykorzystaliśmy mechanizm sprawdzania, czy plik istnieje, do stworzenia mechanizmu obserwowania pliku „test.txt”. Program najpierw sprawdza, czy plik istnieje, a jeśli istnieje, to w pętli zatrzymuje program, dopóki plik nie przestanie istnieć. Fakt, że pętla zostanie przerwana, stanowi informację, że plik został usunięty, bo przestał istnieć.

Krok drugi – obserwowanie wielu plików

Obecnie program sprawdza tylko jeden plik. Dzięki wydzieleniu metody „waitForDeletion”, mogę ją wykorzystać do obserwowania pliku o dowolnej nazwie. Przejdźmy zatem do implementacji następnej funkcji programu – możliwości obserwowania wielu plików.

Spróbuj stworzyć pliki „file.txt” oraz „test.txt” i użyj metody „waitForDeletion” do sprawdzenia obydwu plików.

waitForDeletion("file.txt");
waitForDeletion("test.txt");

Następnie usuń drugi plik – „test.txt”.

Niestety program nie zadziała poprawnie. Metoda waitForDeletion zawiera w sobie pętlę while, która sprawia, że wątek jest „zajęty” dopóki ta pętla się wykonuje.

Z tego powodu nie mogę wykorzystać metody „waitForDeletion” do obserwowania wielu plików jednocześnie. Program najpierw zaczeka, aż plik „file.txt” zostanie usunięty. Po usunięciu tego pliku, metoda waitForDeletion zakończy działanie i program przejdzie do kolejnej instrukcji wewnątrz metody main, czyli do czekania na usunięcie kolejnego pliku.

Możemy poradzić sobie z tym faktem, uruchamiając metodę waitForDeletion w oddzielnym wątku. Wątki są w stanie działać współbieżnie, więc pętle while działające w dwóch wątkach są w stanie działać jednocześnie.

Zmodyfikuję program tak, aby uruchamiał kod w oddzielnych wątkach. Przy okazji przeniosę też kod z metody „waitForDeletion” do oddzielnej klasy.

import java.io.File;

public class FileWatcher {

    public static void main(String[] args) throws InterruptedException {
        Thread file = new FileWatchThread("file.txt");
        Thread test = new FileWatchThread("test.txt");

        file.start();
        test.start();
    }
}

class FileWatchThread extends Thread {

    private final String fileName;

    FileWatchThread(String fileName) {
        this.fileName = fileName;
    }

    @Override
    public void run() {
        File file = new File(fileName);
        if (!file.exists()) {
            System.out.println("File " + file.getName() + " does not exist!");
            return;
        }
        while (file.exists()) {
            // waiting for file deletion
        }
        System.out.println("File " + file.getName() + " was deleted.");
    }
}

Po tej modyfikacji program działa zgodnie z założeniem – jest w stanie obserwować pliki w tym samym momencie.

Spróbuj powtórzyć eksperyment z plikami „test.txt” oraz „file.txt”.

Dodanie interfejsu użytkownika

Teraz, gdy już mamy gotowy mechanizm sprawdzania plików, możemy zająć się interfejsem użytkownika. Stworzymy prosty interfejs, który wyświetla tekst i pobiera polecenia od użytkownika. Program nie będzie posiadał okien i przycisków – nasz program będzie posiadał interfejs tekstowy.

Do pobierania poleceń wykorzystamy klasę „Scanner”. Aby program mógł przyjmować wiele poleceń od użytkownika użyjemy pętli, w której program będzie pobierał wprowadzony tekst.

Gdy użytkownik wpisze „WATCH file.txt”, to program rozpocznie obserwowanie pliku „file.txt”, a polecenie „END” zakończy program.

Zwróć uwagę na to, że w przypadku, gdy użytkownik wpisze „WATCH file.txt” program musi usunąć początek tekstu (prefiks „WATCH ”), aby uzyskać samą nazwę pliku, bez polecenia.

import java.io.File;
import java.util.Scanner;

public class FileWatcher {

    public static void main(String[] args) throws InterruptedException {
        System.out.println("FILE DELETION WATCHER");
        System.out.println("Type WATCH <file name> to watch for file deletion.");
        System.out.println("Type END to end the program.");

        Scanner scanner = new Scanner(System.in);
        while(scanner.hasNext()) {
            String line = scanner.nextLine();

            if (line.startsWith("WATCH")) {
                // remove "WATCH" prefix from user input
                String fileName = line.substring("WATCH ".length());

                Thread thread = new FileWatchThread(fileName);
                thread.start();
            } else if (line.startsWith("END")) {
                System.out.println("Bye!");
                System.exit(0);
            }
        }
    }
}

class FileWatchThread extends Thread {

    private final String fileName;

    FileWatchThread(String fileName) {
        this.fileName = fileName;
    }

    @Override
    public void run() {
        File file = new File(fileName);
        if (!file.exists()) {
            System.out.println("File " + file.getName() + " does not exist!");
            return;
        }
        System.out.println("Watching " + file.getName());
        while (file.exists()) {
            // waiting for file deletion
        }
        System.out.println("File " + file.getName() + " was deleted.");
    }
}

Teraz program powinien mieć prosty interfejs tekstowy. U mnie program wygląda tak jak na obrazku poniżej:

Spróbuj przetestować powyższy program.

Przerywanie obserwacji

Zgodnie z wymaganiami użytkownik powinien mieć możliwość przerwania obserwacji. Aby to zrobić, muszę zrobić trzy rzeczy:

  1. Dodać polecenie „STOP <file name>”, które pozwoli przerwać obserwowanie.
  2. Zacząć zapisywać, które wątki uruchomiłem – obecnie nie wiem, ile wątków jest uruchomionych i które pliki obserwuję.
  3. Zaimplementować mechanizm „zatrzymywania” obserwacji.

Zacznę od punktu 3 – będzie on podstawą tego mechanizmu. Do przerywania obserwacji wykorzystam mechanizm przerwań. Aby poinformować wątek, że chcemy zakończyć obserwację, wyślę przerwanie.

W wątku FileWatchThread nie ma żadnej operacji, która wyrzucałaby wyjątek InterruptedException. W tej sytuacji muszę użyć metody Thread.interrupted() do sprawdzenia, czy inny wątek nie próbuje przerwać naszej pracy.

Wątek najdłużej zatrzymuje się na pętli while. Zmodyfikuję ją, aby w chwili przerwania przerywała obserwację.

while (file.exists()) {
    if (Thread.interrupted()) {
        System.out.println("File " + file.getName() + " is no longer watched.");
        return;
    }
}

Teraz, aby móc wysłać przerwanie do wątku, muszę też mieć zapisaną instancję wątku. Stworzone wątki zapiszę w HashMap. Każda instancja FileWatchThread będzie umieszczona w mapie pod swoim kluczem. Kluczami będą nazwy plików. Dzięki temu będziemy mogli sprawdzić, do którego wątku jest przypisany plik o danej nazwie.

Map<String, Thread> threads = new HashMap<>();

while(scanner.hasNext()) {
    String line = scanner.nextLine();

    if (line.startsWith("WATCH")) {
        // remove "WATCH" prefix from user input
        String fileName = line.substring("WATCH ".length());

        Thread thread = new FileWatchThread(fileName);
        thread.start();
        
        threads.put(fileName, thread);

Teraz muszę dodać tylko obsługę polecenia „STOP <file name>”. Po wpisaniu tego polecenia, program sprawdzi, czy w mapie pod kluczem „<file name>” jest wątek. Jeśli jest, to wyśle przerwanie, kończąc tym samym obserwację.

Poniżej zamieszczam gotowy program:

package com.finanteq.multithreading.state;

import java.io.File;
import java.util.HashMap;
import java.util.Map;
import java.util.Scanner;

public class FileWatcher {

    public static void main(String[] args) throws InterruptedException {
        System.out.println("FILE DELETION WATCHER");
        System.out.println("Type WATCH <file name> to watch for file deletion.");
        System.out.println("Type STOP <file name> to stop file watch.");
        System.out.println("Type END to end the program.");

        Scanner scanner = new Scanner(System.in);
        Map<String, Thread> threads = new HashMap<>();

        while(scanner.hasNext()) {
            String line = scanner.nextLine();

            if (line.startsWith("WATCH")) {
                // remove "WATCH" prefix from user input
                String fileName = line.substring("WATCH ".length());

                Thread thread = new FileWatchThread(fileName);
                thread.start();

                threads.put(fileName, thread);
            } else if (line.startsWith("STOP")) {
                // remove "STOP" prefix from user input
                String fileName = line.substring("STOP ".length());
                Thread thread = threads.get(fileName);
 
                if (thread != null) {
                    thread.interrupt();
                    threads.remove(fileName);
                } else {
                    System.out.println("File " + fileName + " is not watched.");
                }
            } else if (line.startsWith("END")) {
                System.out.println("Bye!");
                System.exit(0);
            } else {
                System.out.println("Unknown command: " + line);
            }
        }
    }
}

class FileWatchThread extends Thread {

    private final String fileName;

    FileWatchThread(String fileName) {
        this.fileName = fileName;
    }

    @Override
    public void run() {
        File file = new File(fileName);
        if (!file.exists()) {
            System.out.println("File " + file.getName() + " does not exist!");
            return;
        }

        System.out.println("Watching " + file.getName());

        while (file.exists()) {
            if (Thread.interrupted()) {
                System.out.println("File " + file.getName() + " is no longer watched.");
                return;
            }
        }
        System.out.println("File " + file.getName() + " was deleted.");
    }
}

Gotowy program możesz znaleźć na GitHubie Finanteq.

Sugestia rozwinięcia programu

Spróbuj rozwinąć program tak, aby wykonywał dodatkowe sprawdzenie po wpisaniu „WATCH <file name>”. Obecnie, gdy jakiś wątek obserwuje dany plik, to użytkownik może stworzyć jeszcze jeden wątek, który będzie obserwował ten sam plik.

Zwróć uwagę na to, że jeśli wprowadzisz dwa polecenia:
WATCH file.txt
WATCH file.txt

to program w tej sytuacji nie zwraca błędu, ale tworzy 2 wątki obserwujące plik file.txt. Pomimo, że w mapie threads jest tylko jeden wątek, to i tak działają dwa wątki obserwujące plik.

Spróbuj tak rozwinąć program (np. pomiędzy linijkami 22-29), aby uwzględnić poniższe problemy:

  • Co jeśli jakiś wątek już obserwuje ten plik?
  • Co się stanie z mapą threads, gdy wątek wykryje usunięcie pliku i zakończy działanie?
  • W jakim stanie są wątki w mapie threads i o czym nam mówią te stany?

Pamiętaj, że możesz w dowolny sposób zaimplementować rozwiązanie problemu, który opisałem wyżej.

Powodzenia!

Po skończeniu, swoje rozwiązanie możesz porównać z moim – kod dostępny jest na GitHubie.

Co dalej?

W następnym artykule pokażę mechanizm synchronizacji wątków i stan BLOCKED. Zobaczysz jak wątki mogą na siebie wpływać oraz różne strategie zabezpieczania danych w pamięci.

Następny artykuł: Wielowątkowość w Java – współdzielenie danych

Autor
Cezary Regec
Cezary Regec
Software Engineer w FINANTEQ
Chcesz z nami pracować?