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

24 KiB
Raw Blame History

title, description, tags
title description tags
Корутины против потоков Реальная стоимость потока и корутины, сравнение с виртуальными потоками Java и случаи, когда нужны именно потоки
kotlin
coroutines

Поток - ресурс ОС: стек в памяти, запись в планировщике ядра, переключение через системный вызов. Корутина - объект в куче, переключение которого - обычный вызов метода.

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() launch
ExecutorService CoroutineScope + диспетчер
Thread.sleep (блокирует) delay (приостанавливает)
Thread.ofVirtual() (Java 21+) корутины
StructuredTaskScope (Java 21+) coroutineScope
ThreadLocal элемент контекста корутины
«поток на запрос» не масштабируется «корутина на запрос» - норма

Модель «поток на запрос» и её предел. Классический 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