Kotlin guide done. (20.10.26)

This commit is contained in:
2026-10-20 12:54:25 +03:00
parent da3ad9dd1a
commit 6853ada348
81 changed files with 30112 additions and 0 deletions
@@ -0,0 +1,344 @@
---
title: Корутины против потоков
description: Реальная стоимость потока и корутины, сравнение с виртуальными потоками Java и случаи, когда нужны именно потоки
tags:
- kotlin
- coroutines
---
Поток - ресурс ОС: стек в памяти, запись в планировщике ядра, переключение через системный вызов. Корутина - объект в куче, переключение которого - обычный вызов метода.
```kotlin
import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis
fun main() = runBlocking {
val coroutines = measureTimeMillis {
List(100_000) { launch { delay(100) } }.forEach { it.join() }
}
println("100 000 корутин: уложились в секунду: ${coroutines < 1000}")
// 100 000 корутин: уложились в секунду: true
}
```
Сто тысяч потоков создать нельзя - JVM упадёт с `OutOfMemoryError` ещё на нескольких десятках тысяч.
## Стоимость в цифрах
| Характеристика | Поток ОС | Корутина |
|---|---|---|
| память | около 1 МБ стека (`-Xss`) | десятки-сотни байт |
| создание | системный вызов, ~50-100 мкс | аллокация объекта, ~100 нс |
| переключение | через ядро, 1-10 мкс | вызов метода, ~10 нс |
| разумный максимум | тысячи | миллионы |
| планировщик | ядро ОС | библиотека, в пользовательском коде |
```kotlin
import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis
fun main() = runBlocking {
val threadTime = measureTimeMillis {
List(1000) { Thread { Thread.sleep(10) } }.onEach { it.start() }.forEach { it.join() }
}
val coroutineTime = measureTimeMillis {
List(1000) { launch { delay(10) } }.forEach { it.join() }
}
println("1000 потоков: ${threadTime} мс, 1000 корутин: ${coroutineTime} мс")
println("корутины быстрее: ${coroutineTime < threadTime}")
// корутины быстрее: true
}
```
Разница здесь не в самом ожидании (оба спят по 10 мс), а в накладных расходах на создание тысячи объектов ядра.
## Где именно экономия
Корутины не делают работу быстрее - они устраняют простой потоков.
```kotlin
import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis
fun main() = runBlocking {
// блокировка: 4 задачи на 1 потоке = 4 × 100 мс
val blocking = measureTimeMillis {
withContext(Dispatchers.Default.limitedParallelism(1)) {
List(4) { launch { Thread.sleep(100) } }.forEach { it.join() }
}
}
// приостановка: 4 задачи на 1 потоке = 100 мс
val suspending = measureTimeMillis {
withContext(Dispatchers.Default.limitedParallelism(1)) {
List(4) { launch { delay(100) } }.forEach { it.join() }
}
}
println("блокировка около 400 мс: ${blocking >= 380}")
println("приостановка около 100 мс: ${suspending < 200}")
// блокировка около 400 мс: true
// приостановка около 100 мс: true
}
```
Тот же один поток, та же работа - разница в четыре раза. Именно это окупает корутины на сервере, обслуживающем много одновременных запросов: поток не стоит в ожидании ответа базы, а берёт следующий запрос.
## Корутины не помогают при счёте
```kotlin
import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis
fun heavy(): Long = (1..20_000_000L).sum()
fun main() = runBlocking {
val sequential = measureTimeMillis { repeat(4) { heavy() } }
val parallel = measureTimeMillis {
withContext(Dispatchers.Default) {
List(4) { async { heavy() } }.awaitAll()
}
}
val oneThread = measureTimeMillis {
withContext(Dispatchers.Default.limitedParallelism(1)) {
List(4) { async { heavy() } }.awaitAll()
}
}
println("параллельно на ядрах быстрее: ${parallel < sequential}")
println("на одном потоке выигрыша нет: ${oneThread >= parallel}")
// параллельно на ядрах быстрее: true
// на одном потоке выигрыша нет: true
}
```
Вывод: для вычислений выигрыш даёт только параллелизм по ядрам, а не количество корутин. Тысяча корутин на четырёх ядрах считает ровно столько же, сколько четыре. Корутины здесь удобны как способ организации кода, а не как ускорение.
## Что происходит при блокировке
```kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val cores = Runtime.getRuntime().availableProcessors()
println("ядер в системе: ${cores > 0}")
val finished = withTimeoutOrNull(300) {
withContext(Dispatchers.Default) {
// занимаем все потоки Default блокирующими вызовами
List(cores) { launch { Thread.sleep(500) } }
launch { println("эта корутина не получит поток вовремя") }
}
true
}
println("диспетчер оказался парализован: ${finished == null}")
// ядер в системе: true
// диспетчер оказался парализован: true
}
```
Это главный практический риск корутин: один блокирующий вызов в неправильном диспетчере останавливает несвязанные задачи. Поток, заблокированный на `Thread.sleep` или JDBC, не может выполнять другие корутины. Подробнее: [Диспетчеры и контекст](Диспетчеры%20и%20контекст.md)
## Виртуальные потоки Java
Project Loom решает ту же задачу иначе: делает блокировку дешёвой.
```kotlin
fun main() {
println("JVM: ${Runtime.version().feature()}")
// Java 21+: Thread.ofVirtual().start { ... } — виртуальный поток,
// который при блокирующем вызове освобождает несущий поток ОС.
// Thread.sleep(1000) на нём стоит почти столько же, сколько delay(1000) в корутине.
}
```
Сравнение подходов:
| | Корутины | Виртуальные потоки |
|---|---|---|
| где видно асинхронность | в типе: `suspend` | нигде, код обычный |
| существующий блокирующий код | нужно переписать | работает как есть |
| отмена | по дереву, встроена | `interrupt`, вручную |
| структурированность | обязательна | `StructuredTaskScope`, опционально |
| потоки данных | `Flow` | нет аналога, нужен Reactive Streams |
| платформы | JVM, Android, Native, JS, Wasm | только JVM 21+ |
| `ThreadLocal` | нужен `asContextElement` | работает (но дорог при миллионах) |
| готовность экосистемы | библиотеки с `suspend` API | любая блокирующая библиотека |
**Главный плюс Loom** - обратная совместимость: миллионы строк блокирующего кода и библиотек (JDBC, Apache HttpClient, старые ORM) внезапно масштабируются без изменений.
**Главный плюс корутин** - асинхронность видна компилятору. Из `suspend` в сигнатуре вырастают структурированная конкурентность, отмена по дереву, `Flow` с backpressure и проверка «это нельзя вызвать из UI-потока» на этапе компиляции. С виртуальными потоками компилятор не знает, где функция заблокируется, поэтому такие гарантии недоступны.
```kotlin
import kotlinx.coroutines.*
// в сигнатуре видно, что функция может приостановиться
suspend fun loadUser(id: Int): String {
delay(10)
return "пользователь $id"
}
// а здесь не видно ничего: может считать 1 нс, может ждать сеть 10 с
fun loadUserBlocking(id: Int): String {
Thread.sleep(10)
return "пользователь $id"
}
fun main() = runBlocking {
println(loadUser(1))
println(loadUserBlocking(2))
// пользователь 1
// пользователь 2
}
```
Второй вариант нельзя безопасно вызвать из UI-потока, и ни компилятор, ни тип этого не подскажут.
Для Android вопрос не стоит: виртуальных потоков там нет, корутины - стандарт. Для серверного Kotlin на JVM 21+ оба подхода рабочие, и выбор определяется экосистемой: Ktor и Exposed дают `suspend` API, Spring MVC и JDBC - блокирующий.
```mermaid
graph TD
A["Нужна конкурентность"] --> B{"Платформа?"}
B -->|"Android, Native, JS"| C["корутины"]
B -->|"JVM 21+"| D{"Код и библиотеки?"}
D -->|"блокирующие: JDBC,<br/>Spring MVC"| E["виртуальные потоки<br/>или корутины + IO"]
D -->|"suspend API: Ktor,<br/>R2DBC"| C
A --> F{"Нужны потоки данных,<br/>отмена по дереву?"}
F -->|"да"| C
```
## Когда нужны именно потоки
Корутины не универсальны.
**1. Блокирующий код, который нельзя переписать.** JDBC, `java.io`, нативные библиотеки через JNI. Их место - `Dispatchers.IO` или отдельный пул:
```kotlin
import kotlinx.coroutines.*
suspend fun queryDatabase(): String = withContext(Dispatchers.IO) {
Thread.sleep(20) // здесь мог быть JDBC
"строка из БД"
}
fun main() = runBlocking {
println(queryDatabase()) // строка из БД
}
```
**2. Код, требующий привязки к потоку.** Некоторые библиотеки используют `ThreadLocal` или требуют вызовов из одного и того же потока (OpenGL, часть UI-тулкитов, нативные SDK):
```kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
@OptIn(DelicateCoroutinesApi::class)
val dedicated = newSingleThreadContext("нативный-поток")
try {
withContext(dedicated) {
println("всегда один и тот же поток: ${Thread.currentThread().name}")
}
} finally {
dedicated.close()
}
// всегда один и тот же поток: нативный-поток
}
```
**3. Приоритеты и реальное время.** `Thread.setPriority`, потоки реального времени, привязка к ядру - у корутин этого нет вообще.
**4. Бесконечный цикл без точек приостановки.** Корутина, которая никогда не приостанавливается, занимает поток диспетчера полностью, и её нельзя отменить. Для такой задачи нужен выделенный поток.
## Измерение: сколько живёт корутина
```kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val before = Runtime.getRuntime().let { it.totalMemory() - it.freeMemory() }
val jobs = List(200_000) { launch { delay(5000) } }
val after = Runtime.getRuntime().let { it.totalMemory() - it.freeMemory() }
val perCoroutine = (after - before) / 200_000
println("200 000 корутин создано: ${jobs.size}")
println("байт на корутину (порядок величины): ${perCoroutine in 1..5000}")
jobs.forEach { it.cancel() }
// 200 000 корутин создано: 200000
// байт на корутину (порядок величины): true
}
```
Измерение грубое (GC работает параллельно), но порядок виден: сотни байт против мегабайта у потока. Именно поэтому «корутина на каждый запрос» или «корутина на каждое соединение» - нормальная архитектура, а «поток на каждый запрос» перестаёт работать на нескольких тысячах.
## Выбор на практике
| Задача | Решение |
|---|---|
| много одновременных сетевых запросов | корутины |
| обработка HTTP-запросов на сервере | корутины или виртуальные потоки |
| асинхронность в Android | корутины, других вариантов нет |
| параллельные вычисления | `Dispatchers.Default`, корутин по числу ядер |
| JDBC и блокирующие драйверы | корутины + `Dispatchers.IO`, либо виртуальные потоки |
| нативная библиотека с привязкой к потоку | выделенный поток |
| задача с приоритетом ОС | поток |
| поток событий с backpressure | `Flow` |
> [!info] Отличия от Java
> | Java | Kotlin |
> |---|---|
> | `new Thread()` | `launch` |
> | `ExecutorService` | `CoroutineScope` + диспетчер |
> | `Thread.sleep` (блокирует) | `delay` (приостанавливает) |
> | `Thread.ofVirtual()` (Java 21+) | корутины |
> | `StructuredTaskScope` (Java 21+) | `coroutineScope` |
> | `ThreadLocal` | элемент контекста корутины |
> | «поток на запрос» не масштабируется | «корутина на запрос» - норма |
>
> **Модель «поток на запрос» и её предел.** Классический Java-сервер выделяет поток на каждое соединение. При 10 000 одновременных соединений это 10 000 потоков, около 10 ГБ стеков и планировщик ядра, занятый переключениями. Именно отсюда выросли NIO, Netty и реактивное программирование - с их колбэками и нечитаемыми стектрейсами. Корутины дают масштабируемость NIO, сохраняя линейный код:
> ```kotlin
> import kotlinx.coroutines.*
>
> suspend fun handleRequest(id: Int): String {
> delay(10) // здесь мог быть запрос к БД
> return "ответ $id"
> }
>
> fun main() = runBlocking {
> val responses = List(10_000) { async { handleRequest(it) } }.awaitAll()
> println("обработано запросов: ${responses.size}") // обработано запросов: 10000
> }
> ```
> Десять тысяч «обработчиков запросов» на несколько потоков - в Java до Loom это требовало полностью асинхронного стека.
>
> **Виртуальные потоки - ответ Java на ту же проблему.** `Thread.ofVirtual().start { }` создаёт поток, который при блокирующем вызове освобождает несущий поток ОС. По стоимости это сравнимо с корутиной, а главное преимущество - существующий блокирующий код работает без изменений. Цена - отсутствие информации об асинхронности в типах, из-за чего нет ни отмены по дереву, ни `Flow`, ни проверок на этапе компиляции. Loom добирает часть этого отдельными API (`StructuredTaskScope`), но они опциональны и появились на 8 лет позже корутин.
>
> **`CompletableFuture` - промежуточное решение, которое проиграло оба раза.** Он даёт неблокирующий код, но ценой разрыва линейности: лямбды, протаскивание переменных через замыкания, неудобные `if` и циклы. Корутины и виртуальные потоки решают задачу так, что код остаётся последовательным - просто разными способами.
>
> **`ThreadLocal` стоит дорого при миллионе потоков.** Это ограничение Loom, о котором говорят меньше: `ThreadLocal` на миллион виртуальных потоков - это миллион копий значения. Для корутин элемент контекста наследуется деревом и копий не создаёт.
>
> **Отладка: стектрейсы.** Поток даёт полный стектрейс бесплатно - это заметный плюс и потоков, и виртуальных потоков. У корутины стектрейс содержит кадры только от последней точки возобновления, а историю приостановок восстанавливает библиотека `kotlinx-coroutines-debug` или флаг `-Dkotlinx.coroutines.debug`. Без них отладка асинхронного кода труднее, чем у потоков.
>
> Сравнить: [Взаимодействие между потоками в Java](../../Программирование%20на%20Java/Многопоточность/Взаимодейтсвие%20между%20потоками.md), [JVM](../../Программирование%20на%20Java/JVM/JVM.md)
> [!warning] Подводные камни
> **Блокирующий вызов отменяет всю выгоду:**
> ```kotlin
> import kotlinx.coroutines.*
> import kotlin.system.measureTimeMillis
>
> fun main() = runBlocking {
> val t = measureTimeMillis {
> withContext(Dispatchers.Default.limitedParallelism(2)) {
> List(10) { launch { Thread.sleep(50) } }.forEach { it.join() }
> }
> }
> println("10 задач по 50 мс на 2 потоках: ${t >= 240}") // 10 задач по 50 мс на 2 потоках: true
> }
> ```
> Корутины превратились в обычный пул потоков с очередью. Весь смысл - в `delay` вместо `Thread.sleep`, то есть в приостановке вместо блокировки.
>
> **«Корутин больше - значит быстрее» неверно для вычислений.** На четырёх ядрах сорок счётных корутин работают не быстрее четырёх, а переключений становится больше. Число параллельных вычислительных задач должно соответствовать числу ядер.
>
> **Стектрейсы хуже, чем у потоков.** Исключение из корутины содержит кадры от точки возобновления, а не всю логическую цепочку вызовов. Это реальная цена корутин, которую частично компенсирует `kotlinx-coroutines-debug`.
>
> **Корутина без точек приостановки не отменяется и занимает поток целиком.** Бесконечный счётный цикл в корутине хуже, чем в потоке: поток можно хотя бы прервать средствами ОС, а корутину - нет.
>
> **`ThreadLocal` не работает так, как ожидается.** Любая библиотека, полагающаяся на `ThreadLocal` (MDC в логах, `SecurityContext` в Spring, транзакции Hibernate), в корутинах требует `asContextElement` или специальной интеграции. С виртуальными потоками такой проблемы нет.
>
> **Профилировщики и инструменты знают о потоках, а не о корутинах.** В дампе потоков вы увидите `DefaultDispatcher-worker-1`, а не свои корутины; счётчики JMX считают потоки. Для корутин нужны отдельные инструменты - `kotlinx-coroutines-debug` или поддержка в IDE.
>
> **Переход на корутины не бесплатен архитектурно.** `suspend` поднимается по всему стеку вызовов до точки входа. Если наверху - не-suspend интерфейс (`Comparator`, `equals`, Java-колбэк), приостановку туда не поместить, и придётся либо менять архитектуру, либо блокировать поток через `runBlocking`.
>
> **Смешивание корутин и блокирующего кода даёт худшее из двух миров.** Половина кода на `suspend`, половина блокирующая, `runBlocking` в середине - такая система и не масштабируется, и сложна в отладке. Границу между блокирующим и приостанавливаемым кодом нужно держать явной и единственной.
>
> **Корутины не отменяют гонок и deadlock'ов.** Разделяемое изменяемое состояние требует синхронизации точно так же, как в потоках, а два `Mutex` в разном порядке дают взаимную блокировку. Подробнее: [Разделяемое состояние](Разделяемое%20состояние.md)
>
> **Измерения на микробенчмарках обманчивы.** JIT-компиляция, прогрев и GC искажают результаты в десятки раз. Цифры в этой заметке - порядки величин, а не точные значения; для реальных измерений нужен JMH.
Далее: [Взаимодействие с Java](../Взаимодействие%20с%20Java/Взаимодействие%20с%20Java.md)