Зачем корутины вместо потоков и как работает suspend

Первый вопрос блока про асинхронность — и лучший индикатор того, понимает ли кандидат механику или просто «пишет как в примерах».

Корутина — не лёгкий поток, а объект с состоянием, который умеет приостанавливаться и возобновляться. Приостановка освобождает поток: пока корутина ждёт, поток выполняет другую работу.

Вопрос 1: «Чем корутина отличается от потока?»

Что проверяет интервьюер

Умеете ли вы объяснить не «корутины удобнее», а почему их можно запустить сто тысяч, а потоков — нет.

Развёрнутый ответ

Поток — это ресурс операционной системы. Под него выделяется стек (в JVM обычно порядка мегабайта), его создание и переключение контекста стоят времени ядра. Тысяча одновременных потоков — это гигабайт памяти и планировщик, который большую часть времени занимается переключениями, а не работой.

Корутина — объект в куче. Её «стек» — это цепочка объектов Continuation, а не системный стек. Корутины исполняются на пуле потоков: одна и та же горстка потоков обслуживает тысячи корутин, потому что ожидающая корутина поток не занимает.

ПотокКорутина
Кто управляетОСрантайм Kotlin
Цена созданиявысокая, ~1 МБ стекаобъект в куче, сотни байт
Ожиданиепоток заблокированпоток освобождён
Отменанебезопасная, устаревшаявстроенная, кооперативная
Реалистичный масштабдесятки-сотнидесятки тысяч

Хорошая формулировка для интервью: корутина — это не «более лёгкий поток», а способ писать асинхронный код в последовательном стиле, не блокируя поток на ожидании.

Вопрос 2: «Что делает ключевое слово suspend?»

Что проверяет интервьюер

Главное заблуждение, которое здесь ищут: «suspend означает, что функция выполнится в фоновом потоке». Это неверно.

Развёрнутый ответ

suspend помечает функцию как способную приостановиться. Компилятор преобразует её в конечный автомат (техника CPS — continuation-passing style): добавляет скрытый параметр Continuation, разбивает тело на участки между точками приостановки и сохраняет между ними состояние.

suspend fun loadUser(id: Long): User {
    val dto = api.getUser(id)      // точка приостановки
    val user = dto.toDomain()
    db.save(user)                  // ещё одна точка приостановки
    return user
}

// упрощённо компилятор превращает это в нечто вроде
// fun loadUser(id: Long, continuation: Continuation<User>): Any

Когда корутина доходит до точки приостановки, она сохраняет состояние в Continuation и возвращает управление потоку. Поток свободен и уходит выполнять другие задачи. Когда результат готов, планировщик вызывает continuation.resume(value), и выполнение продолжается — возможно, уже на другом потоке того же диспетчера.

Отсюда ключевой вывод: suspend сам по себе никуда не переключает. Если внутри suspend-функции написать блокирующий вызов, он заблокирует ровно тот поток, на котором корутина работает — часто главный.

// ПЛОХО: suspend, но блокирует поток
suspend fun badDelay() {
    Thread.sleep(1000)     // главный поток встал на секунду
}

// ХОРОШО: приостанавливает корутину, поток свободен
suspend fun goodDelay() {
    delay(1000)
}

Отсюда же требование, которое называют main-safety: suspend-функция обязана быть безопасной для вызова с главного потока. Ответственность за переключение лежит на самой функции, а не на вызывающем коде:

class UserRepository(private val io: CoroutineDispatcher) {

    // вызывающий может звать откуда угодно, включая главный поток
    suspend fun loadFromDisk(id: Long): User = withContext(io) {
        heavyBlockingRead(id)
    }
}

Такой контракт — стандарт архитектуры Android: ViewModel просто вызывает repository.loadFromDisk(id) и не думает о диспетчерах.

Вопрос 3: «Чем suspend отличается от blocking?»

Blocking-вызов удерживает поток: тот не может делать ничего другого, пока не дождётся. Suspending-вызов отпускает поток. Пара сравнений, которые полезно назвать:

  • Thread.sleep() блокирует, delay() приостанавливает.
  • Future.get() блокирует, Deferred.await() приостанавливает.
  • Синхронный call.execute() в Retrofit блокирует, suspend fun в интерфейсе Retrofit приостанавливает.
  • runBlocking блокирует вызывающий поток до завершения корутины — уместен в тестах и в main() консольного приложения, но не в Android-коде.

Типичные ошибки кандидатов

  • «suspend выполняет код в фоне» — нет, за поток отвечает диспетчер, а не модификатор.
  • Оборачивают вызов репозитория в withContext(Dispatchers.IO) прямо во ViewModel. Это признак того, что репозиторий не main-safe: переключение должно быть внутри него.
  • Используют runBlocking в Android, «чтобы вызвать suspend-функцию» — получают ANR.
  • Говорят «корутина — это лёгкий поток», не умея объяснить, за счёт чего именно лёгкий.

Как ответить кратко (20–30 секунд)

«Поток — ресурс ОС со своим стеком примерно в мегабайт и дорогим переключением контекста; корутина — объект в куче, которая исполняется на пуле потоков. Ключевое свойство: приостановка не блокирует поток, поэтому корутин можно держать десятки тысяч. suspend компилируется в конечный автомат с Continuation: в точке приостановки состояние сохраняется, поток освобождается, потом выполнение возобновляется. Важно, что suspend сам по себе не переключает поток — за это отвечает диспетчер. Поэтому suspend-функции делаю main-safe: withContext внутри репозитория, а не во ViewModel.»

Проверьте себя
1. Что происходит с потоком, когда корутина доходит до вызова delay(1000)?
Aпоток блокируется на секунду, как при Thread.sleep
Bпоток освобождается и может выполнять другие корутины, состояние сохраняется в Continuation
Cсоздаётся новый поток, а текущий завершается
Dкорутина переносится на Dispatchers.IO автоматически
2. Функция помечена suspend, но внутри вызывает Thread.sleep(2000). Что произойдёт при вызове её из viewModelScope без указания диспетчера?
Aничего страшного: suspend-функции всегда выполняются в фоне
Bбудет заблокирован главный поток на две секунды — модификатор suspend не переключает поток
Cкомпилятор не даст собрать проект: Thread.sleep запрещён в suspend-функциях
Dкорутина автоматически уйдёт на Dispatchers.IO
3. Что означает требование main-safety для suspend-функции?
Aфункция должна вызываться только из главного потока
Bфункция обязана возвращать результат в главный поток через withContext(Dispatchers.Main)
Cфункцию можно безопасно вызвать с главного потока: переключение на нужный диспетчер она делает сама
Dфункция не должна быть помечена suspend, если работает с UI