Avatar ·

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

Всем привет! Начал изучать корутины в Kotlin и запутался в двух подходах: launch/join и async/await. Вроде бы оба позволяют запускать задачи параллельно и дожидаться их завершения, но я не понимаю, когда что использовать. У меня есть простой пример: нужно скачать два файла и объединить их. Я попробовал оба способа, но результаты разные. Первый способ с launch/join: ```kotlin fun main() = runBlocking { var result1 = "" var result2 = "" val job1 = launch { delay(1000) result1 = "File1 content" } val job2 = launch { delay(1000) result2 = "File2 content" } job1.join() job2.join() println("$result1 $result2") } ``` Второй способ с async/await: ```kotlin fun main() = runBlocking { val deferred1 = async { delay(1000) "File1 content" } val deferred2 = async { delay(1000) "File2 content" } println("${deferred1.await()} ${deferred2.await()}") } ``` Оба работают, но я слышал, что async/await лучше подходит для получения результатов, а launch/join — для запуска фоновых задач. Но ведь launch тоже может изменять внешние переменные, так в чем принципиальная разница? И когда какой подход использовать? Буду благодарен за объяснение с примерами из реальной жизни. <<>>

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

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

Launch/Join: для задач без результата

launch используется, когда вам нужно выполнить действие, которое не возвращает полезного значения (аналог void в Java). Типичные примеры:

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

Метод join() просто ждет завершения задачи, но не дает доступа к результату. Если вам нужно получить данные, приходится использовать внешние переменные, что ведет к мутабельным состояниям и потенциальным проблемам с потоками:

// Плохой стиль: мутабельная переменная
var result = ""
val job = launch {
    result = fetchData()
}
job.join()
println(result) // Работает, но опасно в многопоточном контексте

Async/Await: для задач с результатом

async предназначен для вычислений, которые возвращают значение. Он возвращает Deferred<T> — специальный объект, который можно рассматривать как обещание (promise) получить результат в будущем. Метод await() приостанавливает корутину до получения результата и возвращает его:

// Правильный стиль: иммутабельный результат
val deferred = async {
    fetchData()
}
val result = deferred.await()
println(result) // Типобезопасно и потокобезопасно

Когда использовать каждый подход

Используйте launch/join, когда:

  • Вам нужно выполнить побочный эффект (запись в лог, отправка сообщения, обновление кэша)
  • Результат не важен или обрабатывается внутри корутины
  • Вы запускаете задачи, которые должны быть выполнены в определенном порядке, но их результаты не нужны

Используйте async/await, когда:

  • Каждая корутина возвращает данные, которые нужны для дальнейшей работы
  • Вы хотите запустить несколько независимых вычислений параллельно и собрать их результаты
  • Нужна типобезопасность — компилятор проверит, что вы используете результат правильного типа

Практический пример: парсинг веб-страниц

Допустим, нам нужно загрузить 3 страницы и извлечь из них заголовки:

// Неправильно: используем launch
var titles = mutableListOf<String>()
val jobs = urls.map { url ->
    launch {
        val content = fetchUrl(url)
        val title = parseTitle(content)
        synchronized(titles) { titles.add(title) }
    }
}
jobs.forEach { it.join() }
println(titles) // Работает, но с блокировками

// Правильно: используем async
val deferreds = urls.map { url ->
    async {
        val content = fetchUrl(url)
        parseTitle(content)
    }
}
val titles = deferreds.awaitAll()
println(titles) // Просто и безопасно

Как видите, async/await делает код чище, безопаснее и короче. launch/join же лучше оставить для задач, которые не требуют возврата данных.

<<>>

Дополнение: обработка ошибок и структурированная конкурентность

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

// launch: ошибка убьет родителя
val job = launch {
    throw RuntimeException("Ошибка")
}
job.join() // Исключение уже выброшено

// async: ошибка отложена
val deferred = async {
    throw RuntimeException("Ошибка")
}
try {
    deferred.await() // Исключение выбросится здесь
} catch (e: Exception) {
    println("Обработано: ${e.message}")
}

Это делает async предпочтительным для параллельных вычислений, где вы хотите контролировать, как обрабатывать ошибки каждой задачи отдельно. Например, если одна из трёх загрузок не удалась, вы можете пропустить её результат, а не прерывать весь процесс.

Также помните: async не начинает выполнение немедленно — он стартует корутину сразу при создании (в отличие от Java Future). Но результат можно получить только через await(). Используйте awaitAll() для ожидания списка Deferred — это стандартный паттерн для параллельных операций.