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

24 KiB
Raw Blame History

title, description, tags
title description tags
Диспетчеры и контекст CoroutineContext как набор элементов, выбор между Default, IO и Main, limitedParallelism и наследование контекста
kotlin
coroutines

CoroutineContext - неизменяемый набор элементов, описывающий, как и где выполняется корутина. Диспетчер - один из этих элементов, отвечающий за поток.

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 что делать с необработанной ошибкой
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
    }
}

Сложение не коммутативно по смыслу: при конфликте элементов одного типа побеждает правый.

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 любой, текущий редкие случаи, не для продакшена
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

Выбор определяется одним вопросом: код считает или ждёт?

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

Ограничивает число одновременных корутин, не создавая новых потоков:

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.

Важная тонкость: лимит считает корутины, занимающие поток, а не существующие. Приостановленная корутина слот освобождает:

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. Подробнее: Разделяемое состояние

Dispatchers.Main

// 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

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:

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 родителя - именно так строится дерево. Подробнее: Структурированная конкурентность

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"]

Свои элементы контекста

Элемент контекста - способ передать данные через всё дерево корутин:

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 и корутины

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.

Проверка потока в отладке

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:

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) - это обычный блок, после которого выполнение продолжается на нужном потоке:

import kotlinx.coroutines.*

suspend fun loadAndRender(): String {
    val data = withContext(Dispatchers.IO) { Thread.sleep(10); "данные" }
    return "отрисовано: $data"       // снова на исходном диспетчере
}

fun main() = runBlocking { println(loadAndRender()) }      // отрисовано: данные

Сравнить: Взаимодействие между потоками в Java, JVM

[!warning] Подводные камни Блокирующий вызов на Dispatchers.Default исчерпывает пул:

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 падает без платформенной зависимости:

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 создаёт настоящий поток, который нужно закрывать:

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 внутри цикла - лишние переключения:

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) вокруг неё добавляет переключение контекста и ничего не даёт.

Далее: Отмена и таймауты