Корутины Kotlin на практике: от простых примеров до продвинутых сценариев

Корутины Kotlin на практике: от простых примеров до продвинутых сценариев

Если вы пишете на Kotlin для Android или backend, вы наверняка слышали о корутинах — элегантном инструменте для асинхронного программирования. Но теория теорией, а настоящая сила раскрывается в практике. В этой статье мы разберем конкретные, рабочие примеры использования корутин — от базовых операций до сложных паттернов, которые помогут вам писать отзывчивый, эффективный и понятный код без головной боли с коллбэками.

Что такое корутины и зачем они нужны?

Корутины — это легковесные потоки (threads), которые позволяют приостанавливать и возобновлять выполнение кода без блокировки основного потока. В отличие от традиционных потоков, их можно создавать тысячи без ущерба для производительности. Основная цель — упрощение асинхронного кода, делая его похожим на последовательный.

Ключевая идея: Корутины — не про многопоточность в классическом понимании, а про структурированную конкурентность. Они управляются не ОС, а самим Kotlin (с помощью планировщика).

Базовые примеры: с чего начать

Пример 1: Простой запуск и задержка

Самый простой способ запустить корутину — использовать GlobalScope.launch (хотя в продакшене его используют редко).

import kotlinx.coroutines.*

fun main() {
    println("Основной поток: старт")
    
    GlobalScope.launch {
        delay(1000L) // Неблокирующая задержка на 1 секунду
        println("Корутина: Привет через 1 секунду!")
    }
    
    println("Основной поток: работаю дальше")
    Thread.sleep(2000L) // Блокировка основного потока, чтобы корутина успела выполниться
}

Здесь delay приостанавливает корутину, но не поток. Основной поток продолжает работать.

Пример 2: Получение результата из корутины (async/await)

Для вычисления результата используйте async, который возвращает Deferred (аналог Future/Promise).

suspend fun fetchUserData(): String {
    delay(1500L) // Имитация долгого сетевого запроса
    return "Данные пользователя"
}

suspend fun fetchNews(): String {
    delay(1000L)
    return "Свежие новости"
}

fun main() = runBlocking {
    val time = measureTimeMillis {
        val userDeferred = async { fetchUserData() }
        val newsDeferred = async { fetchNews() }
        
        println("${userDeferred.await()} и ${newsDeferred.await()}")
    }
    println("Выполнено за $time мс") // ~1500 мс, а не 2500!
}

Оба запроса запускаются конкурентно, общее время — время самого долгого.

Практические сценарии использования

Сценарий 1: Загрузка данных с UI (Android)

На Android важно не блокировать главный поток. С корутинами это становится тривиально.

// В Activity или ViewModel
fun loadUserProfile(userId: String) {
    viewModelScope.launch {
        // Показываем индикатор загрузки на UI потоке
        _loadingState.value = true
        
        try {
            // IO-операция в фоне
            val profile = withContext(Dispatchers.IO) {
                repository.fetchProfile(userId)
            }
            // Обновляем UI на главном потоке (Dispatchers.Main по умолчанию в Android)
            _profileState.value = profile
        } catch (e: Exception) {
            _errorState.value = "Ошибка загрузки"
        } finally {
            _loadingState.value = false
        }
    }
}

Важно: Всегда используйте скоупы (viewModelScope, lifecycleScope) вместо GlobalScope в Android, чтобы автоматически отменять корутины при уничтожении компонента.

Сценарий 2: Параллельная обработка коллекций

Обработка большого списка данных с использованием всех ядер процессора.

suspend fun processImages(images: List): List = coroutineScope {
    images.map { image ->
        async(Dispatchers.Default) { // Используем пул для CPU-интенсивных задач
            heavyImageProcessing(image)
        }
    }.awaitAll() // Ждем завершения всех задач
}

Сценарий 3: Таймауты и отмена операций

Корутины предоставляют встроенные механизмы для контроля времени выполнения.

fun fetchDataWithTimeout() = runBlocking {
    try {
        val result = withTimeout(3000L) { // Таймаут 3 секунды
            networkRequest() // Долгая операция
        }
        println("Результат: $result")
    } catch (e: TimeoutCancellationException) {
        println("Запрос отменен по таймауту")
    }
}

// Отмена по требованию
val job = launch {
    repeat(1000) { i ->
        delay(500L)
        println("Работаю $i...")
        if (!isActive) return@launch // Проверяем, не отменена ли корутина
    }
}

delay(2000L)
job.cancelAndJoin() // Отменяем и ждем завершения
println("Работа отменена")

Продвинутые паттерны

Паттерн: Ограничение конкурентности (Semaphore)

Когда нужно ограничить количество одновременно выполняемых операций (например, сетевых запросов).

val semaphore = Semaphore(3) // Не более 3 одновременно

fun makeLimitedRequests(urls: List) = runBlocking {
    urls.map { url ->
        async {
            semaphore.withPermit { // Захватываем разрешение
                makeRequest(url)
            }
        }
    }.awaitAll()
}

Паттерн: Retry при ошибках

Повторная попытка выполнения при неудачном запросе с экспоненциальной задержкой.

suspend fun  retryWithBackoff(
    times: Int = 3,
    initialDelay: Long = 100,
    maxDelay: Long = 1000,
    block: suspend () -> T
): T {
    var currentDelay = initialDelay
    repeat(times - 1) { attempt ->
        try {
            return block()
        } catch (e: Exception) {
            println("Попытка ${attempt + 1} не удалась: ${e.message}")
        }
        delay(currentDelay)
        currentDelay = (currentDelay * 2).coerceAtMost(maxDelay)
    }
    return block() // Последняя попытка
}

FAQ: Часто задаваемые вопросы

Чем корутины лучше RxJava или потоков?

Корутины проще в изучении и использовании, имеют менее крутую кривую обучения. Код выглядит как последовательный, что улучшает читаемость. Они легковеснее потоков и интегрированы в язык.

Нужно ли использовать Dispatchers всегда явно?

Да, это лучшая практика. Явное указание диспетчера (Dispatchers.IO для сетевых/дисковых операций, Dispatchers.Default для CPU-интенсивных задач, Dispatchers.Main для UI) делает код более предсказуемым и эффективным.

Как тестировать код с корутинами?

Используйте runTest из библиотеки kotlinx-coroutines-test. Она позволяет контролировать виртуальное время и избегать реальных задержек в тестах.

Что такое structured concurrency?

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

Можно ли использовать корутины в Java проектах?

Да, но с ограничениями. Kotlin корутины можно вызывать из Java, но создавать и полноценно работать с ними удобнее только из Kotlin-кода.