CodeGym /Kursy /JAVA 25 SELF /Wielowątkowy odczyt i zapis plików

Wielowątkowy odczyt i zapis plików

JAVA 25 SELF
Poziom 59, Lekcja 0
Dostępny

1. Kiedy wielowątkowość pomaga

Wielowątkowość jest potrzebna, gdy jest dużo pracy, którą można wykonywać równocześnie. Na przykład, jeśli trzeba przetworzyć dziesiątki plików — skopiować je, przeliczyć albo przeanalizować — łatwiej powierzyć różne części różnym wątkom, niż robić wszystko sekwencyjnie. To tak, jakby zamiast jednego znajomego, który przegląda twoje archiwum zdjęć, zaprosić od razu pięciu: praca pójdzie szybciej i przyjemniej.

Jest szczególnie przydatna, gdy masz do czynienia z przetwarzaniem wsadowym plików, pobieraniem lub kopiowaniem dużych plików partiami albo gdy po odczytaniu danych trzeba równolegle coś policzyć dla różnych fragmentów.

Ale wielowątkowość nie zawsze pomaga. Jeśli masz jeden mały plik, uruchamianie dla niego dziesięciu wątków nie ma sensu. Jeśli dysk lub sieć są już obciążone do granic, nowe wątki tylko spowolnią proces. A jeśli kilka wątków jednocześnie zapisuje do tego samego pliku bez synchronizacji — można dostać prawdziwy chaos z uszkodzonymi danymi.

Krótko mówiąc, wielowątkowość to narzędzie. Jak młotek: można nim wbić gwóźdź, a można też uderzyć się w palec. Najważniejsze — wiedzieć, kiedy i jak go używać.

2. Narzędzia Javy do wielowątkowego IO

Wiesz już, że w Javie jest kilka sposobów uruchamiania zadań równolegle:

  • Klasyczny Thread — ręczne tworzenie wątków.
  • Pula wątków przez ExecutorService — nowoczesny, elastyczny i wygodny sposób.
  • CompletableFuture i strumienie równoległe (Stream API) — do bardziej zaawansowanych zadań (omówimy je szerzej w kolejnych wykładach).

Zacznijmy od najprostszego: przetwarzanie kilku plików w różnych wątkach.

Przykład 1: Klasyczny Thread

public class FileCopyTask extends Thread {
    private final Path source;
    private final Path target;

    public FileCopyTask(Path source, Path target) {
        this.source = source;
        this.target = target;
    }

    @Override
    public void run() {
        try {
            Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
            System.out.println("Plik skopiowany: " + source);
        } catch (IOException e) {
            System.err.println("Błąd kopiowania " + source + ": " + e.getMessage());
        }
    }
}
// Uruchomienie kilku kopii w osobnych wątkach
List<Path> filesToCopy = List.of(
    Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
);
for (Path file : filesToCopy) {
    new FileCopyTask(file, Path.of("backup_" + file.getFileName())).start();
}

Zalety: Proste, zrozumiałe.

Wady: Ręczne zarządzanie dużą liczbą wątków jest niewygodne, brak kontroli nad liczbą jednocześnie działających wątków.

Przykład 2: ExecutorService — pula wątków

ExecutorService pozwala delegować zadania do puli wątków, która sama decyduje, ile wątków użyć jednocześnie.

import java.util.concurrent.*;

public class MultiFileCopier {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(4); // maksymalnie 4 wątki

        List<Path> filesToCopy = List.of(
            Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
        );
        for (Path file : filesToCopy) {
            executor.submit(() -> {
                try {
                    Files.copy(file, Path.of("backup_" + file.getFileName()), StandardCopyOption.REPLACE_EXISTING);
                    System.out.println("Skopiowano: " + file);
                } catch (IOException e) {
                    System.err.println("Błąd: " + file + " " + e.getMessage());
                }
            });
        }

        executor.shutdown(); // nie przyjmujemy więcej zadań
        executor.awaitTermination(1, TimeUnit.MINUTES); // czekamy na zakończenie wszystkich zadań
    }
}

Zalety:

  • Łatwe skalowanie (można ustawić wymaganą liczbę wątków).
  • Wygodna kontrola kończenia zadań (metody shutdown(), awaitTermination(...)).
  • Nadaje się do przetwarzania setek i tysięcy plików.

3. Problemy i ograniczenia wielowątkowego IO

Konkurencja o zasoby

Jeśli spróbujesz równocześnie czytać lub pisać ten sam plik z kilku wątków bez synchronizacji — wynik będzie nieprzewidywalny. To tak, jakby dwie osoby jednocześnie pisały na tej samej stronie książki: wyjdzie chaos. Do koordynacji używaj na przykład synchronized, jawnych blokad lub osobnego wątku do zapisu.

Ograniczenia systemu plików i systemu operacyjnego

  • Nie wszystkie systemy plików dobrze obsługują równoczesny zapis do jednego pliku.
  • System operacyjny może ograniczać liczbę jednocześnie otwartych plików.
  • Dysk twardy (zwłaszcza HDD) słabo działa przy dużej liczbie losowych dostępów.

Synchronizacja przy zapisie do wspólnego zasobu

Jeśli kilka wątków zapisuje do jednego pliku (na przykład logu), koniecznie używaj synchronizacji (np. przez synchronized, blokady lub specjalne wątki do zapisu).

Nieefektywność przy małych plikach

Dla małych plików narzut na tworzenie wątków i przełączanie między nimi może być większy niż zysk z równoległości.

4. Praktyczne przykłady

Równoległe kopiowanie plików

Załóżmy, że mamy folder z mnóstwem logów, które trzeba skopiować do katalogu archiwalnego.

import java.nio.file.*;
import java.util.List;
import java.util.concurrent.*;

public class ParallelFileCopier {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(4);

        List<Path> filesToCopy = List.of(
            Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
            // ... dodaj dowolnie wiele plików
        );

        for (Path file : filesToCopy) {
            executor.submit(() -> {
                try {
                    Path target = Path.of("archive", file.getFileName().toString());
                    Files.copy(file, target, StandardCopyOption.REPLACE_EXISTING);
                    System.out.println("Skopiowano: " + file);
                } catch (IOException e) {
                    System.err.println("Błąd: " + file + " " + e.getMessage());
                }
            });
        }

        executor.shutdown();
        executor.awaitTermination(10, TimeUnit.MINUTES);
    }
}

Komentarz:

  • Używamy puli z 4 wątkami — to zwykle wystarcza, by obciążyć dysk, ale nie przeciążyć systemu.
  • Dla 1000 plików można zwiększyć pulę do 8, ale nie warto robić jej zbyt dużą.

Równoległe przetwarzanie linii pliku za pomocą Stream API

Od Javy 8 można używać strumieni równoległych do przetwarzania zawartości pliku:

import java.nio.file.*;
import java.io.IOException;

public class ParallelLineProcessing {
    public static void main(String[] args) throws IOException {
        Path path = Path.of("biglog.txt");

        // Files.lines zwraca Stream<String> — strumień linii pliku
        Files.lines(path)
            .parallel() // zamieniamy na strumień równoległy
            .filter(line -> line.contains("ERROR"))
            .forEach(line -> System.out.println("Błąd: " + line));
    }
}

Ważne:

  • Strumienie równoległe przyspieszają przetwarzanie, jeśli jest ono wymagające obliczeniowo (CPU-bound), a nie sam proces odczytu (IO-bound).
  • Jeśli przetwarzanie linii jest proste (np. samo wypisywanie przez System.out.println), przyrostu może nie być.

Odczyt/zapis różnych części jednego dużego pliku

Java pozwala czytać lub pisać różne obszary jednego pliku jednocześnie za pomocą FileChannel i metod pozycjonowania. To już poziom zaawansowany, ale zasada jest prosta: każdy wątek pracuje ze swoim fragmentem pliku.

import java.nio.channels.FileChannel;
import java.nio.file.*;
import java.io.*;
import java.nio.ByteBuffer;

public class FileChunkReader implements Runnable {
    private final Path path;
    private final long position;
    private final long size;

    public FileChunkReader(Path path, long position, long size) {
        this.path = path;
        this.position = position;
        this.size = size;
    }

    @Override
    public void run() {
        try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
            ByteBuffer buffer = ByteBuffer.allocate((int) size);
            channel.read(buffer, position);
            System.out.println("Przeczytano fragment od pozycji " + position + " o rozmiarze " + size);
            // Tutaj można przetworzyć bufor
        } catch (IOException e) {
            System.err.println("Błąd odczytu fragmentu: " + e.getMessage());
        }
    }
}
// Przykład uruchomienia: czytamy plik po 1 MB w 4 wątki
Path file = Path.of("bigdata.bin");
long fileSize = Files.size(file);
long chunkSize = 1024 * 1024; // 1 MB
int chunks = (int) Math.ceil((double) fileSize / chunkSize);

ExecutorService executor = Executors.newFixedThreadPool(4);

for (int i = 0; i < chunks; i++) {
    long position = i * chunkSize;
    long size = Math.min(chunkSize, fileSize - position);
    executor.submit(new FileChunkReader(file, position, size));
}

executor.shutdown();
executor.awaitTermination(10, TimeUnit.MINUTES);

Komentarz:

  • Każdy wątek czyta swój fragment pliku, nie przeszkadzając innym.
  • Takie podejście jest używane np. w torrentach i menedżerach pobierania.

Synchronizacja przy zapisie do wspólnego pliku

Jeśli kilka wątków zapisuje do tego samego pliku (np. logu), trzeba zsynchronizować dostęp, aby nie otrzymać „miszmaszu” z wierszy:

import java.io.*;

public class SafeLogger {
    private final Writer writer;

    public SafeLogger(String filename) throws IOException {
        this.writer = new BufferedWriter(new FileWriter(filename, true));
    }

    public synchronized void log(String message) throws IOException {
        writer.write(message);
        writer.write(System.lineSeparator());
        writer.flush();
    }

    public void close() throws IOException {
        writer.close();
    }
}

Komentarz:

  • Metoda log jest oznaczona jako synchronized, aby tylko jeden wątek pisał do pliku w danym momencie.
  • To działa, ale przy dużej liczbie wątków może stać się wąskim gardłem — lepiej pisać do różnych plików, a potem je scalać.

5. Kiedy nie warto używać wielowątkowości

Wielowątkowość kusi: więcej wątków, więc wszystko powinno śmigać! W praktyce nie zawsze tak jest. Jeśli przetwarzasz kilka niewielkich plików, prościej i pewniej zrobić to sekwencyjnie. Czas, który poświęcisz na uruchamianie wątków i koordynację między nimi, po prostu się nie zwróci.

Czasem problem w ogóle nie dotyczy dysku, tylko sieci — wtedy dodanie wątków niczego nie przyspieszy, bo wąskie gardło leży gdzie indziej. Kolejna pułapka — równoległy zapis do tego samego pliku. Jeśli masz mało doświadczenia z synchronizacją, lepiej nawet nie próbować: duże szanse na uszkodzone dane.

I wreszcie, jeśli twój dysk lub system plików nie lubią, gdy „szarpie się” je naraz dziesiątkami wątków — wielowątkowość nie uratuje, a tylko pogorszy sytuację.

Krótko mówiąc, jeśli wydaje się, że „im więcej wątków, tym lepiej”, — najczęściej tak nie jest. Czasem jeden spokojny wątek wykona pracę czyściej, szybciej i pewniej niż dziesiątka spieszących się.

6. Krótkie wprowadzenie do FileChannel dla zaawansowanych zadań

FileChannel z pakietu java.nio.channels to narzędzie do pracy z plikami na niskim poziomie, pozwalające czytać i pisać dane w dowolnych pozycjach. Dzięki temu można zrealizować np. równoległe pobieranie lub przetwarzanie dużych plików fragmentami.

Przykład:

try (FileChannel channel = FileChannel.open(Path.of("bigfile.bin"), StandardOpenOption.READ)) {
    ByteBuffer buffer = ByteBuffer.allocate(1024);
    long position = 0;
    int bytesRead = channel.read(buffer, position); // czytamy 1024 bajty od pozycji 0
    // przetwarzanie bufora
}

Ważne:

  • FileChannel nie jest synchronizowany — jeśli kilka wątków pracuje z jednym kanałem, synchronizację trzeba zaimplementować samodzielnie.
  • Do pracy równoległej prościej otwierać osobny kanał dla każdego wątku.

7. Typowe błędy przy wielowątkowym IO

Błąd nr 1: Niesynchronizowany dostęp do jednego pliku do zapisu.
W rezultacie — uszkodzone dane, dziwne znaki, czasem plik w ogóle nie daje się odczytać. Zawsze synchronizuj dostęp albo zapisuj do osobnych plików.

Błąd nr 2: Zbyt wiele wątków.
Jeśli otwierasz 1000 wątków, żeby skopiować 1000 plików, twój komputer może się „obrazić” (OutOfMemoryError, spowolnienia, awarie). Używaj puli wątków (ExecutorService) i ograniczaj ich liczbę.

Błąd nr 3: Nie są zamykane strumienie/pliki.
Każdy otwarty strumień to zasób systemu operacyjnego. Jeśli ich nie zamykasz, możesz dostać błąd „Too many open files”. Używaj try-with-resources albo pamiętaj o wywołaniu close().

Błąd nr 4: Przedwczesne zakończenie programu.
Jeśli nie poczekasz na zakończenie wszystkich wątków (np. nie wywołasz executor.awaitTermination(...)), program może zakończyć się wcześniej, niż wszystkie pliki zostaną skopiowane.

Błąd nr 5: Równoległy zapis w ten sam obszar pliku bez uwzględnienia pozycji.
Jeśli kilka wątków zapisuje do tego samego obszaru pliku — dane się wymieszają. Do zapisu po pozycjach używaj kanałów i wyraźnego podziału zakresów.

1
Zadanie
JAVA 25 SELF, poziom 59, lekcja 0
Niedostępne
Równoległe odczytywanie części dużego pliku
Równoległe odczytywanie części dużego pliku
1
Zadanie
JAVA 25 SELF, poziom 59, lekcja 0
Niedostępne
Synchronizowane zapisy do wspólnego pliku dziennika z wielu wątków
Synchronizowane zapisy do wspólnego pliku dziennika z wielu wątków
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION