24 KiB
title, description, tags
| title | description | tags | ||
|---|---|---|---|---|
| Корутины против потоков | Реальная стоимость потока и корутины, сравнение с виртуальными потоками Java и случаи, когда нужны именно потоки |
|
Поток - ресурс ОС: стек в памяти, запись в планировщике ядра, переключение через системный вызов. Корутина - объект в куче, переключение которого - обычный вызов метода.
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 нс |
| разумный максимум | тысячи | миллионы |
| планировщик | ядро ОС | библиотека, в пользовательском коде |
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 мс), а в накладных расходах на создание тысячи объектов ядра.
Где именно экономия
Корутины не делают работу быстрее - они устраняют простой потоков.
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
}
Тот же один поток, та же работа - разница в четыре раза. Именно это окупает корутины на сервере, обслуживающем много одновременных запросов: поток не стоит в ожидании ответа базы, а берёт следующий запрос.
Корутины не помогают при счёте
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
}
Вывод: для вычислений выигрыш даёт только параллелизм по ядрам, а не количество корутин. Тысяча корутин на четырёх ядрах считает ровно столько же, сколько четыре. Корутины здесь удобны как способ организации кода, а не как ускорение.
Что происходит при блокировке
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, не может выполнять другие корутины. Подробнее: Диспетчеры и контекст
Виртуальные потоки Java
Project Loom решает ту же задачу иначе: делает блокировку дешёвой.
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-потока» на этапе компиляции. С виртуальными потоками компилятор не знает, где функция заблокируется, поэтому такие гарантии недоступны.
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 - блокирующий.
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 или отдельный пул:
import kotlinx.coroutines.*
suspend fun queryDatabase(): String = withContext(Dispatchers.IO) {
Thread.sleep(20) // здесь мог быть JDBC
"строка из БД"
}
fun main() = runBlocking {
println(queryDatabase()) // строка из БД
}
2. Код, требующий привязки к потоку. Некоторые библиотеки используют ThreadLocal или требуют вызовов из одного и того же потока (OpenGL, часть UI-тулкитов, нативные SDK):
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. Бесконечный цикл без точек приостановки. Корутина, которая никогда не приостанавливается, занимает поток диспетчера полностью, и её нельзя отменить. Для такой задачи нужен выделенный поток.
Измерение: сколько живёт корутина
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()launchExecutorServiceCoroutineScope+ диспетчерThread.sleep(блокирует)delay(приостанавливает)Thread.ofVirtual()(Java 21+)корутины StructuredTaskScope(Java 21+)coroutineScopeThreadLocalэлемент контекста корутины «поток на запрос» не масштабируется «корутина на запрос» - норма Модель «поток на запрос» и её предел. Классический Java-сервер выделяет поток на каждое соединение. При 10 000 одновременных соединений это 10 000 потоков, около 10 ГБ стеков и планировщик ядра, занятый переключениями. Именно отсюда выросли NIO, Netty и реактивное программирование - с их колбэками и нечитаемыми стектрейсами. Корутины дают масштабируемость NIO, сохраняя линейный код:
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, JVM
[!warning] Подводные камни Блокирующий вызов отменяет всю выгоду:
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в разном порядке дают взаимную блокировку. Подробнее: Разделяемое состояниеИзмерения на микробенчмарках обманчивы. JIT-компиляция, прогрев и GC искажают результаты в десятки раз. Цифры в этой заметке - порядки величин, а не точные значения; для реальных измерений нужен JMH.
Далее: Взаимодействие с Java