Зачем корутины вместо потоков и как работает 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.»