Конкурентность и многопоточность в Kotlin: корутины, потоки и практические примеры

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Конкурентность и многопоточность в Kotlin: от теории к практике

Современные приложения требуют быстрой и отзывчивой работы. Будь то загрузка данных из сети, обработка больших массивов или работа с пользовательским интерфейсом — без конкурентности и многопоточности не обойтись. Kotlin, будучи современным языком для JVM, Android и мультиплатформенных проектов, предлагает гибкие инструменты для решения этих задач. В этой статье мы разберем ключевые подходы: от классических потоков до легковесных корутин, и покажем, как писать эффективный код.

1. Потоки (Threads) в Kotlin: классический подход

Потоки — это базовый механизм параллельного выполнения, унаследованный от Java. В Kotlin вы можете создавать потоки так же, как в Java, но с более лаконичным синтаксисом.

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

fun main() {
    thread {
        println("Поток запущен: ${Thread.currentThread().name}")
        Thread.sleep(1000)
        println("Поток завершен")
    }
    println("Главный поток: ${Thread.currentThread().name}")
    Thread.sleep(2000)
}

Однако у потоков есть недостатки: они тяжеловесны (каждый поток потребляет около 1 МБ памяти), их количество ограничено, а переключение контекста дорого. Для современных приложений с тысячами параллельных задач потоки — не лучший выбор. Именно здесь на помощь приходят корутины.

2. Корутины (Coroutines): легковесная альтернатива

Корутины — это основная фича Kotlin для конкурентности. Они работают поверх потоков, но используют меньше ресурсов. Один поток может выполнять тысячи корутин, приостанавливая и возобновляя их без блокировки.

Чтобы начать, добавьте зависимость в build.gradle.kts:

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
}

Пример простой корутины:

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {
        delay(1000L) // неблокирующая задержка
        println("Мир из корутины!")
    }
    println("Привет из главной корутины")
    delay(2000L)
}

Здесь launch запускает новую корутину, а delay приостанавливает её без блокировки потока. runBlocking используется для блокировки главного потока до завершения всех корутин (обычно применяется только в main и тестах).

2.1. Suspend-функции

Ключевое слово suspend позволяет приостанавливать выполнение функции, не блокируя поток. Это основа асинхронного программирования в Kotlin.

suspend fun fetchUser(): String {
    delay(500L) // имитация сетевого запроса
    return "User123"
}

fun main() = runBlocking {
    val user = fetchUser()
    println("Получен пользователь: $user")
}

2.2. Async и Deferred для параллельных задач

Если нужно выполнить несколько задач параллельно и дождаться их результатов, используйте async и Deferred:

fun main() = runBlocking {
    val time = measureTimeMillis {
        val user = async { fetchUser() }
        val profile = async { fetchProfile() }
        println("Результаты: ${user.await()}, ${profile.await()}")
    }
    println("Время выполнения: $time мс")
}

suspend fun fetchUser(): String {
    delay(500)
    return "User"
}

suspend fun fetchProfile(): String {
    delay(500)
    return "Profile"
}

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

3. Диспетчеры корутин (Dispatchers)

Диспетчеры определяют, на каких потоках будут выполняться корутины. Основные типы:

  • Dispatchers.Default — для CPU-интенсивных задач (по умолчанию).
  • Dispatchers.IO — для операций ввода-вывода (сеть, файлы).
  • Dispatchers.Main — для UI-потока (Android, JavaFX).
  • Dispatchers.Unconfined — не привязан к конкретному потоку (используется редко).

Пример использования withContext для смены диспетчера:

suspend fun loadData(): String = withContext(Dispatchers.IO) {
    println("Загрузка на потоке: ${Thread.currentThread().name}")
    delay(500)
    "Данные загружены"
}

fun main() = runBlocking {
    val data = loadData()
    println(data)
}

4. Каналы (Channels) для обмена данными

Каналы позволяют передавать данные между корутинами, работая по принципу производитель-потребитель:

fun main() = runBlocking {
    val channel = Channel<Int>()
    
    launch {
        for (i in 1..5) {
            channel.send(i)
            delay(100)
        }
        channel.close()
    }
    
    launch {
        for (value in channel) {
            println("Получено: $value")
        }
    }
}

Каналы полезны для организации потоков данных, например, при обработке событий или стриминге.

5. Сравнение: потоки vs корутины

ХарактеристикаПотокиКорутины
Потребление памяти~1 МБ на поток~несколько КБ
Максимальное количествоОграничено (обычно до нескольких тысяч)Миллионы
БлокировкаБлокирует потокПриостанавливается без блокировки
УправлениеРучное (join, sleep)Структурированное (scope, structured concurrency)

Для большинства современных задач (сетевые запросы, работа с БД, UI) корутины предпочтительнее. Потоки стоит использовать только для CPU-интенсивных операций, где важна полная изоляция.

6. Обработка ошибок и структурированная конкурентность

Kotlin

Блоги

Рекомендаций по книгам