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