405 lines
24 KiB
Markdown
405 lines
24 KiB
Markdown
---
|
||
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)
|