1. Зачем переключать место выполнения
Когда вы только начинаете с корутинами, легко попасть в ловушку: «Раз у меня suspend, значит можно писать что угодно, и оно само станет быстрым». Увы, нет — корутины не отменяют физику. Есть код, который нагружает CPU (считает, сортирует, сжимает), и есть код, который ждёт I/O (файл, сеть, база, внешняя система). withContext — это способ внутри одной корутины сказать: «вот этот маленький кусок делаем в другом месте», не создавая отдельную задачу и не усложняя структуру.
Важно понимать идею дня: корутины — это про управляемые границы и ясную структуру, а withContext — про локальное, аккуратное переключение условий выполнения, чаще всего — dispatcher’а.
Когда говорят «контекст корутины», легко представить себе что-то философское, но на практике это довольно конкретная штука: набор настроек, которые корутина несёт с собой. В 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) | когда нужна последовательная логика, но участок должен выполняться на другом dispatcher’е |
Если хочется очень короткое правило: launch/async — это «создай отдельную задачу», withContext — это «сделай это здесь, но в другом месте».
5. Смотрим на имя потока и видим переключение
Чтобы поверить в происходящее (а не просто «принять на веру документацию»), полезно вывести имя потока. Да, мы обещали «не думать про потоки», но иногда один раз посмотреть полезно — как в кулинарии: один раз увидеть, что духовка горячая, и дальше уже не проверять рукой каждый раз.
Сделаем маленький логгер:
import kotlinx.coroutines.runBlocking
private fun log(msg: String) {
println("$msg (thread=${Thread.currentThread().name})")
}
fun main() = runBlocking {
log("start")
log("still start")
}
Пример вывода будет зависеть от окружения, но формат вы увидите примерно такой:
start (thread=main)
still start (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("before")
withContext(Dispatchers.Default) {
log("inside Default")
}
log("after")
}
Возможный вывод (примерный):
before (thread=main)
inside Default (thread=DefaultDispatcher-worker-1)
after (thread=main)
И вот здесь важно не сделать неправильный вывод. Это не значит, что «создалась новая корутина». Это значит, что выполнение этого куска кода было передано другому dispatcher’у, а после блока мы вернулись в исходный контекст.
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. В реальном коде вместо этого были бы запросы к файлу/БД, но пока нам важна идея выбора dispatcher’а.
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("Order #${receipt.orderId}, total=${receipt.total}")
// Order #1, total=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», «вот тут вычисления». Если смысла нет — лучше не добавлять.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ