Avatar ·

В чем разница между launch/join и async/await в корутинах Kotlin?

Привет! Я начал изучать корутины в Kotlin и запутался в двух основных подходах. Когда я пишу код, я вижу, что можно запустить корутину через `launch`, а потом дождаться её завершения через `join()`. А ещё есть `async` с `await()`. На первый взгляд они делают одно и то же — запускают фоновую работу и ждут результата. Но я чувствую, что разница есть, и она важна. Вот простой пример, где я пытаюсь загрузить данные из двух источников и объединить их. С launch/join у меня получается так: ```kotlin fun main() = runBlocking { var data1 = "" var data2 = "" val job1 = launch { delay(1000) // имитация загрузки data1 = "Данные из API 1" } val job2 = launch { delay(1000) data2 = "Данные из API 2" } job1.join() job2.join() println("$data1 + $data2") // Работает, но некрасиво } ``` А с async/await я делаю так: ```kotlin fun main() = runBlocking { val deferred1 = async { delay(1000) "Данные из API 1" } val deferred2 = async { delay(1000) "Данные из API 2" } println("${deferred1.await()} + ${deferred2.await()}") } ``` Оба варианта работают, но мне сказали, что async/await — это более правильный путь для получения результатов. Почему? И когда вообще стоит использовать launch/join, если async/await кажется удобнее? Объясните, пожалуйста, на примерах, в чём ключевое отличие, и когда какой инструмент выбирать. <<>>

Ключевое отличие: возвращаемое значение

Главное различие между launch и async в Kotlin Coroutines — это то, что они возвращают. launch возвращает Job (не имеет результата), а async возвращает Deferred<T> (обещает результат типа T). Это фундаментально меняет подход к работе с корутинами.

Когда использовать launch/join?

launch предназначен для "fire-and-forget" сценариев — когда вам нужно выполнить действие, но не требуется получать от него значение. Примеры:

  • Отправка логов на сервер
  • Обновление UI (например, анимация)
  • Запись данных в файл без ожидания подтверждения
  • Параллельное выполнение задач, где результат — побочный эффект

Ваш пример с launch и мутабельными переменными — это антипаттерн. Он работает, но нарушает принцип иммутабельности и может привести к проблемам с потоками. join() просто ждёт завершения корутины, но не возвращает данные.

// Правильное использование launch
fun saveLog(context: CoroutineScope) {
    context.launch {
        delay(100) // имитация записи
        println("Лог сохранён")
    }
    // Мы не ждём результат, просто запустили
}

Когда использовать async/await?

async создан для случаев, когда корутина должна вернуть значение. Deferred<T> — это легковесный аналог Future или Promise. Вы можете:

  • Параллельно выполнять несколько задач и собирать их результаты
  • Обрабатывать ошибки каждой задачи отдельно
  • Строить цепочки зависимостей
// Пример с async/await
suspend fun fetchUser(id: Int): User = async {
    delay(1000)
    User(id, "Name")
}.await()

// Параллельный запуск
suspend fun loadDashboard(): Dashboard = coroutineScope {
    val user = async { fetchUser(1) }
    val posts = async { fetchPosts() }
    val stats = async { fetchStats() }
    
    Dashboard(
        user = user.await(),
        posts = posts.await(),
        stats = stats.await()
    )
}

Дополнительные различия

  • Обработка исключений: launch выбрасывает исключение сразу (если не перехвачено), а async "заворачивает" его в Deferred — вы получите исключение только при вызове await().
  • Структурированная конкурентность: async внутри coroutineScope или supervisorScope даёт больше контроля над отменой и ошибками.
  • Производительность: Оба варианта имеют схожую производительность, но async создаёт объект Deferred, что может быть чуть дороже для очень большого количества корутин.

Практический совет

Используйте launch для задач без результата (логи, уведомления, обновление кэша). Используйте async когда вам нужно вернуть значение. В вашем примере с загрузкой данных async/await — правильный выбор, так как он чище, безопаснее и следует функциональному стилю.

// Итоговое сравнение
// launch — для действий (выполнить и забыть)
// async — для вычислений (выполнить и получить результат)
<<>>

Дополнение: разница в обработке ошибок и отмене

Есть ещё один важный аспект, который часто упускают — как эти два подхода ведут себя при возникновении исключений.

С launch исключение выбрасывается немедленно в момент возникновения. Если вы не обернули корутину в try-catch, оно "всплывёт" и может крашнуть приложение (или, в случае runBlocking, вызовет исключение сразу).

С async исключение "откладывается" до момента вызова await(). Это даёт возможность:

  • Запустить несколько корутин параллельно и обработать ошибки каждой отдельно
  • Использовать try-catch вокруг await() для точечной обработки
  • Применять CoroutineExceptionHandler для глобальной обработки
// Пример обработки ошибок с async
val result = try {
    async { riskyOperation() }.await()
} catch (e: Exception) {
    "Fallback value"
}

Также стоит упомянуть, что async внутри supervisorScope позволяет одной корутине упасть, не отменяя остальные. С launch в обычном coroutineScope ошибка одной корутины отменит все дочерние. Выбирайте подход в зависимости от того, нужно ли вам изолировать ошибки.