JavaRush /Курси /Kotlin SELF /withContext(Dispatchers.IO) + use {}: правильні межі I/O‑...

withContext(Dispatchers.IO) + use {}: правильні межі I/O‑функцій

Kotlin SELF
Рівень 56 , Лекція 1
Відкрита

1. Шаблон I/O‑обгортки в suspend-функції

Коли ви пишете програму, вам майже завжди здається, що головне — «що вона робить». Але в корутинах зʼявляється ще один шар реальності: «де і як вона це робить». Файлові операції на JVM найчастіше блокувальні: потік справді чекає на диск, ОС, файлову систему, іноді навіть на мережу (якщо файл лежить на мережевому диску). Якщо змішати це з обчисленнями й не відокремити акуратно, програма починає «гальмувати» в найнесподіваніших місцях — ніби в неї раптово зникла мотивація жити.

Уявіть кухню: Dispatchers.Default — це кухарі, які ріжуть, смажать і думають, а Dispatchers.IO — це курʼєр, який стоїть у черзі за продуктами. Якщо ви змусите кухарів стояти в черзі замість курʼєра, кухня зупиниться. І так, це звучить смішно — доки ви не побачите, як «просто читання файлу» ламає чуйність усього застосунку.

Хороша I/O‑функція в корутинному застосунку зазвичай виглядає дуже нудно — і це комплімент. Вона робить мінімум: читає/пише/перевіряє файл і повертає результат. Уся «розумна» логіка залишається зовні. Технічно це майже завжди означає: suspend fun ... = withContext(Dispatchers.IO) { ... }.

Головна ідея проста: ми явно позначаємо межу, де починається блокувальний світ файлової системи. Завдяки цьому ви, ваш керівник команди й навіть ви за тиждень (найсуворіший ревʼюер) швидко зрозумієте: «ага, ось тут ми ходимо у файлову систему».

Міні‑приклад: перевірка існування файлу

Ця функція здається надто простою, щоб бути окремою… але саме такі функції потім рятують проєкт від хаосу, бо задають єдиний стиль.

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File

suspend fun fileExists(path: String): Boolean =
    withContext(Dispatchers.IO) {
        File(path).exists()
    }

Ключова думка: навіть exists() — це звернення до файлової системи. Не завжди «важке», але за своєю природою блокувальне.

Повертаємо значення з withContext (і чому це зручно)

withContext { ... } — це вираз. Він повертає те, що повертає останній оператор блоку. Завдяки цьому I/O‑функції виглядають «звичайними»: їх легко читати, тестувати й використовувати в логіці.

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File

suspend fun readTextIo(path: String): String =
    withContext(Dispatchers.IO) {
        File(path).readText()
    }

Тут є приємна деталь: readText() сам відкриває і закриває ресурс усередині себе, тому use { ... } нам не потрібен. Але щойно ви переходите до потоків/рідерів/райтерів — use стане вашим найкращим другом.

Ранній вихід: return@withContext

Коли всередині withContext ви хочете «повернути null» або «закінчити за умовою», звичайний return не підходить (він спробує вийти з усієї функції). Потрібен позначений return: return@withContext.

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File

suspend fun readFirstLineOrNull(path: String): String? =
    withContext(Dispatchers.IO) {
        val file = File(path)
        if (!file.exists() || !file.isFile) return@withContext null

        file.bufferedReader().readLine()
    }

Зараз ми навмисно зробили приклад «без use», щоб побачити проблему: bufferedReader() потрібно закривати. Виправимо це в наступному розділі.

2. use { ... }: гарантовано закриваємо ресурси

У файловому I/O є стара істина: «Відкрити потік легко. Закрити потік вчасно — це вже характер». У корутинах характер особливо важливий, бо виконання може перериватися винятками, скасуванням, раннім виходом і просто помилками.

use { ... } — це Kotlin‑ідіома, яка гарантує закриття ресурсу (приблизно як try/finally, тільки компактніше й менш схильна до копіпасту). На JVM вона працює для Closeable/AutoCloseable: рідерів, райтерів, потоків.

Правильне читання першого рядка: bufferedReader().use { ... }

Тепер перепишемо попередній приклад так, щоб він не залишав відкритого ресурсу.

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.io.use
import java.io.File

suspend fun readFirstLineOrNull(path: String): String? =
    withContext(Dispatchers.IO) {
        val file = File(path)
        if (!file.exists() || !file.isFile) return@withContext null

        file.bufferedReader().use { reader ->
            reader.readLine()
        }
    }

Важливо помітити: use закриває reader навіть якщо readLine() кине виняток. Це той рідкісний випадок, коли «магія» Kotlin — насправді просто акуратний finally.

Запис тексту через bufferedWriter().use { ... }

Запис теж має бути акуратним. writeText() і appendText() зручні, але інколи ви хочете писати порядково, контролювати переноси рядків або просто звикли до Writer.

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.io.use
import java.io.File

suspend fun writeLinesIo(path: String, lines: List<String>) {
    withContext(Dispatchers.IO) {
        val file = File(path)
        file.parentFile?.mkdirs()

        file.bufferedWriter().use { w ->
            for (line in lines) w.appendLine(line)
        }
    }
}

Зверніть увагу на parentFile?.mkdirs(): це маленька деталь, яка перетворює програму з «падає на рівному місці» на «працює передбачувано».

Вкладені use: чи це нормально?

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

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.io.use
import java.io.File

suspend fun copyFirstLine(from: String, to: String) {
    withContext(Dispatchers.IO) {
        File(from).bufferedReader().use { r ->
            File(to).bufferedWriter().use { w ->
                w.appendLine(r.readLine() ?: "")
            }
        }
    }
}

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

3. Розділяємо I/O та обробку

Найчастіша помилка новачка (а інколи й досвідченого розробника, який «просто хотів швидше») — зробити «все всередині withContext(Dispatchers.IO)». Файл прочитали, розпарсили, порахували статистику, відсортували, відформатували звіт, записали звіт… і наче працює. Але це погана архітектура: ви втрачаєте контроль над тим, де I/O, а де CPU, і починаєте випадково виконувати обчислення в IO‑пулі.

Правильніше мислити так: I/O‑функція має торкатися файлової системи, але не має бути «кухнею всієї страви». Вона приносить продукти — а готує кухар.

Поганий приклад: «усе в одному місці»

Тут ми навмисно робимо приклад «неправильним», щоб ви побачили проблему: ми змішали читання файлу й важку обробку.

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File

suspend fun countLongLinesWrong(path: String): Int =
    withContext(Dispatchers.IO) {
        val lines = File(path).readLines()
        lines.count { it.trim().length >= 80 }
    }

Формально це працює. Але тепер CPU‑робота (trim(), length, count) живе в IO‑контексті. Інколи це дрібниця, а інколи — початок великих «гальм».

Хороший приклад: «спочатку прочитали, потім порахували»

Розділимо на два шари: I/O‑шар повертає дані, а доменний шар рахує.

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File

suspend fun readLinesIo(path: String): List<String> =
    withContext(Dispatchers.IO) { File(path).readLines() }

suspend fun countLongLines(path: String): Int {
    val lines = readLinesIo(path)
    return lines.count { it.trim().length >= 80 }
}

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

Міні‑таблиця: як перевіряти себе «на око»

Після цього розділу зручно мати коротке правило для самоперевірки:

Запитання до функції Якщо відповідь «так» Що це означає
Вона торкається File, Reader, Writer, InputStream, OutputStream? Так Усередині має бути Dispatchers.IO та акуратне закриття ресурсів
Вона сортує, фільтрує, парсить, рахує статистику? Так Це доменна/CPU‑логіка, намагайтеся тримати її поза Dispatchers.IO
Вона робить і те, і інше Так Отже, ви змішали відповідальність, і код буде складніше підтримувати

Таблиця проста, але в реальному проєкті саме вона економить години розборів «чому воно гальмує».

4. Практика: File Organizer і акуратний I/O‑шар

Щоб приклади не були «у вакуумі», уявімо, що в нас уже є консольний застосунок File Organizer з попередніх міні‑проєктів: він обходить каталог, вибирає файли й розкладає їх по папках. Сьогодні ми не будемо нічого розпаралелювати й не будемо робити скасування — мета простіша. Потрібно оформити чисті I/O‑утиліти, якими потім зручно користуватися.

Нехай у нас є два допоміжні файли: organizer.ignore (список розширень, які ігноруємо) і organizer.log (журнал подій).

Читаємо ignore‑лист як Set<String>

Зробімо функцію, яка читає рядки, прибирає пробіли й приводить до зрозумілого вигляду. Важливо: файлову частину тримаємо в Dispatchers.IO, а «нормалізацію» можна робити й зовні. Але тут вона легка, тож зробимо все акуратно — і водночас читабельно.

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File

suspend fun readIgnoreExtensions(path: String): Set<String> =
    withContext(Dispatchers.IO) {
        val file = File(path)
        if (!file.exists()) return@withContext emptySet()

        file.readLines()
            .map { it.trim().lowercase() }
            .filter { it.isNotEmpty() && !it.startsWith("#") }
            .toSet()
    }

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

Логування: appendLine як окрема I/O‑операція

Логи — це класика: ви пишете один appendText(...) усередині програми, потім другий, потім третій… і раптом логування розмазане по всьому проєкту. Краще зробити одну маленьку I/O‑утиліту.

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File

suspend fun appendLogLine(path: String, line: String) {
    withContext(Dispatchers.IO) {
        val file = File(path)
        file.parentFile?.mkdirs()
        file.appendText(line + "\n")
    }
}

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

Використання в main: runBlocking лише на вході

Дуже важлива дисципліна: runBlocking живе в main, а не в helper‑функціях. Helper‑функції мають бути suspend.

import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val ignore = readIgnoreExtensions("config/organizer.ignore")
    println("Ігнор: $ignore") // Ігнор: [...]

    appendLogLine("logs/organizer.log", "Органайзер запущено")
    println("Лог записано") // Лог записано
}

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

Де ставити withContext, а де — ні

Коли ви тільки починаєте, хочеться знайти «чарівне правило»: наприклад, «уся функція в withContext(IO)». Але правильніше мислити трохи інакше. withContext(Dispatchers.IO) ставиться навколо саме тих рядків коду, які торкаються файлової системи. Не «навколо функції», не «навколо модуля», а навколо конкретного I/O‑блоку.

Нормальна схема потоку керування

Щоб закріпити картинку в голові, зручно уявити це так:

flowchart TD
    A[main/runBlocking] --> B[Сценарій/логіка]
    B --> C["withContext(Dispatchers.IO)"]
    C --> D[File API / Streams / Readers]
    D --> C
    C --> B
    B --> A

Сенс схеми такий: логіка викликає I/O‑цеглинку, I/O‑цеглинка «пірнає» в IO‑диспетчер, робить справу й повертає результат назад у логіку.

Чому не варто тримати withContext(IO) «надто широким»

Якщо ви напишете так: «уся команда organize працює в IO‑контексті», ви позбавите себе можливості чітко відокремити обчислення від файлів. Інколи це призведе до того, що CPU‑частина опиниться в IO‑пулі (який узагалі-то призначений для очікування I/O), і продуктивність стане непередбачуваною.

Грубо кажучи, широкий withContext(IO) перетворює IO‑контекст на смітник «для всього, що не знаю куди покласти». А ми якраз учимося робити навпаки: розкладати все по поличках.

5. Типові помилки: Dispatchers.IO і use { ... }

Помилка № 1: робити runBlocking усередині helper‑функції, «бо так простіше викликати suspend».
Це виглядає як хитрощі, але по суті ви самі собі ставите палицю в колесо: блокуєте потік, ламаєте structured concurrency і отримуєте дивні підвисання в неочікуваних місцях. Якщо функція робить I/O — нехай вона буде suspend, а запуск корутинного світу залишається в main.

Помилка № 2: відкривати bufferedReader()/inputStream() і забувати закрити, бо «ну воно ж маленьке».
«Маленьке сьогодні» не означає «маленьке завтра». Витоки файлових дескрипторів накопичуються, і в якийсь момент ОС скаже «досить» (зазвичай — найнеприємнішим способом). use { ... } — це не прикраса, а страховка: ресурс закриється за будь-якого результату, включно з винятками.

Помилка № 3: пхати важку обробку всередину withContext(Dispatchers.IO), бо «це ж усе про файл».
Файл — це джерело даних, але сортування, агрегації й складний розбір — це CPU‑робота. Якщо тримати її в IO‑контексті, ви отримаєте код, який складно оптимізувати й складно аналізувати. Розділяйте: прочитали в IO, обробили зовні.

Помилка № 4: робити I/O‑функцію «універсальним комбайном», який і читає, і валідує, і друкує, і логує.
Такий код швидко стає нечитабельним: у нього десять причин змінитися, і кожна нова фіча додає ще одну гілку. Набагато спокійніше мати кілька маленьких функцій: readLinesIo, appendLogLine, writeLinesIo. Так, їх більше. Зате кожна — зрозуміла й передбачувана.

Помилка № 5: забувати про return@withContext і намагатися використовувати звичайний return усередині блоку.
Це частий «перший удар компілятора по голові» в корутинах. Якщо всередині withContext потрібно вийти раніше, використовуйте return@withContext. Це не примха Kotlin, а спосіб зробити керування потоком явним і безпечним.

Помилка № 6: змішувати підготовку каталогів (mkdirs) і запис так, що за помилки виходить напівробочий стан.
Навіть у простих функціях корисно дотримуватися дисципліни: спочатку створити каталог (якщо потрібно), потім відкрити writer у use, а вже потім писати. Так ви знижуєте шанс залишити систему в дивному проміжному стані, особливо коли згодом додадуться скасування й паралельність.

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