JavaRush /Курсы /Kotlin SELF /withContext и dispatcher в корутине: переключение места в...

withContext и dispatcher в корутине: переключение места выполнения

Kotlin SELF
53 уровень , 1 лекция
Открыта

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 — это не «параллельно», это «в другом месте, но по-прежнему последовательно».

Чтобы не путаться, зафиксируем сравнение в таблице:

Инструмент Что делает Возвращает Когда использовать
launch { ... }
создаёт новую корутину для действий
Job
когда нужно выполнить действие параллельно и потом (возможно) дождаться
async { ... }
создаёт новую корутину для результата
Deferred<T>
когда нужно параллельно посчитать значение и потом сделать await()
withContext(ctx) { ... }
не создаёт новую корутину, а выполняет кусок в другом контексте результат блока (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-кода.
withContextsuspend, и компилятор здесь ваш союзник: он не даёт «случайно» сделать приостановку там, где это невозможно. Если вам нужно использовать withContext, значит функция должна стать suspend, а вызов — происходить из корутины (например, из runBlocking или из другой suspend-функции).

Ошибка №5: раскидать withContext повсюду «на всякий случай».
Если в проекте появляется привычка «оборачивать всё в withContext», код становится шумным и теряет ясность: вы уже не понимаете, где реальная причина переключения, а где просто «ритуал». withContext хорош, когда он объясняет смысл: «вот тут I/O», «вот тут вычисления». Если смысла нет — лучше не добавлять.

1
Задача
Kotlin SELF, 53 уровень, 1 лекция
Недоступна
Смена смены
Смена смены
1
Задача
Kotlin SELF, 53 уровень, 1 лекция
Недоступна
Настройка ретраев
Настройка ретраев
1
Задача
Kotlin SELF, 53 уровень, 1 лекция
Недоступна
Данные и статистика
Данные и статистика
1
Задача
Kotlin SELF, 53 уровень, 1 лекция
Недоступна
Нормализация текста
Нормализация текста
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ