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 — це не «паралельно». Це «в іншому місці, але так само послідовно».
Щоб не плутатися, зафіксуймо порівняння в таблиці:
| Інструмент | Що робить | Повертає | Коли використовувати |
|---|---|---|---|
|
створює нову корутину для дій | |
коли потрібно виконати дію паралельно й потім (можливо) дочекатися |
|
створює нову корутину для результату | |
коли потрібно паралельно обчислити значення й потім зробити await() |
|
не створює нову корутину, а виконує шматок коду в іншому контексті | результат блоку (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-коду.
withContext — suspend, і компілятор тут ваш союзник: він не дає «випадково» зробити призупинення там, де це неможливо. Якщо вам потрібно використовувати withContext, отже, функція має стати suspend, а виклик має відбуватися з корутини (наприклад, з runBlocking або з іншої suspend-функції).
Помилка №5: розкидати withContext всюди «про всяк випадок».
Якщо в проєкті зʼявляється звичка «обгортати все в withContext», код стає шумним і втрачає ясність: ви вже не розумієте, де реальна причина перемикання, а де просто «ритуал». withContext добрий, коли він пояснює сенс: «ось тут I/O», «ось тут обчислення». Якщо сенсу немає, краще не додавати.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ