launch/join vs async/await в Kotlin: что выбрать в 2025? Полный разбор с примерами
Привет, коллега! Если ты когда-нибудь писал на Kotlin корутины, то наверняка задавался вопросом: «А в чём, собственно, разница между launch и async? И зачем нужен этот загадочный join?». Этот вопрос — настоящий хит на Stack Overflow, набравший тысячи просмотров. И неспроста: на первый взгляд оба инструмента делают одно и то же — запускают фоновую задачу. Но дьявол, как всегда, в деталях.
В 2025 году, когда Kotlin coroutines стали мейнстримом в Android-разработке и бэкенде, понимание этих различий — не просто прихоть, а необходимость для написания чистого, безопасного и производительного кода. В этой статье мы не просто перечислим сухие факты. Мы разберём реальные сценарии, посмотрим на код и выведем лучшие практики, которые можно смело применять в своих проектах прямо сейчас.
Поехали!
launch: «Запустил и забыл»
Функция launch — это, пожалуй, самый простой способ запустить корутину. Она создаёт новую корутину, которая не возвращает результат. Её задача — выполнить какую-то работу: обновить UI, отправить лог, записать данные в файл. Тип возвращаемого значения — Job. Это как дать задание курьеру: «Отвези посылку и возвращайся, отчёт не нужен».
Метод join позволяет дождаться завершения этой корутины. Он приостанавливает выполнение текущей корутины до тех пор, пока job, вызвавшая join, не завершится.
Рассмотрим пример. Допустим, нам нужно параллельно загрузить две картинки, а потом объединить их. С помощью launch и join это будет выглядеть так:
import kotlinx.coroutines.*
suspend fun loadImage(url: String): Image {
// имитация загрузки
delay(1000)
return Image(url)
}
data class Image(val url: String)
suspend fun loadTwoImagesWithLaunch() = coroutineScope {
var image1: Image? = null
var image2: Image? = null
val job1 = launch {
image1 = loadImage("https://example.com/img1.png")
}
val job2 = launch {
image2 = loadImage("https://example.com/img2.png")
}
// Ждём обе задачи
job1.join()
job2.join()
// Теперь image1 и image2 гарантированно загружены
println("Images loaded: $image1 and $image2")
}
Что здесь важно? Мы используем изменяемые переменные (var), чтобы сохранить результат. Это не очень красиво и может привести к проблемам с потоками, хотя корутины и работают в рамках одного потока. Но главный минус — мы не можем использовать launch для возврата значения напрямую.
async: «Запустил и получи результат»
А вот async — это уже «бизнес-класс». Он создаёт корутину, которая возвращает результат в виде объекта Deferred<T>. Это как заказать такси: вы ждёте, и вам возвращается не просто факт поездки, а конкретный результат — вы на месте. Чтобы получить этот результат, используется await().
Тот же пример с загрузкой картинок, но с использованием async/await, становится элегантнее:
import kotlinx.coroutines.*
suspend fun loadImage(url: String): Image {
delay(1000)
return Image(url)
}
data class Image(val url: String)
suspend fun loadTwoImagesWithAsync() = coroutineScope {
val deferred1 = async { loadImage("https://example.com/img1.png") }
val deferred2 = async { loadImage("https://example.com/img2.png") }
// await() приостанавливает корутину, пока не получит результат
val image1 = deferred1.await()
val image2 = deferred2.await()
println("Images loaded: $image1 and $image2")
}
Чувствуете разницу? Никаких var, никакого ручного управления. Всё чисто, функционально и типобезопасно. async явно говорит: «Я создаю задачу, которая вернёт значение типа Image». А await — это точка, где мы это значение забираем.
Ключевое отличие: возвращаемое значение
Давайте зафиксируем главное различие в таблице:
| Характеристика | launch / join | async / await |
|---|---|---|
| Возвращаемое значение | Job (не содержит результат) | Deferred<T> (содержит результат) |
| Цель | Fire-and-forget (запустил и забыл) | Получение результата асинхронной операции |
| Обработка исключений | Исключение пробрасывается в родительскую корутину | Исключение «заворачивается» в Deferred, всплывает при await() |
| Типичное применение | Логирование, обновление UI, запись в БД без возврата | Параллельные HTTP-запросы, вычисления, загрузка данных |
Обработка ошибок: подводные камни
Вот где начинается самое интересное. В 2025 году Structured Concurrency (структурированная конкурентность) — это не просто модное слово, а основа правильной работы с корутинами. И launch с async ведут себя в этом плане по-разному.
Если внутри launch возникает исключение, оно мгновенно пробрасывается наверх, в родительскую корутину (если не перехвачено). Это может привести к отмене всех дочерних корутин. С async ситуация иная: исключение не распространяется автоматически. Оно «запоминается» внутри Deferred и выбрасывается только в момент вызова await().
Пример:
import kotlinx.coroutines.*
fun main() = runBlocking {
// Пример с launch
val job = launch {
throw RuntimeException("Ошибка в launch!")
}
job.join() // Исключение будет выброшено здесь
println("После launch") // Эта строка НЕ выполнится
// Пример с async
val deferred = async {
throw RuntimeException("Ошибка в async!")
}
// Исключение пока не выброшено!
println("После async") // Эта строка выполнится