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

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

Основные задачи CoroutineScope

  1. Организация корутин в группы
    Корутины, запущенные внутри одного CoroutineScope, могут контролироваться как единое целое. Это позволяет структурировать асинхронный код — подход называется structured concurrency.

  2. Управление временем жизни корутин
    Если CoroutineScope отменяется, все корутины, запущенные внутри него, тоже отменяются. Это предотвращает утечки памяти и «зависшие» задачи.

  3. Контекст исполнения
    CoroutineScope содержит CoroutineContext, где определены:

    • Job — для контроля выполнения и отмены

    • Dispatcher — определяет, на каком потоке выполняется корутина (Dispatchers.IO, Dispatchers.Main, и т.д.)

    • другие элементы контекста (например, CoroutineName, CoroutineExceptionHandler)

Как создаются CoroutineScope

1. Через CoroutineScope() (функция-обёртка):

val scope = CoroutineScope(Dispatchers.Default)
scope.launch {
// работа в фоне
}

2. Через наследование в классе:

class MyComponent : CoroutineScope {
private val job = Job()
override val coroutineContext = Dispatchers.Main + job
fun doSomething() {
launch {
// корутина в Main
}
}
fun cancelAll() {
job.cancel() // отмена всех корутин
}
}

3. В Android и Compose:

  • В Activity, ViewModel, Fragment часто используют встроенные scope’ы:

    • lifecycleScope

    • viewModelScope

    • rememberCoroutineScope() (в Compose)

Эти scope уже привязаны к жизненному циклу соответствующего компонента.

Пример с CoroutineScope

fun main() {
val scope = CoroutineScope(Dispatchers.Default)
scope.launch {
delay(1000)
println("Коррутина 1 завершена")
}
scope.launch {
delay(2000)
println("Коррутина 2 завершена")
}
Thread.sleep(3000)
}

Здесь scope управляет двумя корутинами, и обе выполняются параллельно в фоновом потоке. Если бы мы вызвали scope.cancel(), обе корутины были бы остановлены.

Structured concurrency и важность scope

Structured concurrency — это концепция, согласно которой все корутины должны запускаться внутри ограниченного CoroutineScope, чтобы:

  • они **завершались, когда завершился scope
    **
  • ошибки в дочерних корутинах **не терялись
    **
  • можно было контролировать **иерархию задач
    **

Пример:

fun CoroutineScope.fetchData() = launch {
val user = async { fetchUser() }
val posts = async { fetchPosts() }
println("User: ${user.await()}, Posts: ${posts.await()}")
}

Здесь CoroutineScope используется как родительский контейнер, а async-блоки наследуют его контекст. Если родительская корутина будет отменена, оба запроса тоже будут остановлены.

Как scope влияет на отмену

Каждая корутина имеет Job, и при создании внутри CoroutineScope, она становится дочерней к Job-у scope'а. Отмена родителя приводит к отмене всех потомков.

val scope = CoroutineScope(Dispatchers.IO)
val job = scope.launch {
delay(5000)
println("Задача завершена")
}
job.cancel() // отменяет выполнение delay и печать

Ошибки и CoroutineScope

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

val scope = CoroutineScope(Dispatchers.Default)
scope.launch {
launch {
throw RuntimeException("Ошибка") // всё внутри scope будет отменено
}
launch {
delay(1000)
println("Эта строка уже не будет выведена")
}
}

Пример в Android

class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch {
val data = repository.getData()
// Обновить UI через livedata
}
}
}

viewModelScope автоматически отменяется, когда ViewModel уничтожается. Это предотвращает утечки памяти и избыточную работу.

Расширения на CoroutineScope

Можно писать собственные функции-расширения для CoroutineScope, чтобы логически группировать задачи:

fun CoroutineScope.loadUserAndPosts() = launch {
val user = async { getUser() }
val posts = async { getPosts() }
updateUi(user.await(), posts.await())
}

Dispatcher внутри CoroutineScope

Можно задавать разные диспетчеры при создании CoroutineScope, чтобы указать, где должны выполняться корутины:

  • Dispatchers.Default — CPU-ориентированные задачи

  • Dispatchers.IO — сетевые и файловые операции

  • Dispatchers.Main — работа с UI (в Android)

  • newSingleThreadContext("MyThread") — пользовательский поток

Пример:

val scope = CoroutineScope(Dispatchers.IO)
scope.launch {
// выполняется в IO-потоке
}

Почему нельзя использовать GlobalScope

GlobalScope живёт до конца процесса и не отменяется автоматически. Это может привести к:

  • утечкам памяти

  • неуправляемым фоновым операциям

  • ошибкам, которые сложно отследить

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

CoroutineScope и supervisorScope

Если нужно, чтобы одна корутина не отменяла другие при ошибке, используют supervisorScope:

scope.launch {
supervisorScope {
launch {
throw Exception("Падает")
}
launch {
delay(1000)
println("Эта корутина не пострадает")
}
}
}

Здесь только первая корутина будет отменена.