JavaRush /Курси /JAVA 25 SELF /Багатопотокове читання та запис файлів

Багатопотокове читання та запис файлів

JAVA 25 SELF
Рівень 59 , Лекція 0
Відкрита

1. Коли багатопоточність допомагає

Багатопоточність потрібна, коли є багато роботи, яку можна виконувати одночасно. Наприклад, якщо треба обробити десятки файлів — скопіювати їх, перелічити або проаналізувати — простіше доручити різні частини різним потокам, ніж робити все послідовно. Це якби замість одного друга, який перебирає ваш архів фотографій, ви покликали одразу п’ятьох: робота піде швидше й веселіше.

Вона особливо корисна, коли ви маєте справу з пакетною обробкою файлів, завантаженням або копіюванням великих файлів частинами або коли після читання даних потрібно паралельно щось обчислити для різних фрагментів.

Але багатопоточність не завжди допомагає. Якщо у вас один маленький файл, запускати заради нього десяток потоків безглуздо. Якщо диск або мережа вже завантажені вщерть, нові потоки лише сповільнять процес. А якщо кілька потоків одночасно пишуть в один і той самий файл без синхронізації — можна отримати справжній хаос із пошкодженими даними.

Простіше кажучи, багатопоточність — це інструмент. Як молоток: ним можна забити цвях, а можна й по пальцю вдарити. Головне — знати, коли й як його використовувати.

2. Інструменти Java для багатопотокового I/O

Ви вже знаєте, що в Java є кілька способів запускати завдання паралельно:

  • Класичний Thread — ручне створення потоків.
  • Пул потоків через ExecutorService — сучасний, гнучкий і зручний спосіб.
  • CompletableFuture та паралельні стріми (Stream API) — для просунутих завдань (детальніше розглянемо їх у наступних лекціях).

Почнемо з найпростішого: обробка кількох файлів у різних потоках.

Приклад 1: Класичний 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("Файл скопійовано: " + source);
        } catch (IOException e) {
            System.err.println("Помилка копіювання " + source + ": " + e.getMessage());
        }
    }
}
// Запуск кількох копій в окремих потоках
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();
}

Плюси: Просто й зрозуміло.

Мінуси: Керувати великою кількістю потоків вручну незручно, немає контролю над кількістю потоків, що працюють одночасно.

Приклад 2: ExecutorService — пул потоків

ExecutorService дозволяє делегувати завдання пулу потоків, який сам вирішить, скільки потоків використовувати одночасно.

import java.util.concurrent.*;

public class MultiFileCopier {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(4); // максимум 4 потоки

        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("Скопійовано: " + file);
                } catch (IOException e) {
                    System.err.println("Помилка: " + file + " " + e.getMessage());
                }
            });
        }

        executor.shutdown(); // більше не приймаємо завдання
        executor.awaitTermination(1, TimeUnit.MINUTES); // чекаємо завершення всіх завдань
    }
}

Плюси:

  • Легко масштабувати (можна задати потрібну кількість потоків).
  • Зручно контролювати завершення завдань (методи shutdown(), awaitTermination(...)).
  • Підходить для обробки сотень і тисяч файлів.

3. Проблеми та обмеження багатопотокового I/O

Конкуренція за ресурси

Якщо ви спробуєте одночасно читати або писати один і той самий файл із кількох потоків без синхронізації — результат буде непередбачуваним. Це якби двоє людей одночасно писали на одній і тій самій сторінці книжки: вийде каша. Для координації використовуйте, наприклад, synchronized, явні блокування або окремий потік запису.

Обмеження файлової системи та ОС

  • Не всі файлові системи добре підтримують одночасний запис в один файл.
  • Операційна система може обмежувати кількість одночасно відкритих файлів.
  • Жорсткий диск (особливо HDD) погано працює за великої кількості випадкових звернень.

Синхронізація під час запису у спільний ресурс

Якщо кілька потоків пишуть в один файл (наприклад, лог), обов’язково використовуйте синхронізацію (наприклад, через synchronized, блокування або спеціальні потоки запису).

Неефективність на малих файлах

Для невеликих файлів накладні витрати на створення потоків і перемикання між ними можуть бути більшими, ніж виграш від паралелізму.

4. Практичні приклади

Паралельне копіювання файлів

Припустімо, у нас є папка з купою логів, які потрібно скопіювати в архівний каталог.

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")
            // ... додайте скільки завгодно файлів
        );

        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("Скопійовано: " + file);
                } catch (IOException e) {
                    System.err.println("Помилка: " + file + " " + e.getMessage());
                }
            });
        }

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

Коментар:

  • Ми використовуємо пул із 4 потоків — цього зазвичай достатньо, щоб завантажити диск, але не перевантажити систему.
  • Для 1000 файлів можна збільшити пул до 8, але не варто робити його надто великим.

Паралельна обробка рядків файлу за допомогою Stream API

Починаючи з Java 8 можна використовувати паралельні стріми для обробки вмісту файлу:

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 повертає Stream<String> — потік рядків файлу
        Files.lines(path)
            .parallel() // перетворюємо на паралельний потік
            .filter(line -> line.contains("ERROR"))
            .forEach(line -> System.out.println("Помилка: " + line));
    }
}

Важливо:

  • Паралельні стріми пришвидшують обробку, якщо вона потребує значних обчислень (CPU-bound), а не сам процес читання (I/O-bound).
  • Якщо обробка рядка проста (наприклад, просто друк через System.out.println), приросту швидкості може не бути.

Читання/запис різних частин одного великого файлу

Java дозволяє читати або писати різні ділянки одного файлу одночасно за допомогою FileChannel і методів позиціювання. Це вже просунутий рівень, але принцип простий: кожен потік працює зі своїм фрагментом файлу.

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("Прочитано фрагмент із позиції " + position + " розміром " + size);
            // Тут можна обробити buffer
        } catch (IOException e) {
            System.err.println("Помилка читання фрагмента: " + e.getMessage());
        }
    }
}
// Приклад запуску: читаємо файл по 1 МБ у 4 потоки
Path file = Path.of("bigdata.bin");
long fileSize = Files.size(file);
long chunkSize = 1024 * 1024; // 1 МБ
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);

Коментар:

  • Кожен потік читає свій фрагмент файлу, не заважаючи іншим.
  • Такий підхід використовується, наприклад, у торентах і завантажувачах.

Синхронізація під час запису у спільний файл

Якщо кілька потоків пишуть в один і той самий файл (наприклад, лог), потрібно синхронізувати доступ, щоб не отримати «вінегрет» із рядків:

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();
    }
}

Коментар:

  • Метод log позначено як synchronized, щоб лише один потік писав у файл у цей момент.
  • Це працює, але за великої кількості потоків може стати вузьким місцем — краще писати в різні файли, а потім об’єднувати.

5. Коли не варто використовувати багатопоточність

Багатопоточність вабить до себе: більше потоків — значить, усе має літати! Але на практиці це не завжди так. Якщо ви обробляєте кілька невеликих файлів, то простіше й надійніше зробити це послідовно. Час, який ви витратите на запуск потоків і координацію між ними, просто не окупиться.

Іноді проблема взагалі не в диску, а в мережі — тоді додавання потоків нічого не пришвидшить, тому що «вузьке місце» (bottleneck) перебуває зовсім в іншому місці. Ще одна пастка — паралельний запис в один і той самий файл. Якщо у вас мало досвіду із синхронізацією, краще навіть не пробувати: великий шанс отримати зіпсовані дані.

І нарешті, якщо ваш диск або файлова система не люблять, коли до них одночасно звертаються десятки потоків, — багатопоточність не врятує, а лише погіршить ситуацію.

Простіше кажучи, якщо здається, що «чим більше потоків, тим краще», — найчастіше це не так. Інколи один спокійний потік робить роботу чистіше, швидше й надійніше, ніж десяток квапливих.

6. Коротке знайомство з FileChannel для просунутих завдань

FileChannel із пакета java.nio.channels — це інструмент для роботи з файлами на низькому рівні, який дозволяє читати й писати дані за довільними позиціями. Це дозволяє реалізувати, наприклад, паралельне завантаження або обробку великих файлів частинами.

Приклад:

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); // читаємо 1024 байти з позиції 0
    // обробка буфера
}

Важливо:

  • FileChannel не синхронізований — якщо кілька потоків працюють з одним каналом, синхронізацію потрібно реалізувати самостійно.
  • Для паралельної роботи простіше відкривати окремий канал для кожного потоку.

7. Типові помилки при багатопоточному I/O

Помилка № 1: Несинхронізований доступ до одного файлу на запис.
У результаті — пошкоджені дані, дивні символи, інколи файл взагалі не читається. Завжди синхронізуйте доступ або пишіть в окремі файли.

Помилка № 2: Надто багато потоків.
Якщо ви відкриваєте 1000 потоків заради копіювання 1000 файлів, ваш комп’ютер може образитися (OutOfMemoryError, гальма, падіння). Використовуйте пул потоків (ExecutorService) і обмежуйте їх кількість.

Помилка № 3: Потоки/файли не закриваються.
Кожен відкритий потік — це ресурс ОС. Якщо їх не закривати, можна отримати помилку «Too many open files». Використовуйте try-with-resources або не забувайте викликати close().

Помилка № 4: Передчасне завершення програми.
Якщо не дочекатися завершення всіх потоків (наприклад, не викликати executor.awaitTermination(...)), програма може завершитися раніше, ніж усі файли скопіюються.

Помилка № 5: Паралельний запис в одну й ту саму ділянку файлу без урахування позицій.
Якщо кілька потоків пишуть в одну й ту саму область файлу — дані перемішаються. Для запису за позиціями використовуйте канали й чіткий поділ діапазонів.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ