Avatar ·

В чем разница между потоком (Thread) и корутиной (Coroutine) в Kotlin?

Всем привет! Начал изучать Kotlin и корутины, но никак не могу понять фундаментальную разницу между обычным потоком (Thread) и корутиной (Coroutine). Вроде бы и то, и другое позволяет выполнять код параллельно или асинхронно. Я читал документацию, но там написано сложно. Например, я написал простой пример с потоками: ```kotlin fun main() { println("Main starts: ${Thread.currentThread().name}") val thread = Thread { println("Thread working: ${Thread.currentThread().name}") Thread.sleep(1000) println("Thread done") } thread.start() println("Main continues: ${Thread.currentThread().name}") thread.join() println("Main ends") } ``` И эквивалент на корутинах: ```kotlin fun main() = runBlocking { println("Main starts: ${Thread.currentThread().name}") launch { println("Coroutine working: ${Thread.currentThread().name}") delay(1000) println("Coroutine done") } println("Main continues: ${Thread.currentThread().name}") println("Main ends") } ``` Оба примера работают похоже. Но я слышал, что корутины "легче" потоков и могут запускать тысячи штук без проблем. А еще говорят, что корутины не блокируют поток. Можете объяснить на пальцах, в чем ключевая разница? Когда использовать Thread, а когда coroutine? И почему в примере с корутиной я не вижу переключения потоков, хотя код выполняется асинхронно? Буду благодарен за подробное объяснение! <<>>

Главное отличие: потоки — это ресурсы ОС, корутины — это управляемые задачи

Давайте разберем фундаментальную разницу. Поток (Thread) — это объект операционной системы. Каждый поток имеет свой стек памяти (обычно 1-2 МБ), и переключение между потоками — дорогостоящая операция, требующая вмешательства ядра ОС. Вы можете запустить максимум несколько тысяч потоков на обычном компьютере, прежде чем система начнет тормозить или выдавать ошибки.

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

Почему ваш пример с корутиной не показывает переключение потоков?

В вашем примере используется runBlocking — это мост между обычным кодом и корутинами. Он блокирует текущий поток (main) и создает диспетчер корутин, который по умолчанию использует тот же поток для выполнения. Если вы хотите увидеть переключение потоков, используйте Dispatchers.Default или Dispatchers.IO:

fun main() = runBlocking {
    println("Main: ${Thread.currentThread().name}")
    
    launch(Dispatchers.Default) {
        println("Coroutine on Default: ${Thread.currentThread().name}")
        delay(500)
        println("After delay: ${Thread.currentThread().name}")
    }
    
    delay(100)
    println("Main continues: ${Thread.currentThread().name}")
}

Вы увидите, что корутина может начаться на одном потоке из пула, а после delay() продолжиться на другом. Это и есть переключение контекста без блокировки.

Ключевые различия в деталях:

  • Создание и стоимость: Создать поток — дорого (аллокация стека, системный вызов). Создать корутину — дешево (просто объект в куче, ~несколько десятков байт). Можно запустить миллионы корутин без проблем.
  • Блокировка vs Приостановка: Thread.sleep() блокирует поток — ОС вытесняет его, и никакая другая работа на этом потоке невозможна. delay() — это suspend-функция: она говорит "я подожду, а поток пока может делать другую работу". Поток не блокируется, он переключается на другую корутину.
  • Управление: Потоками управляет ОС (вытесняющая многозадачность). Корутинами управляет ваша программа (кооперативная многозадачность) — корутина сама решает, когда приостановиться.
  • Память: Каждый поток имеет свой стек (минимум 64 КБ на Android, 1 МБ на JVM). Корутины используют общий стек потока, на котором они выполняются, экономя память.

Когда что использовать?

Используйте потоки, когда:

  • Вы выполняете CPU-интенсивные задачи, которые не делают пауз (циклы, вычисления).
  • Работаете с библиотеками, которые блокируют поток (старые Java-библиотеки).
  • Нужен настоящий параллелизм на многоядерном процессоре.

Используйте корутины, когда:

  • У вас много задач, ожидающих I/O (сеть, база данных, файлы).
  • Нужно обрабатывать тысячи одновременных соединений.
  • Пишете асинхронный код, который должен читаться как синхронный (без callback-ада).
  • Работаете с UI (Android, Swing) — корутины позволяют легко переключаться между фоновым и UI-потоком.

Практический пример: 10 000 задач

Попробуйте запустить 10 000 потоков — вероятно, получите OutOfMemoryError. А 10 000 корутин — без проблем:

fun main() = runBlocking {
    val jobs = List(10_000) {
        launch(Dispatchers.Default) {
            delay(1000)
            print(".")
        }
    }
    jobs.joinAll()
    println("\nDone! All 10,000 coroutines finished.")
}

Этот код использует всего несколько потоков (обычно 4-8, по числу ядер процессора), но обрабатывает 10 000 задач, просто переключаясь между ними.

В итоге: корутины — это не "замена" потокам, а надстройка над ними. Они позволяют эффективно использовать потоки, не блокируя их, и писать асинхронный код в императивном стиле.

<<>>

Дополнение: структурная конкурентность и жизненный цикл

Еще одно важное отличие, которое не сразу видно из примеров — это структурная конкурентность (structured concurrency) в корутинах.

Когда вы создаете поток, он "отрывается" от родительского кода. Вы должны вручную управлять его жизненным циклом: запустить, дождаться (join()), обработать исключения. Если вы забудете это сделать, поток может работать вечно или завершиться с ошибкой, которую никто не поймает.

Корутины же живут в рамках CoroutineScope. Все дочерние корутины автоматически привязываются к родительской. Если родительская корутина отменяется — все дочерние тоже отменяются. Если дочерняя корутина выбрасывает исключение — оно всплывает к родителю. Это делает код более предсказуемым и безопасным:

fun main() = runBlocking {
    // Если эта корутина будет отменена, child тоже отменится
    val parent = launch {
        val child = launch {
            try {
                delay(Long.MAX_VALUE)
            } catch (e: CancellationException) {
                println("Child was cancelled!")
            }
        }
        delay(100)
        // Родитель отменяется -> дочерняя тоже
    }
    delay(50)
    parent.cancel()
    println("Parent cancelled")
}

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