JavaRush /Курси /Kotlin SELF /withContext і dispatcher у корутині: перемикання місця ви...

withContext і dispatcher у корутині: перемикання місця виконання

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

1. Навіщо перемикати місце виконання

Коли ви лише починаєте працювати з корутинами, легко потрапити в пастку: «Раз у мене suspend, то можна писати що завгодно — і воно само стане швидким». На жаль, ні: корутини не скасовують фізики. Є код, який навантажує CPU (рахує, сортує, стискає), і є код, який чекає на I/O (файл, мережа, база, зовнішня система). withContext — це спосіб усередині однієї корутини сказати: «Оцей невеликий шматок зробімо в іншому місці», не створюючи окремого завдання й не ускладнюючи структуру.

Важливо зрозуміти ключову ідею: корутини — це про керовані межі й чітку структуру, а withContext — про локальне, акуратне перемикання умов виконання, найчастіше — диспетчера.

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

Сьогодні нас цікавить насамперед dispatcher. Спрощено (але корисно) можна думати так:

  • dispatcher відповідає за те, на яких потоках і у якому пулі виконується ваш код;
  • Dispatchers.Default зазвичай підходить для CPU-завдань (обчислень);
  • Dispatchers.IO зазвичай підходить для I/O-завдань (очікування введення-виведення).

Намалюймо мінісхему, щоб не тримати все в голові як «хмару»:

flowchart TD
    A[Coroutine] --> B[CoroutineContext]
    B --> C[Job: життєвий цикл]
    B --> D[Dispatcher: де виконуємося]
    D --> E[Dispatchers.Default: CPU]
    D --> F[Dispatchers.IO: I/O]

І ключова думка: withContext змінює контекст виконання для блоку коду (зазвичай змінюють dispatcher), а не «запускає ще одну корутину».

2. Що робить withContext і чому це suspend

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

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

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

Мініприклад безпосередньо:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext

fun main() = runBlocking {
    val x: Int = withContext(Dispatchers.Default) {
        1 + 2 + 3
    }
    println("x = $x") // x = 6
}

Зверніть увагу на приємну деталь: withContext { ... } — це вираз, він повертає значення блоку. Тож ми можемо писати код доволі «звично» — без зайвих тимчасових змінних і без async/await, якщо нам не потрібна паралельність.

4. withContext — не про паралельність

Поширена помилка новачків (і дуже по-людськи зрозуміла): побачити Dispatchers.IO і подумати: «Ага, це ж потоки — значить буде паралельно». Але withContext — це не «паралельно». Це «в іншому місці, але так само послідовно».

Щоб не плутатися, зафіксуймо порівняння в таблиці:

Інструмент Що робить Повертає Коли використовувати
launch { ... }
створює нову корутину для дій
Job
коли потрібно виконати дію паралельно й потім (можливо) дочекатися
async { ... }
створює нову корутину для результату
Deferred<T>
коли потрібно паралельно обчислити значення й потім зробити await()
withContext(ctx) { ... }
не створює нову корутину, а виконує шматок коду в іншому контексті результат блоку (T) коли потрібна послідовна логіка, але ділянка має виконуватися на іншому диспетчері

Якщо хочеться зовсім коротке правило: launch/async — це «створи окреме завдання», а withContext — «зроби це тут, але в іншому місці».

5. Дивимося на імʼя потоку й бачимо перемикання

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

Зробімо маленький логер:

import kotlinx.coroutines.runBlocking

private fun log(msg: String) {
    println("$msg (thread=${Thread.currentThread().name})")
}

fun main() = runBlocking {
    log("початок")
    log("усе ще початок")
}

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

початок (thread=main)
усе ще початок (thread=main)

Тепер додамо withContext і подивімося, чи зміниться потік:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext

private fun log(msg: String) {
    println("$msg (thread=${Thread.currentThread().name})")
}

fun main() = runBlocking {
    log("до")
    withContext(Dispatchers.Default) {
        log("усередині Default")
    }
    log("після")
}

Можливий вивід (приблизний):

до (thread=main)
усередині Default (thread=DefaultDispatcher-worker-1)
після (thread=main)

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

6. Міні-застосунок: консольна каса, CPU та I/O

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

Усередині є два принципово різні етапи:

  • «отримати ціни» — у реальності це могло б бути читання з файла/БД/мережі (тобто I/O), але ми поки зімітуємо це через Thread.sleep;
  • «порахувати суму та знижку» — це обчислення (CPU), їх логічно тримати в Dispatchers.Default.

Спочатку заведемо просту модель даних:

data class Order(
    val id: Int,
    val items: List<String>
)

data class Receipt(
    val orderId: Int,
    val total: Int
)

Тепер напишемо «отримання прайсу». Ми спеціально використовуємо Thread.sleep, щоб підкреслити: це блокувальна імітація I/O. У реальному коді замість цього були б запити до файла/БД, але зараз нам важлива сама ідея вибору диспетчера.

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

suspend fun loadPriceList(): Map<String, Int> =
    withContext(Dispatchers.IO) {
        Thread.sleep(80) // імітація I/O
        mapOf("coffee" to 120, "tea" to 80, "cake" to 150)
    }

Тепер обчислімо суму. Тут ми свідомо використовуємо Default як місце для обчислень:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

suspend fun calculateTotal(order: Order, prices: Map<String, Int>): Int =
    withContext(Dispatchers.Default) {
        order.items.sumOf { item -> prices[item] ?: 0 }
    }

І нарешті зберемо сценарій в одну suspend-функцію:

suspend fun makeReceipt(order: Order): Receipt {
    val prices = loadPriceList()
    val total = calculateTotal(order, prices)
    return Receipt(orderId = order.id, total = total)
}

Зверніть увагу: ми не створювали async, не робили await. Нам не потрібна паралельність — нам потрібна послідовна логіка, але різні ділянки мають виконуватися «у правильному місці».

7. Як це виглядає в main з runBlocking

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

import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val order = Order(id = 1, items = listOf("coffee", "cake"))
    val receipt = makeReceipt(order)

    println("Замовлення #${receipt.orderId}, сума=${receipt.total}")
    // Замовлення #1, сума=270
}

Тут можна впіймати себе на думці: «Зачекайте, а де багатопотоковість?» І це чудове питання. Відповідь така: багатопотоковість — не ціль, а інструмент. Наша ціль — щоб I/O і CPU не заважали одне одному та виконувалися у відповідних пулах. А структура коду водночас лишалася простою.

8. Практичні патерни роботи з withContext

Тримайте withContext вузьким: це скальпель, а не відро

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

Хороша дисципліна виглядає так: ви тримаєте withContext максимально вузьким — лише навколо того місця, де справді потрібен інший dispatcher.

Наприклад, поганий стиль (надто широкий блок, у якому змішані різні типи робіт):

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

suspend fun makeReceiptBad(order: Order): Receipt =
    withContext(Dispatchers.IO) {
        val prices = loadPriceList()
        val total = order.items.sumOf { prices[it] ?: 0 } // це вже CPU-робота
        Receipt(orderId = order.id, total = total)
    }

Краще — як ми робили раніше: I/O відокремили, CPU відокремили, а решту залишили в «нейтральному» коді.

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

У withContext є дуже приємна особливість: він повертає те, що повернув блок. Це допомагає писати компактно, але не перетворювати код на «однорядковий ребус».

Наприклад, інколи зручно зробити «коротку I/O-операцію» прямо в місці використання:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

suspend fun loadDiscountPercent(): Int =
    withContext(Dispatchers.IO) {
        Thread.sleep(30) // імітація читання налаштування
        10
    }

А потім використати її в обчисленнях:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

suspend fun applyDiscount(total: Int, discountPercent: Int): Int =
    withContext(Dispatchers.Default) {
        total - (total * discountPercent / 100)
    }

І ви знову отримуєте читабельну послідовність:

suspend fun makeReceiptV2(order: Order): Receipt {
    val prices = loadPriceList()
    val total = calculateTotal(order, prices)
    val discount = loadDiscountPercent()
    val finalTotal = applyDiscount(total, discount)
    return Receipt(orderId = order.id, total = finalTotal)
}

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

Успадкування dispatcher і локальна заміна

У корутинах важлива ідея: дочірні дії успадковують налаштування від батьківської, зокрема й контекст. Це одна з «цеглинок» структурованого підходу, де корутини живуть «усередині» керованих меж.

Практично це означає:

  • якщо ваш runBlocking/scope запущено на якомусь dispatcher, то launch без явного dispatcher успадкує його;
  • withContext тимчасово «перевизначає» контекст для блоку, а потім повертає його назад.

Можна уявити це так: «усередині однієї операції ми на кілька хвилин переходимо в іншу кімнату, а потім повертаємося».

Коли withContext краще, ніж async/await

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

Порівняйте два підходи.

Варіант А: async/await (створюємо окрему корутину заради результату):

import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope

suspend fun calcTotalAsync(order: Order, prices: Map<String, Int>): Int = coroutineScope {
    val d: Deferred<Int> = async(Dispatchers.Default) {
        order.items.sumOf { prices[it] ?: 0 }
    }
    d.await()
}

Варіант Б: withContext (послідовно, але в потрібному dispatcher):

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

suspend fun calcTotalWithContext(order: Order, prices: Map<String, Int>): Int =
    withContext(Dispatchers.Default) {
        order.items.sumOf { prices[it] ?: 0 }
    }

Другий варіант коротший і, головне, чесніший за змістом: «не паралелимо, а просто виконуємо обчислення там, де треба».

9. Типові помилки під час роботи з withContext і dispatcher

Помилка №1: очікувати паралельності від withContext.
Дуже спокусливо думати: «раз я перемкнувся на Dispatchers.IO, то тепер воно виконується десь паралельно, а я тим часом продовжу». Але ні: withContext не продовжує виконання далі, доки блок не завершиться. Він саме про послідовну логіку. Якщо вам потрібна паралельність, це вже історія про запуск окремої корутини через launch/async.

Помилка №2: робити величезні блоки всередині withContext.
Коли всередину withContext(Dispatchers.IO) потрапляє і «прочитати дані», і «розпарсити», і «порахувати статистику», і «сформувати звіт», ви втрачаєте сенс розділення. У підсумку CPU-робота починає жити в I/O-пулі, а ви потім дивуєтеся, чому все стало «якимось вʼязким». Набагато краще тримати withContext вузьким, як у прикладах: одна функція — один тип роботи.

Помилка №3: використовувати Dispatchers.IO для обчислень і Dispatchers.Default для очікувань.
Це схоже на ситуацію «я смажу суп і варю котлети»: технічно щось вийде, але правильніше навпаки. Default зазвичай беруть під CPU, IO — під очікування. Якщо переплутати, можна отримати зайву конкуренцію за потоки й неприємні затримки.

Помилка №4: намагатися викликати withContext не з suspend-коду.
withContextsuspend, і компілятор тут ваш союзник: він не дає «випадково» зробити призупинення там, де це неможливо. Якщо вам потрібно використовувати withContext, отже, функція має стати suspend, а виклик має відбуватися з корутини (наприклад, з runBlocking або з іншої suspend-функції).

Помилка №5: розкидати withContext всюди «про всяк випадок».
Якщо в проєкті зʼявляється звичка «обгортати все в withContext», код стає шумним і втрачає ясність: ви вже не розумієте, де реальна причина перемикання, а де просто «ритуал». withContext добрий, коли він пояснює сенс: «ось тут I/O», «ось тут обчислення». Якщо сенсу немає, краще не додавати.

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