1. Два способа запускать корутины
Когда вы впервые видите корутины, очень хочется спросить: “Окей, я понял suspend и delay. А как сделать так, чтобы одновременно происходило несколько вещей?” И вот тут появляются два героя дня: launch и async. Они оба запускают корутину, но с разными намерениями: один — “сделай действие”, другой — “вычисли результат”. И если намерение в коде читается правильно, мозг меньше страдает.
Важно зафиксировать: и launch, и async — это “строители корутин” (coroutine builders) из kotlinx.coroutines. В документации Kotlin это прямо описывается: корутины запускаются builder-ами вроде .launch() и .async(), и это расширения на CoroutineScope (то есть их можно вызывать “внутри корутинного окружения”, например внутри runBlocking).
2. launch: запускаем корутину ради действий
Представьте, что вы наняли курьера. Вам не нужно, чтобы он вернул вам “число” или “строку” — вам нужно, чтобы он сходил и сделал: доставил посылку, нажал кнопку, напечатал лог, отправил письмо. Вот это — launch: “запускаем корутину ради побочного эффекта”. Как правило, это println, запись в файл, отправка события, обновление состояния — то есть действие, а не вычисление значения.
Начнём с крошечного примера: мы запускаем две корутины, которые печатают строки с разной задержкой.
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
launch {
delay(100)
println("L1 done") // L1 done
}
launch {
delay(50)
println("L2 done") // L2 done
}
println("Main continues") // Main continues
}
Здесь очень важно морально подготовиться: строка Main continues обычно выводится первой, а L2 done — раньше L1 done, потому что задержка меньше. То есть порядок “не сверху вниз”, а “как успели” — и это не баг, это и есть цель.
Заметьте ещё одну вещь: launch ничего не возвращает как “результат вычисления”. Технически он возвращает Job, но сегодня мы не делаем из этого отдельную тему (управление жизненным циклом корутин — это уже другой разговор). Главное: launch — это про действие, поэтому контракт “результата” у него не в виде Int/String.
Конкурентность vs параллельность
Сейчас будет важная философская минутка на 40–60 слов, обещаю, без занудства. Когда вы запускаете две корутины через launch, вы не обязаны получать “настоящую параллельность” (как два человека, которые одновременно поднимают два разных дивана). Вы получаете конкурентность: задачи могут чередоваться, ожидать, “уступать место” другим. Это уже даёт выигрыш в ожиданиях и удобстве кода — и это то, ради чего мы здесь.
Можно нарисовать простую схему по времени (упрощённо):
sequenceDiagram
participant M as runBlocking/main
participant A as launch #1
participant B as launch #2
M->>A: start
M->>B: start
M->>M: println("Main continues")
A->>A: delay(100)
B->>B: delay(50)
B-->>M: println("L2 done")
A-->>M: println("L1 done")
3. async: корутина, которая обещает результат
Теперь представьте не курьера, а друга-математика (у каждого должен быть такой друг… хотя бы виртуальный). Вы просите: “Посчитай мне сумму налогов” или “Собери статистику”. Вы не хотите просто “факт действия”, вы хотите значение. Вот под это и сделан async.
async { ... } запускает корутину и возвращает объект типа Deferred<T>. Читается это так: “отложенный результат типа T”. Как будто вам дали бумажку: “результат будет позже, держи номерок”.
Почему это важно: вы можете стартовать несколько вычислений, а потом в удобный момент собрать результаты.
Kotlin-документация описывает, что для запуска корутин используются builder-ы вроде .async() так же, как .launch(), и работают они в рамках CoroutineScope.
4. Deferred<T> и await(): “обещание” и “получение”
На этом месте новички часто делают одну и ту же ошибку: думают, что Deferred<Int> — это уже Int. Нет. Это “контейнер ожидания”. Чтобы получить настоящее значение, нужно вызвать await(). И await() — это suspend-операция, потому что она может ждать.
Сначала покажу “ловушку в лоб” — чтобы вы один раз увидели и больше не наступали.
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val d = async { 1 + 2 }
println(d) // DeferredCoroutine{Active}@...
println(d.await()) // 3
}
Здесь println(d) печатает не 3, а внутреннее описание объекта Deferred. А вот d.await() уже возвращает результат.
И давайте добавим маленькую аналогию, без лишней поэзии. Deferred<T> — это как чек из кафе: вы заплатили (запустили вычисление), чек у вас на руках, а бургер (T) вам принесут чуть позже. await() — это как подойти к стойке и сказать: “Я за заказом, вот чек”.
5. Шаблон: несколько async, потом await()
Сейчас мы подходим к главной практической “фишке” async: вы запускаете несколько задач, не ожидая сразу, а потом собираете результаты. Это важно, потому что если вы сделаете await() сразу после каждого async, вы можете случайно превратить всё обратно в последовательное выполнение.
Сравним два подхода. Сначала “неудачный” (в смысле конкурентности): стартовали и тут же ждём.
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val a = async { delay(100); 10 }.await()
val b = async { delay(100); 32 }.await()
println(a + b) // 42
}
Это работает, но логика такая: дождались a, потом начали b. Иногда это нормально, но часто вы хотели другое.
Теперь “правильнее для параллельного ожидания”: сначала запускаем, потом ждём.
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val a = async { delay(100); 10 }
val b = async { delay(100); 32 }
println(a.await() + b.await()) // 42
}
Тут обе задачи стартуют сразу, и общее ожидание обычно ближе к 100 мс, а не к 200 мс (упрощаем, но смысл именно такой).
Этот подход очень похож на примеры из материалов Kotlin, где показывают запуск нескольких concurrent задач через корутины: идея та же — много независимых работ стартуют, дальше мы просто ждём, пока они закончатся.
6. Практический пример: “загрузка профиля” в консоли
Чтобы примеры не были “в вакууме”, давайте соберём небольшой сценарий: консольная программа, которая “загружает профиль пользователя”. По-настоящему мы пока ни в сеть, ни в БД не ходим (это отдельные темы), поэтому будем имитировать долгие операции через delay.
Представим, что нам нужно получить три вещи: имя, баланс и количество уведомлений. С точки зрения бизнес-смысла это независимые куски, и мы можем делать их конкурентно.
Три suspend-функции, которые “долго работают”
Здесь важно написать функции маленькими и честными: если внутри есть delay, функция должна быть suspend.
import kotlinx.coroutines.delay
suspend fun loadUserName(): String {
delay(120)
return "Alice"
}
suspend fun loadBalance(): Int {
delay(200)
return 150
}
suspend fun loadNotifications(): Int {
delay(80)
return 3
}
Уже сейчас можно заметить приятное: функции выглядят как обычные, “сверху вниз”. И это одна из причин, почему Kotlin так любит корутины: асинхронность не превращает код в лапшу из колбэков.
Последовательная версия: просто и честно, но медленнее
Сначала сделаем “как написалось бы по привычке”: вызываем функции одну за другой. Это не ошибка — иногда так и надо. Но мы хотим увидеть разницу.
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val name = loadUserName()
val balance = loadBalance()
val notifications = loadNotifications()
println("$name: $$balance, notifications=$notifications")
// Alice: $150, notifications=3
}
Смысл: мы ждём 120 мс, потом ещё 200, потом ещё 80. В сумме ожидание “накапливается”.
Конкурентная версия: запускаем всё сразу и ждём результаты
Теперь та же логика, но с async. Мы стартуем три вычисления, получаем три “обещания”, а потом извлекаем результаты через await().
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val nameD = async { loadUserName() }
val balanceD = async { loadBalance() }
val notifD = async { loadNotifications() }
println("${nameD.await()}: $${balanceD.await()}, notifications=${notifD.await()}")
// Alice: $150, notifications=3
}
Здесь результат тот же, но “ожидание” должно быть ближе к самому долгому кусочку (в нашем случае это loadBalance() на 200 мс), а не к сумме всех задержек. И вот это уже похоже на реальную жизнь: часто мы ждём сеть/диск, и конкурентность даёт ощущение “приложение быстрее реагирует”, хотя CPU оно не ускоряет магически.
7. Где использовать launch, а где async
Очень легко скатиться в “всё оберну в async, потому что могу”. Обычно это плохая идея: код становится менее выразительным. Нам хочется, чтобы по коду было видно намерение: “я запускаю действие” или “я запускаю вычисление результата”.
Ниже — небольшая таблица, которая помогает выбрать инструмент без гадания на кофейной гуще:
| Ситуация в коде | Что логичнее | Почему |
|---|---|---|
| Нужно напечатать лог / показать сообщение / записать что-то | |
Результат не нужен, важен факт действия |
| Нужно получить значение (число/строку/список) | |
Есть результат, его нужно вернуть и использовать |
| Хотим запустить 2–5 независимых вычислений и потом собрать результаты | |
Хорошо выражает “параллельное ожидание” |
| Хотим сделать фоновую “параллельную” печать/индикатор (упрощённо) | |
Это побочный эффект (вывод), не вычисление |
Обратите внимание: мы пока не обсуждаем тонкости управления жизненным циклом (отмена, Job, иерархии). Сегодня нам важно научиться правильно выбирать форму запуска под задачу, не усложняя.
Можно ли смешивать launch и async
В реальных программах вы часто делаете и то и другое: параллельно “что-то печатаем/логируем” и параллельно “что-то считаем/грузим”. Важно лишь не превращать программу в фейерверк, где всё мигает и никто не понимает, кто за что отвечает.
Давайте сделаем маленький пример: мы параллельно запускаем загрузку данных (async) и печатаем “старт/финиш” отдельными действиями (launch).
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
launch { println("Loading profile...") } // Loading profile...
val nameD = async { delay(100); "Alice" }
val notifD = async { delay(80); 3 }
println("Hi, ${nameD.await()}! You have ${notifD.await()} notifications.")
// Hi, Alice! You have 3 notifications.
}
Да, тут launch выглядит “лишним”, потому что можно было просто сделать println напрямую. Но как учебный пример он показывает разницу намерений: launch — “действие отдельно”, async — “результат отдельно”.
8. Минимально про исключения в корутинах
Когда вы пишете конкурентный код, исключения никуда не исчезают. Они просто становятся более “неожиданными”, если вы не понимаете, где именно вы их “получаете”.
В случае async важная практическая деталь такая: если внутри async { ... } происходит ошибка, то часто она “вылезает” в момент await() (ведь именно там вы пытаетесь получить результат).
Покажу короткий пример: деление на ноль. Не повторяйте дома… хотя нет, повторяйте — и смотрите на поведение.
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val d = async { 10 / 0 }
println("Before await") // Before await
println(d.await()) // ArithmeticException here
}
Мы видим, что программа успевает вывести Before await, а потом падает на await(). Это логично: ошибка была “в вычислении результата”, а await() — это точка, где вы просите результат наружу.
С launch история тоже непростая, но сегодня мы не углубляемся в стратегию обработки исключений в группах корутин. Просто держите в голове честное правило: конкурентность не отменяет необходимость думать об ошибках, она лишь меняет место, где вы их замечаете.
9. Типичные ошибки при работе с launch и async/await
Ошибка №1: использовать launch, когда нужен результат.
Часто новичок запускает launch { val x = ... }, а потом пытается “как-то вернуть x наружу”. Это выглядит как попытка вынести воду решетом: launch задуман как “сделать действие”, и именно поэтому он не даёт вам нормальный контракт результата типа T. Если вам нужно значение, выбирайте async и забирайте результат через await().
Ошибка №2: забывать, что Deferred<T> — не T.
Deferred — это не число и не строка, это “обещание”. Когда вы печатаете Deferred, вы увидите внутреннюю информацию, а не результат. Если вы дальше передаёте Deferred туда, где ожидается Int, компилятор справедливо возмутится. Лечится просто: дисциплина await() именно там, где вам реально нужен результат.
Ошибка №3: делать await() сразу после каждого async, а потом удивляться, что “ускорения нет”.
Если вы пишете async {...}.await() подряд два раза, вы по сути делаете “запустил → дождался”, “запустил → дождался”. Конкурентность теряется, и суммарное ожидание снова становится похоже на сумму задержек. Правильный шаблон: сначала стартуем несколько async, потом ждём.
Ошибка №4: оборачивать корутинами всё подряд без причины.
Иногда самый быстрый способ сделать программу медленнее и запутаннее — добавить конкурентность “на всякий случай”. Если операция мгновенная (например, val x = 1 + 2), async только усложнит код. Корутины особенно полезны там, где есть ожидание или независимые куски работы.
Ошибка №5: путать “корутину” и “поток” и ожидать строгой параллельности.
Корутина — штука лёгкая и умеющая приостанавливаться; поток — тяжёлый ресурс ОС. Корутины могут выполняться конкурентно, и это уже полезно. Но “обязательной параллельности” в стиле “две корутины = два ядра CPU” никто не обещал.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ