Files
obsidian/Kotlin/Корутины/Диспетчеры и контекст.md
T

405 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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["Контекст родителя:<br/>Job#1 + Dispatchers.IO + Name"] --> B["launch { }"]
B --> C["Контекст ребёнка:<br/>Job#2 (parent = Job#1)<br/>+ Dispatchers.IO + Name"]
A --> D["launch(Dispatchers.Default) { }"]
D --> E["Контекст ребёнка:<br/>Job#3 (parent = Job#1)<br/>+ 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<RequestId>
}
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<String?>()
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)