JavaRush /Курсы /Kotlin SELF /Знакомство с корутинами

Знакомство с корутинами

Kotlin SELF
52 уровень , 0 лекция
Открыта

1. Долгие операции и Thread.sleep

Прежде чем бросаться в новые термины, давайте честно признаем: слово «асинхронность» звучит так, будто сейчас будет либо магия, либо боль. Чаще всего — оба варианта, но с разными пропорциями. Мы начнём с очень бытового наблюдения: некоторые действия занимают время, и если вы в этот момент «зажали» единственный поток выполнения, то программа просто перестаёт двигаться дальше.

Представьте, что ваш код — это очередь в кофейне. Если бариста (поток) на 20 секунд ушёл в подсобку ждать поставку молока, очередь не «параллелится» от того, что люди в очереди очень стараются. Очередь стоит. В программах происходит то же самое, только вместо молока — сеть, диск, база данных или просто таймер.

Самый честный пример блокировки — Thread.sleep(...). Он делает паузу, но платой за паузу становится то, что поток ничего больше делать не может.

fun main() {
    println("A")              // A
    Thread.sleep(300)
    println("B")              // B
}

Этот пример не «плохой». Он просто демонстрирует поведение: между "A" и "B" выполнение полностью остановилось.

Иногда блокировки накапливаются настолько незаметно, что вы удивляетесь: «Почему программа такая медленная? Я же ничего особенного не делаю». А вы делаете. Вы ждёте. И ждёте блокирующим способом.

fun main() {
    Thread.sleep(200)
    Thread.sleep(200)
    println("Done")           // Done
}

Что именно блокируется: поток

Слово «поток» (thread) звучит так, будто это что-то из сантехники: где-то течёт, где-то перекрыли кран. На самом деле аналогия почти удачная. Поток — это «дорожка выполнения» кода: по ней ваш main идёт шаг за шагом. И если на дорожке поставить бетонный блок (Thread.sleep, блокирующее чтение, долгий запрос), то движение по дорожке останавливается до тех пор, пока блок не уберут.

В Kotlin/JVM обычная консольная программа стартует с потока, который часто называется "main". Мы можем увидеть это так:

fun main() {
    println(Thread.currentThread().name) // main
}

Теперь важное: блокируется не «ваша функция», не «строчка кода», не «переменная». Блокируется поток, на котором это выполняется. Если у вас один поток — заморозка тотальная. Если потоков несколько — заморозка локальная, но вы платите другой ценой (мы к этому подойдём).

Посмотрим на пример, который имитирует «долгую загрузку профиля». Это не сеть и не база данных — просто задержка, чтобы почувствовать модель.

fun loadProfileBlocking(id: Int): String {
    Thread.sleep(200)               // имитируем ожидание
    return "User#$id"
}

fun main() {
    val name = loadProfileBlocking(1)
    println(name)                   // User#1
}

Пока выполняется Thread.sleep, поток "main" занят ожиданием. Он не «переключается» на другие задачи, потому что у него… нет других задач, и он сам «уснул».

Почему «просто больше потоков» не панацея

После предыдущего раздела обычно появляется идея, которая звучит как план: «Окей, раз один поток блокируется — заведём второй, третий, сотый!». И это действительно иногда работает. Но тут начинается реальность: потоки — ресурс операционной системы, и они относительно «тяжёлые». Создавать их пачками дорого, а держать тысячи потоков в ожидании — ещё дороже.

В официальной документации Kotlin это формулируется очень прямолинейно: распространённый способ конкурентности — потоки, но потоки «тяжёлые», и их массовое создание может приводить к проблемам производительности. Именно на этом месте и появляется мотивация корутин как более лёгкой единицы выполнения.

Попробуем почувствовать «тяжесть» потоков без профайлеров и шаманских бубнов. Если вы сделаете программу, которая запускает много задач, и каждая «висит» в sleep, то потоки будут заняты ожиданием. С точки зрения человека это выглядит так: «они же ничего не делают, просто ждут». Но с точки зрения системы вы держите много активных сущностей, которыми нужно управлять.

В этой лекции мы не будем строить многопоточный ад (это отдельный жанр хоррора), но важно запомнить мысль: потоки — не бесплатные. Поэтому нужен способ ожидать так, чтобы не держать поток «заложником».

2. Приостановка: что такое корутины

Корутина как приостанавливаемая задача

Сейчас будет ключевая идея дня. Корутина — это лёгкая единица выполнения, которую можно приостановить и потом продолжить. При этом приостановка устроена так, что она не обязана удерживать поток всё время ожидания.

Если продолжать кофейную аналогию, то поток — это бариста, а корутина — это заказ. Если заказ «ждёт печку 2 минуты», бариста не обязан стоять и смотреть на печку гипнотическим взглядом. Он может заняться другим заказом. Потом печка «пикнула», и бариста возвращается к первому заказу и продолжает с того же места.

Вот очень грубая схема различия:

flowchart TD
    A[Поток main выполняет код] --> B{Нужно подождать?}
    B -- Да, Thread.sleep --> C[Поток блокируется и ничего не делает]
    C --> D[Ожидание закончилось]
    D --> E[Продолжаем выполнение]

    B -- Да, suspend/delay --> F[Корутина приостанавливается]
    F --> G[Поток может выполнять другую работу]
    G --> H[Ожидание закончилось]
    H --> I[Корутина продолжает с того же места]

Пока это звучит почти как «волшебная многозадачность», но важная поправка: корутина — не поток. Корутина может выполняться на потоке, потом приостановиться, а потом продолжиться (в зависимости от настроек и окружения) — и всё это без того, чтобы выделять отдельный поток на каждое ожидание.

suspend как контракт

Теперь мы подходим к слову, которое вы будете видеть постоянно: suspend. В Kotlin корутины построены вокруг приостанавливающих функций (suspending functions). Документация формулирует это так: корутины позволяют писать асинхронный код последовательным стилем, а suspend помечает функции, которые могут приостанавливать выполнение.

Супер-важная мысль для новичка: suspend — это не «ускорение» и не «запуск в другом потоке». Это маркер контракта: внутри функции могут быть точки приостановки. То есть функция может сказать: «Я сейчас подожду, но подожду корректно, не обязательно блокируя поток».

Пример «подготовленной» функции, которая в теории может быть долгой:

suspend fun loadUserName(): String {
    return "Alice"
}

Да, пример слишком простой. Но он показывает форму: добавился suspend, а возвращаемый тип остался нормальным String. Это важное отличие Kotlin-подхода от подходов, где вместо String вам возвращают «обёртку-обещание» (типа Future/Promise). Kotlin старается сохранить ощущение обычного кода.

Есть и обратная сторона: suspend-функцию нельзя просто так вызвать откуда угодно. Если вы попробуете сделать так, компилятор вас остановит:

suspend fun loadUserName(): String = "Alice"

fun main() {
    // val name = loadUserName() // ОШИБКА компиляции: suspend function should be called only from a coroutine or another suspend function
}

Это не вредность Kotlin. Это защита смысла: если функция может приостанавливаться, её нельзя вызвать из «обычного мира», который не умеет ждать такую приостановку.

Точки приостановки и delay

Пока у нас есть слово suspend, но непонятно, где именно происходит пауза. Для этого есть понятие точки приостановки (suspension point). Это место, где корутина может временно остановиться и отдать поток другим задачам, а потом продолжиться.

Классический пример — delay(...) из библиотеки kotlinx.coroutines. Он похож на Thread.sleep(...) по смыслу («подожди»), но отличается по механике: delay — приостанавливающая функция, и она не обязана блокировать поток во время ожидания. Эта идея прямо связана с тем, что корутины могут «suspend without blocking system resources».

Пока мы не изучили, как запускать корутинный код правильно (это следующая лекция), но форму можно увидеть уже сейчас:

import kotlinx.coroutines.delay

suspend fun slowHello(): String {
    delay(100)               // точка приостановки
    return "Hello"
}

Обратите внимание на дисциплину: функция честно помечена как suspend, потому что внутри есть delay. Это очень здоровая привычка: если внутри реальное ожидание — контракт должен быть честным. Если ожидания нет — не надо лепить suspend «на всякий случай», иначе вы сами усложните себе жизнь.

3. Инструменты: kotlinx.coroutines и вход в корутинный мир

Сейчас важный анти-миф. Корутины в Kotlin — это не «магия компилятора, которая всегда включена». Большая часть практических инструментов корутин живёт в библиотеке kotlinx.coroutines. Документация прямо говорит, что большинство возможностей корутин предоставляет именно эта библиотека.

Отсюда вытекают два бытовых следствия.

Первое: вам понадобятся зависимости (Gradle/Maven) и импорты. Это нормально: Kotlin-стандартная библиотека не тащит за собой всё на свете.

Второе: чтобы выполнить suspend-код, нужен «вход» в корутинный мир. Минимальный пример, который просто показывает каркас (без деталей, мы разберём их дальше), выглядит так:

import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    println("We are inside coroutine world") // We are inside coroutine world
}

Здесь runBlocking — буквально «блокирующий мост» из обычного main в корутинный блок. Он блокирует текущий поток до завершения блока, но внутри блока вы уже можете вызывать suspend-функции. Мы подробно разберём это в следующей лекции — сегодня нам важно просто понять, почему такой мост вообще нужен.

4. Мини-практический пример: синхронизация расходов

Чтобы не оставаться на уровне терминов, давайте заведём маленький фрагмент «практического приложения», которое мы сможем улучшать в следующих лекциях дня. Представим, что у нас есть консольный трекер расходов (мы уже умеем хранить данные, печатать отчёты, работать с коллекциями), и теперь мы хотим добавить команду «синхронизировать данные». Синхронизация — это почти всегда ожидание (сеть, диск, сервер), то есть идеальный кандидат на корутины.

Сначала сделаем плохую, но понятную блокирующую версию: она честно «зависает», пока идёт синхронизация.

data class Expense(val title: String, val amount: Int)

fun syncExpensesBlocking(): List<Expense> {
    Thread.sleep(400) // имитируем долгую синхронизацию
    return listOf(
        Expense("Coffee", 250),
        Expense("Pizza", 700)
    )
}

Теперь пусть main вызывает эту функцию и печатает результат:

fun main() {
    println("Sync started...")             // Sync started...
    val expenses = syncExpensesBlocking()
    println("Sync done: ${expenses.size}") // Sync done: 2
}

С точки зрения консоли всё «окей». Но с точки зрения поведения это ровно тот случай, когда наш поток не делает ничего, кроме ожидания. И если бы это была не учебная консоль, а, например, UI-приложение, пользователь бы увидел «приложение не отвечает». Именно поэтому в документации корутины часто объясняют как способ выполнять задачи конкурентно и не блокировать друг друга.

Как будет выглядеть «честный контракт» в корутинном стиле (пока без запуска)? Мы бы сделали синхронизацию suspend-функцией:

import kotlinx.coroutines.delay

suspend fun syncExpenses(): List<Expense> {
    delay(400) // вместо Thread.sleep: приостановка, а не блокировка
    return listOf(
        Expense("Coffee", 250),
        Expense("Pizza", 700)
    )
}

Заметьте, что здесь меняется не логика «верни список», а способ ожидания и контракт вызова. Именно ради этого (и ради возможности запускать несколько таких операций грамотно) мы и идём в корутины: хотим уметь ждать так, чтобы код оставался читаемым, но ожидание не превращалось в «заморозку всего вокруг».

5. Типичные ошибки при знакомстве с корутинами

Ошибка №1: думать, что «корутина = поток».
Это очень популярная ловушка из серии «вроде похоже, значит одно и то же». Поток — ресурс ОС, довольно тяжёлый. Корутина — более лёгкая единица выполнения, которую можно приостанавливать и продолжать, не создавая отдельный поток на каждую задачу. Именно это противопоставление «threads are heavy» vs «coroutines are lightweight» и является одним из главных мотиваторов корутин.

Ошибка №2: ожидать, что suspend автоматически делает код параллельным и быстрым.
Suspend — это не «ускоритель». Это метка контракта: функция может приостанавливаться. Параллельность появляется не от слова suspend, а от того, как и где вы запускаете корутины. Kotlin специально старается сохранить обычную, последовательную модель кода, чтобы вы писали «сверху вниз», но ожидали корректно.

Ошибка №3: воспринимать корутины как «встроенную часть языка без библиотек».
В Kotlin действительно есть ключевое слово suspend, но большая часть практических инструментов — delay, launch, async и так далее — приходит из kotlinx.coroutines. Поэтому если «не компилируется импорт» — это не кара небесная, а напоминание: нужна зависимость и правильные импорты.

Ошибка №4: путать «приостановку» и «блокировку» на уровне ощущений.
Снаружи кажется, что и Thread.sleep(1000), и delay(1000) — «подождали секунду». Но смысл разный: Thread.sleep блокирует поток, а delay приостанавливает корутину, оставляя шанс потоку заняться другой работой. Эта разница особенно важна, когда задач много и ожидания частые.

Ошибка №5: пытаться вызвать suspend-функцию из обычного main и злиться на компилятор.
Компилятор здесь ваш союзник: он не даёт вам случайно написать код, который «не умеет» корректно жить в мире приостановок. Чтобы вызвать suspend-код, нужен корутинный контекст (например, мост через runBlocking, который мы разберём в следующей лекции).

1
Задача
Kotlin SELF, 52 уровень, 0 лекция
Недоступна
Пауза кассира
Пауза кассира
1
Задача
Kotlin SELF, 52 уровень, 0 лекция
Недоступна
Два стоп-кадра
Два стоп-кадра
1
Задача
Kotlin SELF, 52 уровень, 0 лекция
Недоступна
Тикер турникета
Тикер турникета
1
Задача
Kotlin SELF, 52 уровень, 0 лекция
Недоступна
Отчёт сервера
Отчёт сервера
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ