Files
obsidian/Kotlin/Корутины/Корутины против потоков.md
T

345 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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)