В чем разница между launch/join и async/await в корутинах Kotlin?
Ключевое различие: возвращаемое значение
Главное отличие между 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 — это стандартный паттерн для параллельных операций.