--- 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,
Spring MVC"| E["виртуальные потоки
или корутины + IO"] D -->|"suspend API: Ktor,
R2DBC"| C A --> F{"Нужны потоки данных,
отмена по дереву?"} 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)