CoroutineScope и coroutineScope: в чём разница

Представьте: вы пишете функцию, которая должна загрузить профиль и ленту параллельно, а потом вернуть собранный экран. Пишете так:

suspend fun loadScreen(): Screen {
    val scope = CoroutineScope(Dispatchers.IO)
    val user = scope.async { api.getUser() }
    val feed = scope.async { api.getFeed() }
    return Screen(user.await(), feed.await())
}

Работает. Потом вы меняете одну букву — заглавную C на строчную:

suspend fun loadScreen(): Screen = coroutineScope {
    val user = async { api.getUser() }
    val feed = async { api.getFeed() }
    Screen(user.await(), feed.await())
}

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

Что такое скоуп

Скоуп — контейнер, в котором живёт корутина. Он нужен, чтобы управлять её жизнью: пока скоуп жив, живут запущенные в нём корутины; отменили скоуп — отменились все разом.

Скоупы выстраиваются в дерево. У родителя есть потомки, и родитель не завершится, пока не завершатся они. Отмена родителя отменяет детей. Это и называется структурированной конкурентностью: время жизни задач повторяет структуру кода, а не живёт своей жизнью.

Отсюда и вся разница между двумя функциями.

CoroutineScope() — фабрика

CoroutineScope(context) — обычная функция. Она создаёт новый объект скоупа и возвращает его вам. Всё. Она ничего не запускает, ничего не ждёт и ни к чему не привязана.

Корутины, запущенные в таком скоупе, работают сами по себе. Функция, которая его создала, спокойно завершится, не дожидаясь их. Если никто не позаботится отменить этот скоуп — никто и не отменит.

Поэтому место CoroutineScope() — там, где у объекта есть собственный жизненный цикл и вы знаете, в какой момент его оборвать. Так устроены viewModelScope и lifecycleScope: они созданы ровно этой функцией и отменяются, когда умирает ViewModel или экран.

А вот внутри suspend-функции создавать скоуп вручную — почти всегда ошибка. Вы создали дерево, которое ни к чему не прикреплено, и забыли про него.

coroutineScope — приостановка

coroutineScope { ... } — suspend-функция. Она создаёт скоуп неявно, выполняет в нём ваш блок и приостанавливается, пока не завершатся все запущенные внутри корутины. Только потом возвращает результат.

Вот что это даёт на практике:

  • функция не вернёт управление, пока работа не доделана;
  • если одна из корутин упала, остальные отменяются, а исключение вылетает наружу — и его можно поймать обычным try/catch вокруг блока;
  • если кто-то отменит вызывающую корутину, отменится и всё содержимое блока.

То есть снаружи такая функция выглядит как обычная синхронная: вызвали, дождались, получили результат. А внутри — конкурентность.

CoroutineScope(context)coroutineScope { }
Типобычная функцияsuspend-функция
Что возвращаетобъект скоупарезультат своего блока
Ждёт свои корутинынетда
Связана с родителемнетда

Почему внутри runBlocking можно писать launch без точки

Короткое наблюдение, которое снимает половину путаницы. launch и async объявлены как функции-расширения для CoroutineScope — значит, вызывать их полагается через точку, и скоуп обязан откуда-то взяться.

Но внутри runBlocking мы пишем просто launch { }. Дело в сигнатуре: блок runBlocking объявлен как лямбда с приёмником CoroutineScope. Внутри такой лямбды this — это скоуп, и точку можно опустить. Мы всё это время писали this.launch, просто не видели этого.

И отдельно — GlobalScope

Раз уж речь о скоупах: GlobalScope — это скоуп без родителя, исключённый из иерархии. Его нельзя отменить, можно только дождаться, пока корутины закончатся сами.

В Android это прямая утечка: процесс приложения может висеть в памяти и после закрытия всех экранов — вместе с корутиной, которая всё ещё что-то делает. Компилятор не зря требует отдельной аннотации, чтобы вы им воспользовались.

Если GlobalScope кажется решением — почти наверняка нужен viewModelScope.

Короткое правило

Нужен скоуп, живущий вместе с объектом, — CoroutineScope(), и не забудьте его отменить. Нужно запустить несколько задач и дождаться их прямо здесь — coroutineScope. Внутри suspend-функции в девяти случаях из десяти правильный ответ — второй.

Структурированная конкурентность — одна из глав большого руководства «Корутины в Kotlin».