24 KiB
title, description, tags
| title | description | tags | ||
|---|---|---|---|---|
| Диспетчеры и контекст | CoroutineContext как набор элементов, выбор между Default, IO и Main, limitedParallelism и наследование контекста |
|
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.IOSwingUtilities.invokeLater,runOnUiThreadDispatchers.MainThreadLocalэлемент контекста 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)вокруг неё добавляет переключение контекста и ничего не даёт.
Далее: Отмена и таймауты