Kotlin guide done. (20.10.26)
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user