--- title: Диспетчеры и контекст description: CoroutineContext как набор элементов, выбор между Default, IO и Main, limitedParallelism и наследование контекста tags: - kotlin - coroutines --- `CoroutineContext` - неизменяемый набор элементов, описывающий, как и где выполняется корутина. Диспетчер - один из этих элементов, отвечающий за поток. ```kotlin import kotlinx.coroutines.* import kotlin.coroutines.ContinuationInterceptor fun main() = runBlocking { println(coroutineContext[ContinuationInterceptor] != null) // true println(coroutineContext[Job] != null) // true println(coroutineContext[CoroutineName]) // null — имени не задали withContext(CoroutineName("загрузка")) { println(coroutineContext[CoroutineName]?.name) // загрузка } } ``` Элементы складываются оператором `+`, а извлекаются по ключу как из `Map`. Ключ диспетчера называется `ContinuationInterceptor` - это более общее понятие «перехватчик продолжений», частным случаем которого является диспетчер. Ключ `CoroutineDispatcher` тоже существует, но помечен `@ExperimentalStdlibApi`. ## Из чего состоит контекст | Элемент | Зачем | |---|---| | `Job` | жизненный цикл, иерархия, отмена | | `CoroutineDispatcher` | на каком потоке выполнять | | `CoroutineName` | имя для отладки и логов | | `CoroutineExceptionHandler` | что делать с необработанной ошибкой | ```kotlin import kotlinx.coroutines.* import kotlin.coroutines.ContinuationInterceptor fun main() = runBlocking { val context = Dispatchers.IO + CoroutineName("задача") + SupervisorJob() withContext(context) { println(coroutineContext[CoroutineName]?.name) // задача println(coroutineContext[ContinuationInterceptor] == Dispatchers.IO) // true } } ``` Сложение не коммутативно по смыслу: при конфликте элементов одного типа побеждает **правый**. ```kotlin import kotlinx.coroutines.* import kotlin.coroutines.CoroutineContext fun main() { val base: CoroutineContext = Dispatchers.IO + CoroutineName("первое") val combined = base + CoroutineName("второе") println(combined[CoroutineName]?.name) // второе } ``` Для двух диспетчеров подряд (`Dispatchers.IO + Dispatchers.Default`) компилятор выдаёт предупреждение о бессмысленности выражения - правило то же, побеждает правый, но писать так незачем. ## Стандартные диспетчеры | Диспетчер | Потоков | Для чего | |---|---|---| | `Dispatchers.Default` | по числу ядер (минимум 2) | вычисления, обработка данных | | `Dispatchers.IO` | до 64 (растёт по требованию) | блокирующий I/O: файлы, JDBC, legacy-HTTP | | `Dispatchers.Main` | один, платформенный | UI (Android, Swing, JavaFX) | | `Dispatchers.Unconfined` | любой, текущий | редкие случаи, не для продакшена | ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { println("runBlocking: ${Thread.currentThread().name}") withContext(Dispatchers.Default) { println("Default: ${Thread.currentThread().name.contains("DefaultDispatcher")}") } withContext(Dispatchers.IO) { println("IO: ${Thread.currentThread().name.contains("DefaultDispatcher")}") } // runBlocking: main // Default: true // IO: true } ``` Обратите внимание: и `Default`, и `IO` показывают `DefaultDispatcher-worker-N` - они **делят один пул потоков**. `IO` - это тот же пул с другим лимитом параллелизма, поэтому переключение между `Default` и `IO` дешёвое и не требует смены потока. ## Default против IO Выбор определяется одним вопросом: код считает или ждёт? ```kotlin import kotlinx.coroutines.* // CPU-bound: больше потоков, чем ядер, только вредит suspend fun compute(): Long = withContext(Dispatchers.Default) { (1..1_000_000).sumOf { it.toLong() } } // блокирующий I/O: поток простаивает, поэтому их нужно много suspend fun readBlocking(): String = withContext(Dispatchers.IO) { Thread.sleep(20) // имитация JDBC или File.readText "прочитано" } fun main() = runBlocking { println(compute()) // 500000500000 println(readBlocking()) // прочитано } ``` Логика лимитов: на `Default` потоков столько же, сколько ядер, потому что больше вычислять всё равно некому - лишние потоки только добавят переключений контекста. На `IO` потоки в основном спят в ожидании ответа, поэтому их держат до 64. Если API уже `suspend` и не блокирует (Ktor-клиент, R2DBC, `kotlinx-io`), диспетчер менять не нужно вообще - это лишнее переключение. ## limitedParallelism Ограничивает число одновременных корутин, не создавая новых потоков: ```kotlin import kotlinx.coroutines.* import java.util.concurrent.atomic.AtomicInteger fun main() = runBlocking { val limited = Dispatchers.IO.limitedParallelism(2) val running = AtomicInteger(0) val maxRunning = AtomicInteger(0) withContext(limited) { repeat(10) { launch { val now = running.incrementAndGet() maxRunning.updateAndGet { m -> maxOf(m, now) } Thread.sleep(20) // занимает поток running.decrementAndGet() } } } println("одновременно выполнялось не больше 2: ${maxRunning.get() <= 2}") // одновременно выполнялось не больше 2: true } ``` Это замена старому `newFixedThreadPoolContext`: вместо отдельного пула используется общий, а ограничивается только параллелизм. Типичное применение - не перегрузить базу данных или внешний API. Важная тонкость: лимит считает корутины, **занимающие поток**, а не существующие. Приостановленная корутина слот освобождает: ```kotlin import kotlinx.coroutines.* import java.util.concurrent.atomic.AtomicInteger fun main() = runBlocking { val limited = Dispatchers.IO.limitedParallelism(2) val inFlight = AtomicInteger(0) val maxInFlight = AtomicInteger(0) withContext(limited) { repeat(10) { launch { val now = inFlight.incrementAndGet() maxInFlight.updateAndGet { m -> maxOf(m, now) } delay(20) // слот освобождён inFlight.decrementAndGet() } } } println("одновременно в полёте больше 2: ${maxInFlight.get() > 2}") // одновременно в полёте больше 2: true } ``` Поэтому `limitedParallelism` - не семафор на число запросов. Если нужно ограничить именно количество одновременных обращений к внешнему API (включая время ожидания ответа), нужен `Semaphore` из `kotlinx.coroutines.sync`. Подробнее: [Разделяемое состояние](Разделяемое%20состояние.md) ## Dispatchers.Main ```kotlin // Android или Swing — требует соответствующей зависимости // implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0") // // viewModelScope.launch { // уже на Main // val data = withContext(Dispatchers.IO) { repository.load() } // textView.text = data // снова на Main // } ``` Главный шаблон UI-программирования: корутина стартует на `Main`, тяжёлую работу уводит в `withContext`, результат обрабатывает снова на `Main` - без коллбэков и `runOnUiThread`. Без платформенной зависимости `Dispatchers.Main` бросит `IllegalStateException` при первом обращении. ## Unconfined ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { withContext(Dispatchers.Unconfined) { println("до delay: ${Thread.currentThread().name}") delay(10) println("после delay на потоке таймера: ${Thread.currentThread().name.contains("DefaultExecutor")}") } // до delay: main // после delay на потоке таймера: true } ``` `Unconfined` не привязывает корутину к потоку: до первой приостановки она идёт в текущем, а после возобновляется в том потоке, который её разбудил. Это ломает любые предположения о потоках, поэтому в продакшене не используется - только в тестах и специальных случаях. ## Наследование контекста Дочерняя корутина наследует контекст родителя, заменяя только `Job`: ```kotlin import kotlinx.coroutines.* import kotlin.coroutines.ContinuationInterceptor fun main() = runBlocking { withContext(Dispatchers.IO + CoroutineName("родитель")) { launch { println("имя: ${coroutineContext[CoroutineName]?.name}") // имя: родитель val inherited = coroutineContext[ContinuationInterceptor] == Dispatchers.IO println("диспетчер унаследован: $inherited") // диспетчер унаследован: true } launch(CoroutineName("ребёнок")) { println("переопределено: ${coroutineContext[CoroutineName]?.name}") // переопределено: ребёнок } } } ``` `Job` ребёнка всегда новый и становится дочерним по отношению к `Job` родителя - именно так строится дерево. Подробнее: [Структурированная конкурентность](Структурированная%20конкурентность.md) ```mermaid graph TD A["Контекст родителя:
Job#1 + Dispatchers.IO + Name"] --> B["launch { }"] B --> C["Контекст ребёнка:
Job#2 (parent = Job#1)
+ Dispatchers.IO + Name"] A --> D["launch(Dispatchers.Default) { }"] D --> E["Контекст ребёнка:
Job#3 (parent = Job#1)
+ Dispatchers.Default + Name"] ``` ## Свои элементы контекста Элемент контекста - способ передать данные через всё дерево корутин: ```kotlin import kotlinx.coroutines.* import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext import kotlin.coroutines.coroutineContext class RequestId(val value: String) : AbstractCoroutineContextElement(RequestId) { companion object Key : CoroutineContext.Key } suspend fun logWithId(message: String) { val id = coroutineContext[RequestId]?.value ?: "нет id" println("[$id] $message") } fun main() = runBlocking { withContext(RequestId("req-42")) { logWithId("начало обработки") launch { logWithId("из дочерней корутины") } } logWithId("вне контекста") // [req-42] начало обработки // [req-42] из дочерней корутины // [нет id] вне контекста } ``` Это корутинный аналог `ThreadLocal`, но работающий правильно при переключении потоков. Применяется для трассировки запросов, идентификаторов пользователя, локали. ## ThreadLocal и корутины ```kotlin import kotlinx.coroutines.* val threadLocal = ThreadLocal() fun main() = runBlocking { threadLocal.set("главный") println("до: ${threadLocal.get()}") withContext(Dispatchers.Default) { println("на другом потоке: ${threadLocal.get()}") // null — другой поток } withContext(Dispatchers.Default + threadLocal.asContextElement("перенесённый")) { println("через asContextElement: ${threadLocal.get()}") // перенесённый } // до: главный // на другом потоке: null // через asContextElement: перенесённый } ``` `ThreadLocal` привязан к потоку, а корутина потоки меняет - поэтому напрямую он не работает. `asContextElement()` переносит значение при каждом переключении. Это важно для MDC в логировании и Spring Security. ## Проверка потока в отладке ```kotlin import kotlinx.coroutines.* fun main() = runBlocking(CoroutineName("корень")) { println(coroutineContext[CoroutineName]?.name) // корень launch(CoroutineName("дочерняя")) { println(coroutineContext[CoroutineName]?.name) // дочерняя }.join() } ``` Запуск с `-Dkotlinx.coroutines.debug` добавляет к имени потока идентификатор корутины (`main @корень#1`), что сильно упрощает чтение логов. > [!info] Отличия от Java > | Java | Kotlin | > |---|---| > | `ExecutorService` передаётся аргументом | диспетчер наследуется из контекста | > | `Executors.newFixedThreadPool(n)` | `Dispatchers.Default` / `limitedParallelism(n)` | > | `Executors.newCachedThreadPool()` | `Dispatchers.IO` | > | `SwingUtilities.invokeLater`, `runOnUiThread` | `Dispatchers.Main` | > | `ThreadLocal` | элемент контекста | > | `MDC` в логировании | `MDCContext` или свой элемент | > | пул = потоки | диспетчер = политика, потоки общие | > > **Пул потоков против диспетчера.** В Java каждый `ExecutorService` владеет своими потоками: четыре пула по 10 потоков - это 40 реальных потоков, даже если 35 из них простаивают. `Dispatchers.Default` и `Dispatchers.IO` делят один пул, а `limitedParallelism` ограничивает только число одновременных задач. Поэтому в Kotlin «ограничить параллелизм до 2 для работы с БД» не стоит двух простаивающих потоков. > > **Контекст наследуется, пул - нет.** В Java пул нужно передавать в каждый асинхронный вызов, и забытый аргумент молча уводит задачу в `ForkJoinPool.commonPool`: > ```java > CompletableFuture.supplyAsync(this::load); // commonPool! > CompletableFuture.supplyAsync(this::load, myExecutor); // правильно > ``` > Эти две строки отличаются одним аргументом и дают совершенно разное поведение под нагрузкой. В Kotlin дочерняя корутина получает диспетчер родителя автоматически, а переопределение - явное и видимое. > > **`ThreadLocal` в асинхронном коде в Java не работает так же** - и там это обходят сложнее. При передаче задачи в пул `ThreadLocal` теряется; Spring решает это через `TaskDecorator` и копирование `SecurityContext`/MDC в каждом месте. В Kotlin элемент контекста путешествует вместе с корутиной по всему дереву, а `asContextElement()` переносит и настоящий `ThreadLocal`, когда он нужен библиотеке. > > **Переключение потока явное и дешёвое.** В Java чтобы вернуться в UI-поток, нужен `invokeLater` или `Handler.post` с коллбэком, который разрывает линейность кода. `withContext(Dispatchers.Main)` - это обычный блок, после которого выполнение продолжается на нужном потоке: > ```kotlin > import kotlinx.coroutines.* > > suspend fun loadAndRender(): String { > val data = withContext(Dispatchers.IO) { Thread.sleep(10); "данные" } > return "отрисовано: $data" // снова на исходном диспетчере > } > > fun main() = runBlocking { println(loadAndRender()) } // отрисовано: данные > ``` > > Сравнить: [Взаимодействие между потоками в Java](../../Программирование%20на%20Java/Многопоточность/Взаимодейтсвие%20между%20потоками.md), [JVM](../../Программирование%20на%20Java/JVM/JVM.md) > [!warning] Подводные камни > **Блокирующий вызов на `Dispatchers.Default` исчерпывает пул:** > ```kotlin > import kotlinx.coroutines.* > import kotlin.system.measureTimeMillis > > fun main() = runBlocking { > val t = measureTimeMillis { > withContext(Dispatchers.Default.limitedParallelism(2)) { > List(6) { launch { Thread.sleep(50) } }.forEach { it.join() } > } > } > println("сериализовалось вместо параллели: ${t >= 150}") // сериализовалось вместо параллели: true > } > ``` > Шесть задач по 50 мс на двух потоках заняли 150 мс вместо 50. На реальном `Default` (число ядер) блокирующий код точно так же съедает все потоки, и вся программа встаёт - включая несвязанные корутины. > > **`Dispatchers.IO` не делает блокирующий код неблокирующим.** Он лишь даёт больше потоков. 64 одновременных JDBC-запроса всё равно упрутся в размер пула соединений, а 1000 корутин на `IO` встанут в очередь. > > **`Dispatchers.Main` падает без платформенной зависимости:** > ```kotlin > import kotlinx.coroutines.* > > fun main() { > val error = runCatching { runBlocking { withContext(Dispatchers.Main) { } } } > .exceptionOrNull() > println(error != null) // true > } > ``` > Сообщение - «Module with the Main dispatcher is missing». Нужен `kotlinx-coroutines-android`, `-javafx` или `-swing`. > > **`newSingleThreadContext` создаёт настоящий поток, который нужно закрывать:** > ```kotlin > import kotlinx.coroutines.* > > fun main() = runBlocking { > @OptIn(DelicateCoroutinesApi::class) > val dispatcher = newSingleThreadContext("мой-поток") > try { > withContext(dispatcher) { > println(Thread.currentThread().name) // мой-поток > } > } finally { > dispatcher.close() // без этого поток живёт до конца процесса > } > } > ``` > API помечено `@DelicateCoroutinesApi` именно из-за этой утечки. Почти всегда вместо него нужен `limitedParallelism(1)`. > > **`Unconfined` возобновляется в неизвестном потоке.** Код после `delay` окажется на потоке таймера, после `Channel.receive` - на потоке отправителя. Любая работа с UI или `ThreadLocal` при этом ломается непредсказуемо. > > **`withContext` внутри цикла - лишние переключения:** > ```kotlin > import kotlinx.coroutines.* > import kotlin.system.measureTimeMillis > > fun main() = runBlocking { > val inside = measureTimeMillis { > repeat(10_000) { withContext(Dispatchers.Default) { it } } > } > val outside = measureTimeMillis { > withContext(Dispatchers.Default) { repeat(10_000) { } } > } > println("снаружи быстрее: ${outside < inside}") // снаружи быстрее: true > } > ``` > > **Передача `Job` в `withContext` рвёт структурированность.** `withContext(Dispatchers.IO + Job())` создаёт корутину без родителя - отмена сверху до неё не дойдёт. Передавайте только диспетчер и имя. > > **Контекст неизменяем, `+` создаёт новый.** `context + CoroutineName("x")` не меняет исходный объект; забытое присваивание результата - тихая ошибка. > > **`ThreadLocal` без `asContextElement` теряется при любом переключении**, в том числе при возобновлении после `delay` на другом потоке. Для MDC в логах это значит, что идентификатор запроса исчезнет из части записей - используйте `MDCContext` из `kotlinx-coroutines-slf4j`. > > **Число потоков `IO` настраивается только через системное свойство** (`kotlinx.coroutines.io.parallelism`) и влияет на весь процесс. Для ограничения конкретного места используйте `limitedParallelism`. > > **Переключение диспетчера для неблокирующего API - чистая потеря.** Если библиотека уже `suspend` и не блокирует, `withContext(Dispatchers.IO)` вокруг неё добавляет переключение контекста и ничего не даёт. Далее: [Отмена и таймауты](Отмена%20и%20таймауты.md)