launch, async и runBlocking: что когда выбирать

Два запроса к серверу, каждый по секунде. Экран грузится две секунды. Вы точно помните, что корутины нужны как раз для того, чтобы так не было:

viewModelScope.launch {
    val user = api.getUser()     // 1 секунда
    val feed = api.getFeed()     // ещё одна
    render(user, feed)
}

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

Три билдера и чем они отличаются

Билдер — это функция, которая создаёт корутину и запускает её. Их три, и путаница между ними порождает больше проблем, чем любая другая тема в корутинах.

Что возвращаетБлокирует потокКогда нужен
launchJobнетдействие без результата
asyncDeferred<T>нетнужен результат
runBlockingрезультат блокадамост из обычного кода

launch — выстрелил и забыл

launch запускает работу и не возвращает результата. Он отдаёт Job — ручку, которой можно отменить корутину или дождаться её через join().

Это рабочая лошадка: сохранить, отправить, залогировать, обновить экран. Если результат не нужен прямо в коде — берите launch.

async — когда нужен результат

async возвращает Deferred<T> — обещание результата. Забрать его можно вызовом await(). Именно так чинится пример из начала статьи:

viewModelScope.launch {
    val user = async { api.getUser() }
    val feed = async { api.getFeed() }
    render(user.await(), feed.await())   // ~1 секунда вместо двух
}

Обе корутины стартуют сразу, ждут параллельно, и общее время равно максимуму из двух, а не сумме.

А вот так делать бессмысленно — это снова последовательный код, только с лишними буквами:

val user = async { api.getUser() }.await()   // сразу ждём
val feed = async { api.getFeed() }.await()   // и только потом запускаем вторую

Правило простое: async имеет смысл, только если между запуском и await() вы успеваете запустить что-то ещё.

Вторая особенность async — исключения. У launch ошибка улетает вверх по дереву корутин сразу, у async она сохраняется внутри Deferred и выбрасывается при await(). Поэтому и ловить её нужно вокруг await().

runBlocking — мост, а не билдер на каждый день

runBlocking запускает корутину и блокирует текущий поток, пока та не закончит. То есть делает ровно то, от чего корутины должны избавлять.

Законных применений у него три:

  • мост из обычного блокирующего кода в корутинный — например, из старого легаси;
  • обёртка вокруг main(), чтобы программа не завершилась раньше корутин;
  • тесты.

Причём для второго и третьего давно есть замена: main() с Kotlin 1.3 можно объявить как suspend, а для тестов существует runTest с виртуальным временем.

В Android-коде runBlocking почти всегда ошибка. Если он оказался в главном потоке — интерфейс замирает, и приложение получает ANR.

Что обычно спрашивают на собеседовании

«Чем launch отличается от async» — вопрос-разминка, но ответ «один возвращает Job, другой Deferred» засчитывается наполовину. Хороший ответ добавляет про исключения: у launch они уходят вверх немедленно, у async ждут await(). И про то, что параллельность даёт не сам async, а то, что вы не вызываете await() сразу.

Подробнее про дерево корутин и то, кто кого ждёт, — в руководстве по корутинам.