JavaRush /Курси /JAVA 25 SELF /Розбір помилок під час асинхронної роботи з файлами

Розбір помилок під час асинхронної роботи з файлами

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

1. Помилки з буферами: ByteBuffer, позиція та ліміт

Асинхронні методи читання та запису працюють з об’єктом ByteBuffer. На відміну від звичайних масивів, буфер має «внутрішній курсор» — позицію (position) і ліміт (limit), які визначають, які байти буде прочитано чи записано. Якщо неправильно керувати цими властивостями, можна отримати не той результат, на який очікуєте, або взагалі зламати логіку.

Як це виглядає в коді?

Приклад неправильного використання:

ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
    @Override
    public void completed(Integer result, ByteBuffer buf) {
        // Ой! Намагаємося одразу читати рядок із буфера:
        String str = new String(buf.array()); // Це неправильно!
        // ...
    }
    // ...
});

Що не так?

  • Після читання буфер перебуває в режимі «запису»: position вказує на кінець зчитаних даних, а limit — на розмір буфера. Якщо одразу читати з буфера, ви отримаєте купу сміття (усі 1024 байти, навіть якщо фактично зчитано 10).
  • Виклик buf.array() повертає весь внутрішній масив, а не лише прочитану частину. Крім того, для прямого буфера (виділений через ByteBuffer.allocateDirect) array() викине виняток UnsupportedOperationException.

Як правильно?

Перед читанням даних із буфера потрібно викликати buffer.flip() — це перемкне буфер у режим «читання»:

public void completed(Integer result, ByteBuffer buf) {
    buf.flip(); // Тепер position = 0, limit = кількість зчитаних байтів
    String str = StandardCharsets.UTF_8.decode(buf).toString();
    // ... обробка рядка
}

Повторне використання буфера

Якщо ви хочете повторно використовувати буфер для наступної операції, не забудьте викликати buffer.clear() або buffer.compact() після обробки даних:

  • clear() — повністю «обнуляє» межі: position=0, limit=capacity(), старі дані вважаються сміттям.
  • compact() — зберігає непрочитані байти на початку буфера і готує його до дозапису.

Нюанс: якщо result дорівнює -1, досягнуто кінця файлу — додаткової обробки не буде.

2. Паралельний доступ: гонки та неконсистентність

AsynchronousFileChannel дозволяє запускати кілька операцій паралельно. Але якщо ви не контролюєте цей процес, легко отримати «биті» дані або аварійне завершення програми.

Проблема 1: Одночасне читання в один буфер

// Два одночасні читання в один і той самий буфер
channel.read(buffer, 0, buffer, handler1);
channel.read(buffer, 1024, buffer, handler2);

Обидві операції записують в один і той самий буфер! Якщо обидві завершаться майже одночасно, вміст буфера буде непередбачуваним.

Проблема 2: Одночасний запис в один файл

Якщо два потоки одночасно пишуть в одну й ту саму ділянку файлу, результат залежить від того, яка операція завершиться раніше. Це класична гонка (race condition), яка може призвести до пошкоджених даних.

Як уникнути?

  • Для кожної асинхронної операції використовуйте окремий буфер (ByteBuffer) — потоки не будуть «лізти» один одному в пам’ять.
  • Не запускайте паралельні записи на один і той самий діапазон файлу; розділяйте зміщення або синхронізуйте доступ.
  • Якщо важливий суворий порядок, нову операцію запускайте лише після завершення попередньої — наприклад, із completed(...) у CompletionHandler.

3. Витоки ресурсів: забули закрити канал

Асинхронний канал — це системний ресурс. Якщо його не закрити (channel.close()), файл залишиться «зайнятим» у системі, можуть виникнути витоки пам’яті, а на Windows — ще й блокування файлу для інших програм.

Типова помилка:

AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, ...);
// ... запустили операцію
// забули викликати channel.close() після завершення всіх операцій!

Як правильно?

Використовуйте try-with-resources і обов’язково дочекайтеся завершення всіх операцій перед виходом із блоку:

CountDownLatch latch = new CountDownLatch(1);

try (AsynchronousFileChannel channel =
         AsynchronousFileChannel.open(path, StandardOpenOption.READ)) {

    ByteBuffer buf = ByteBuffer.allocate(4096);
    channel.read(buf, 0, buf, new CompletionHandler<Integer, ByteBuffer>() {
        @Override public void completed(Integer r, ByteBuffer b) {
            // обробка...
            latch.countDown();
        }
        @Override public void failed(Throwable ex, ByteBuffer b) {
            ex.printStackTrace();
            latch.countDown();
        }
    });

    latch.await(); // Чекаємо завершення асинхронної операції
}
// Закриття відбудеться автоматично

4. Обробка винятків: ігнорування помилок у CompletionHandler

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

Приклад «невидимої» помилки:

channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
    @Override
    public void completed(Integer result, ByteBuffer buf) {
        // ... обробка результату
    }
    @Override
    public void failed(Throwable exc, ByteBuffer buf) {
        // Ой, порожньо! Помилку втрачено.
    }
});

Як правильно?

@Override
public void failed(Throwable exc, ByteBuffer buf) {
    System.err.println("Помилка під час читання файлу: " + exc.getMessage());
    exc.printStackTrace();
    // Можливо: закрити канал, оновити метрики, повідомити користувача тощо.
}

5. Втрата посилань на Future/CompletionHandler

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

Приклад:

channel.read(buffer, 0, buffer, handler); // handler — анонімний, ніде не зберігається
// Програма відразу завершилася, не дочекавшись завершення читання

Як правильно?

  • Працюючи з Future<Integer>: зберігайте посилання і використовуйте future.get() або future.cancel(true) за потреби.
  • Використовуючи CompletionHandler: застосовуйте механізми синхронізації (CountDownLatch, Semaphore) і коректно дочікуйтеся завершення всіх операцій перед закриттям каналу/програми.

6. Помилки з кодуванням

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

Проблема:

// Читаємо файл по 1024 байти, потім перетворюємо на рядок
String chunk = new String(buffer.array(), "UTF-8");

Якщо символ розбився між двома буферами (наприклад, один байт символу UTF-8 залишився в кінці одного буфера, а решта — на початку наступного), ви отримаєте некоректні символи або помилку декодування.

Як правильно? Використовуйте CharsetDecoder і зберігайте «рештки» незавершених символів між читаннями:

CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
        .onMalformedInput(CodingErrorAction.REPORT)
        .onUnmappableCharacter(CodingErrorAction.REPORT);

ByteBuffer byteBuf = ByteBuffer.allocate(4096);
CharBuffer charBuf = CharBuffer.allocate(4096);

// Під час кожного completed(...):
byteBuf.flip();
CoderResult cr = decoder.decode(byteBuf, charBuf, false); // false — це не кінець введення
if (cr.isError()) {
    cr.throwException();
}
byteBuf.compact();
charBuf.flip();
String text = charBuf.toString();
charBuf.clear();

// Коли введення більше не очікується:
decoder.flush(charBuf);

7. Передчасне завершення програми

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

Приклад:

// Запускаємо асинхронне читання
channel.read(buffer, 0, buffer, handler);
// Основний потік одразу завершився — програма закрилася, операція не встигла завершитися

Як правильно?

Використовуйте CountDownLatch, Semaphore або хоча б Thread.sleep(...) (для демо), щоб дочекатися завершення всіх операцій:

CountDownLatch latch = new CountDownLatch(1);
channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
    @Override public void completed(Integer result, ByteBuffer buf) { /* ... */ latch.countDown(); }
    @Override public void failed(Throwable exc, ByteBuffer buf) { /* ... */ latch.countDown(); }
});
latch.await(); // Чекаємо завершення операції

8. Невдала інтеграція з ExecutorService

AsynchronousFileChannel дозволяє вказати свій ExecutorService для обробки подій. Якщо ви передасте пул із малою кількістю потоків або взагалі один потік, усі операції виконуватимуться послідовно, а не паралельно. Якщо пул занадто великий — отримаєте зайві накладні витрати на перемикання контексту.

Приклад:

ExecutorService executor = Executors.newSingleThreadExecutor();
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, options, executor);
// Усі асинхронні операції по суті будуть синхронними!

Як правильно?

  • Підбирайте розмір пулу за реальною завантаженістю та кількістю одночасних операцій.
  • Для більшості задач підходять ForkJoinPool.commonPool() або Executors.newCachedThreadPool().
  • Пам’ятайте, що переданий ExecutorService керує викликами зворотних викликів (completed/failed), а не самим дисковим I/O.
1
Опитування
Асинхронні операції з файлами, рівень 56, лекція 4
Недоступний
Асинхронні операції з файлами
Асинхронні операції з файлами
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ