Spec-Zone.ru › Kotlin 1.9

Подпрограммы и каналы — учебное пособие

В этом учебном пособии вы узнаете, как использовать подпрограммы в IntelliJ IDEA для выполнения сетевых запросов без блокировки потока или обратных вызовов.

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

Вы узнаете:

  • Зачем и как использовать приостанавливающие функции для выполнения сетевых запросов.

  • Как отправлять запросы одновременно, используя подпрограммы.

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

Для сетевых запросов вам понадобится библиотека Retrofit, но подход, показанный в этом учебном пособии, аналогичен для других библиотек, поддерживающих подпрограммы.

Вы найдете решения для всех задач на ветке solutions репозитория проекта проекта.

Перед началом

  1. Загрузите и установите последнюю версию IntelliJ IDEA.

  2. Скопируйте шаблон проекта шаблон проекта, выбрав Получить из VCS на экране «Добро пожаловать» или выбрав Файл | Новый | Проект из системы контроля версий.

    Вы также можете скопировать его из командной строки:

    git clone https://github.com/kotlin-hands-on/intro-coroutines
    

Генерация токена разработчика GitHub

В проекте вы будете использовать API GitHub. Для получения доступа укажите имя вашей учетной записи GitHub и пароль или токен. Если у вас включена двухфакторная аутентификация, токена будет достаточно.

Сгенерируйте новый токен GitHub для использования API GitHub со своей учетной записью:

  1. Укажите имя токена, например, coroutines-tutorial:

    Generate a new GitHub token
  2. Не выбирайте никакие области. Нажмите Создать токен в нижней части страницы.

  3. Скопируйте сгенерированный токен.

Запуск кода

Программа загружает информацию о всех участниках репозиториев заданной организации (по умолчанию «kotlin»). Позже вы добавите логику для сортировки пользователей по количеству их вкладов.

  1. Откройте файл src/contributors/main.kt и запустите функцию main(). Вы увидите следующее окно:

    First window

    Если шрифт слишком мелкий, измените его размер, изменив значение setDefaultFontSize(18f) в функции main().

  2. Укажите имя пользователя GitHub и токен (или пароль) в соответствующих полях.

  3. Убедитесь, что опция БЛОКИРУЮЩИЙ выбрана в раскрывающемся списке Вариант.

  4. Нажмите Загрузить участников. Интерфейс пользователя должен замереть на некоторое время, а затем отобразить список участников.

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

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

Блокирующие запросы

Для выполнения HTTP-запросов к GitHub вы будете использовать библиотеку Retrofit. Она позволяет запросить список репозиториев в рамках данной организации и список авторов для каждого репозитория:

interface GitHubService {
    @GET("orgs/{org}/repos?per_page=100")
    fun getOrgReposCall(
        @Path("org") org: String
    ): Call<List<Repo>>

    @GET("repos/{owner}/{repo}/contributors?per_page=100")
    fun getRepoContributorsCall(
        @Path("owner") owner: String,
        @Path("repo") repo: String
    ): Call<List<User>>
}

Этот API используется функцией loadContributorsBlocking() для получения списка авторов для данной организации.

  1. Откройте src/tasks/Request1Blocking.kt для просмотра его реализации:

    fun loadContributorsBlocking(service: GitHubService, req: RequestData): List<User> {
        val repos = service
            .getOrgReposCall(req.org)   // #1
            .execute()                  // #2
            .also { logRepos(req, it) } // #3
            .body() ?: emptyList()      // #4
    
        return repos.flatMap { repo ->
            service
                .getRepoContributorsCall(req.org, repo.name) // #1
                .execute()                                   // #2
                .also { logUsers(repo, it) }                 // #3
                .bodyList()                                  // #4
        }.aggregate()
    }
    
    • Сначала вы получаете список репозиториев для данной организации и сохраняете его в списке repos. Затем для каждого репозитория запрашивается список авторов, и все списки объединяются в один итоговый список авторов.

    • getOrgReposCall() и getRepoContributorsCall() оба возвращают экземпляр класса *Call (#1). На этом этапе ни один запрос не отправляется.

    • Затем вызывается *Call.execute() для выполнения запроса (#2). execute() — это синхронный вызов, который блокирует подлежащую нить.

    • При получении ответа результат регистрируется, вызывая функции logRepos() и logUsers() (#3). Если HTTP-ответ содержит ошибку, эта ошибка будет здесь зарегистрирована.

    • Наконец, получите тело ответа, содержащее необходимые данные. Для этого учебника вы будете использовать пустой список в качестве результата в случае ошибки и регистрировать соответствующую ошибку (#4).

  2. Чтобы избежать повторения .body() ?: emptyList(), объявляется функция-расширение bodyList().

    fun <T> Response<List<T>>.bodyList(): List<T> {
        return body() ?: emptyList()
    }
    
  3. Запустите программу снова и посмотрите вывод системы в IntelliJ IDEA. Он должен выглядеть примерно так:

    1770 [AWT-EventQueue-0] INFO  Contributors - kotlin: loaded 40 repos
    2025 [AWT-EventQueue-0] INFO  Contributors - kotlin-examples: loaded 23 contributors
    2229 [AWT-EventQueue-0] INFO  Contributors - kotlin-koans: loaded 45 contributors
    ...
    
    • Первым элементом в каждой строке является количество миллисекунд, прошедших с момента запуска программы, затем имя потока в квадратных скобках. Вы можете увидеть, из какого потока был вызван запрос загрузки.

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

    Этот вывод логов демонстрирует, что все результаты были зарегистрированы из основного потока. При запуске кода с параметром БЛОКИРУЮЩИЙ окно замирает и не реагирует на входные данные до завершения загрузки. Все запросы выполняются из того же потока, что и вызов loadContributorsBlocking(), который является основным потоком пользовательского интерфейса (в Swing это поток диспетчеризации событий AWT). Этот основной поток блокируется, и поэтому пользовательский интерфейс замирает:

    The blocked main thread

    После загрузки списка авторов результат обновляется.

  4. В src/contributors/Contributors.kt, найдите функцию loadContributors(), отвечающую за выбор способа загрузки авторов, и посмотрите, как вызывается loadContributorsBlocking().

    when (getSelectedVariant()) {
        BLOCKING -> { // Blocking UI thread
            val users = loadContributorsBlocking(service, req)
            updateResults(users, startTime)
        }
    }
    
    • Вызов updateResults() идет сразу после вызова loadContributorsBlocking().

    • updateResults() обновляет пользовательский интерфейс, поэтому его всегда следует вызывать из потока пользовательского интерфейса.

    • Поскольку loadContributorsBlocking() также вызывается из потока пользовательского интерфейса, поток пользовательского интерфейса блокируется, и пользовательский интерфейс замирает.

Задача 1

Первая задача поможет вам ознакомиться с областью задачи. В настоящее время имя каждого автора повторяется несколько раз, один раз для каждого проекта, в котором они участвовали. Реализуйте функцию aggregate() для объединения пользователей таким образом, чтобы каждый автор добавлялся только один раз. Свойство User.contributions должно содержать общее количество вкладов данного пользователя во все проекты. Результирующий список должен быть отсортирован по убыванию количества вкладов.

Откройте src/tasks/Aggregation.kt и реализуйте функцию List<User>.aggregate(). Пользователи должны быть отсортированы по общему количеству их вкладов.

Соответствующий тестовый файл test/tasks/AggregationKtTest.kt показывает пример ожидаемого результата.

Вы можете переключаться между исходным кодом и тестовым классом автоматически, используя ярлык IntelliJ IDEA командной навигации Ctrl+Shift+T / ⇧ ⌘ T.

После выполнения этой задачи результирующий список для организации «kotlin» должен быть похож на следующий:

The list for the "kotlin" organization

Решение задачи 1

  1. Чтобы сгруппировать пользователей по логину, используйте groupBy(), которая возвращает карту из логина к всем появлениям пользователя с этим логином в разных репозиториях.

  2. Для каждого элемента карты подсчитайте общее количество вкладов для каждого пользователя и создайте новый экземпляр класса User с заданным именем и общим количеством вкладов.

  3. Отсортируйте результирующий список по убыванию:

    fun List<User>.aggregate(): List<User> =
        groupBy { it.login }
            .map { (login, group) -> User(login, group.sumOf { it.contributions }) }
            .sortedByDescending { it.contributions }
    

Альтернативным решением является использование функции groupingBy() вместо groupBy().

Обработка событий (Callbacks)

Предыдущее решение работает, но блокирует поток, из-за чего интерфейс пользователя зависает. Традиционный подход, который этого избегает, использует обработку событий (callbacks).

Вместо вызова кода, который должен быть выполнен сразу после завершения операции, его можно выделить в отдельный callback, часто в виде лямбда-выражения, и передать этот лямбда-вызов вызывающей стороне для вызова позже.

Для обеспечения отзывчивости интерфейса пользователя, можно либо перенести всю вычисление в отдельный поток, либо переключиться на API Retrofit, который использует обработку событий (callbacks) вместо блокирующих вызовов.

Использование фонового потока

  1. Откройте src/tasks/Request2Background.kt и изучите его реализацию. Сначала вся вычисление перемещается в другой поток. Функция thread() запускает новый поток:

    thread {
        loadContributorsBlocking(service, req)
    }
    

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

    The freed main thread
  2. Подпись функции loadContributorsBackground() меняется. Она принимает callback updateResults() в качестве последнего аргумента, чтобы вызвать его после завершения всей загрузки:

    fun loadContributorsBackground(
        service: GitHubService, req: RequestData,
        updateResults: (List<User>) -> Unit
    )
    
  3. Теперь, когда вызывается loadContributorsBackground(), вызов updateResults() попадает в callback, а не сразу после него, как это было раньше:

    loadContributorsBackground(service, req) { users ->
        SwingUtilities.invokeLater {
            updateResults(users, startTime)
        }
    }
    

    Вызывая SwingUtilities.invokeLater, вы гарантируете, что вызов updateResults(), который обновляет результаты, происходит в главном потоке пользовательского интерфейса (поток диспетчеризации событий AWT).

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

Задача 2

Исправьте функцию loadContributorsBackground() в src/tasks/Request2Background.kt, чтобы результирующий список отображался в пользовательском интерфейсе.

Решение задачи 2

Если вы попытаетесь загрузить участников, вы увидите в логах, что участники загрузились, но результат не отображается. Чтобы исправить это, вызовите updateResults() для результирующего списка пользователей:

thread {
    updateResults(loadContributorsBlocking(service, req))
}

Убедитесь, что вы явно вызываете логику, переданную в callback. В противном случае ничего не произойдет.

Использование API Retrofit с обработкой событий

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

Обработка данных для каждого репозитория должна быть разделена на две части: загрузка и обработка полученного ответа. Вторая часть обработки должна быть выделена в callback.

Загрузку для каждого репозитория можно начать до получения результата для предыдущего репозитория (и вызова соответствующего callback):

Using callback API

API Retrofit с обработкой событий может помочь в этом. Функция Call.enqueue() запускает HTTP-запрос и принимает callback в качестве аргумента. В этом callback необходимо указать, что нужно сделать после каждого запроса.

Откройте src/tasks/Request3Callbacks.kt и изучите реализацию loadContributorsCallbacks(), которая использует этот API:

fun loadContributorsCallbacks(
    service: GitHubService, req: RequestData,
    updateResults: (List<User>) -> Unit
) {
    service.getOrgReposCall(req.org).onResponse { responseRepos ->  // #1
        logRepos(req, responseRepos)
        val repos = responseRepos.bodyList()

        val allUsers = mutableListOf<User>()
        for (repo in repos) {
            service.getRepoContributorsCall(req.org, repo.name)
                .onResponse { responseUsers ->  // #2
                    logUsers(repo, responseUsers)
                    val users = responseUsers.bodyList()
                    allUsers += users
                }
            }
        }
        // TODO: Why doesn't this code work? How to fix that?
        updateResults(allUsers.aggregate())
    }
  • Для удобства этот фрагмент кода использует функцию расширения onResponse(), объявленную в том же файле. Она принимает лямбда-выражение в качестве аргумента вместо объекта выражения.

  • Логика обработки ответов выделена в callbacks: соответствующие лямбда-выражения начинаются на строках #1 и #2.

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

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

Задача 3 (необязательно)

Перепишите код в файле src/tasks/Request3Callbacks.kt так, чтобы загруженный список участников отображался.

Первое предполагаемое решение для задачи 3

В текущем решении многие запросы запускаются одновременно, что уменьшает общее время загрузки. Однако результат не загружается. Это происходит потому, что callback updateResults() вызывается сразу после запуска всех запросов на загрузку, до того, как список allUsers заполнится данными.

Можно попробовать исправить это таким изменением:

val allUsers = mutableListOf<User>()
for ((index, repo) in repos.withIndex()) {   // #1
    service.getRepoContributorsCall(req.org, repo.name)
        .onResponse { responseUsers ->
            logUsers(repo, responseUsers)
            val users = responseUsers.bodyList()
            allUsers += users
            if (index == repos.lastIndex) {    // #2
                updateResults(allUsers.aggregate())
            }
        }
}
  • Сначала вы перебираете список репозиториев с индексом (#1).

  • Затем из каждого callback вы проверяете, является ли это последней итерацией (#2).

  • И если это так, то результат обновляется.

Однако, и этот код не решает поставленную задачу. Попробуйте найти ответ самостоятельно или ознакомьтесь с решением ниже.

Второе предполагаемое решение для задачи 3

Поскольку запросы на загрузку запускаются одновременно, нет гарантии, что результат последнего придёт последним. Результаты могут приходить в любом порядке.

Таким образом, если вы сравниваете текущий индекс с lastIndex в качестве условия завершения, вы рискуете потерять результаты для некоторых репозиториев.

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

Один из способов исправить это — ввести индекс и проверить, все ли репозитории уже обработаны:

val allUsers = Collections.synchronizedList(mutableListOf<User>())
val numberOfProcessed = AtomicInteger()
for (repo in repos) {
    service.getRepoContributorsCall(req.org, repo.name)
        .onResponse { responseUsers ->
            logUsers(repo, responseUsers)
            val users = responseUsers.bodyList()
            allUsers += users
            if (numberOfProcessed.incrementAndGet() == repos.size) {
                updateResults(allUsers.aggregate())
            }
        }
}

Этот код использует синхронизированный вариант списка и AtomicInteger(), потому что, как правило, нет гарантии, что разные callback'ы, обрабатывающие getRepoContributors() запросы, всегда будут вызваны из одного потока.

Третье предполагаемое решение для задачи 3

Еще более эффективное решение — использовать класс CountDownLatch. Он хранит счетчик, инициализированный количеством репозиториев. Этот счетчик уменьшается после обработки каждого репозитория. Затем он ожидает, пока счетчик latch не станет равным нулю, прежде чем обновить результаты:

val countDownLatch = CountDownLatch(repos.size)
for (repo in repos) {
    service.getRepoContributorsCall(req.org, repo.name)
        .onResponse { responseUsers ->
            // processing repository
            countDownLatch.countDown()
        }
}
countDownLatch.await()
updateResults(allUsers.aggregate())

Результат затем обновляется из главного потока. Это более прямое решение, чем делегирование логики в дочерние потоки.

После рассмотрения этих трёх попыток решения, вы можете видеть, что написание правильного кода с обработкой событий (callbacks) — непростая и подверженная ошибкам задача, особенно при наличии нескольких подпотоков и синхронизации.

В качестве дополнительного упражнения, вы можете реализовать ту же логику с использованием реактивного подхода с библиотекой RxJava. Все необходимые зависимости и решения для использования RxJava можно найти в отдельной ветке rx. Также можно завершить этот учебник и реализовать или проверить предлагаемые Rx версии для сравнения.

Функции приостановки

Вы можете реализовать ту же логику, используя функции приостановки. Вместо возвращения Call<List<Repo>>, определите вызов API как функцию приостановки следующим образом:

interface GitHubService {
    @GET("orgs/{org}/repos?per_page=100")
    suspend fun getOrgRepos(
        @Path("org") org: String
    ): List<Repo>
}
  • getOrgRepos() определяется как функция suspend. Когда вы используете функцию приостановки для выполнения запроса, подлежащая нить не блокируется. Более подробные сведения о том, как это работает, будут представлены в последующих разделах.

  • getOrgRepos() возвращает результат напрямую, вместо возвращения Call. Если результат неудачный, выбрасывается исключение.

В качестве альтернативы, Retrofit позволяет возвращать результат, обернутый в Response. В этом случае тело результата предоставляется, и можно вручную проверить наличие ошибок. Этот учебник использует версии, которые возвращают Response.

В src/contributors/GitHubService.kt, добавьте следующие объявления в интерфейс GitHubService:

interface GitHubService {
    // getOrgReposCall & getRepoContributorsCall declarations

    @GET("orgs/{org}/repos?per_page=100")
    suspend fun getOrgRepos(
        @Path("org") org: String
    ): Response<List<Repo>>

    @GET("repos/{owner}/{repo}/contributors?per_page=100")
    suspend fun getRepoContributors(
        @Path("owner") owner: String,
        @Path("repo") repo: String
    ): Response<List<User>>
}

Задача 4

Ваша задача — изменить код функции, которая загружает участников, чтобы использовать две новые функции приостановки, getOrgRepos() и getRepoContributors(). Новая функция loadContributorsSuspend() помечена как suspend для использования нового API.

Функции приостановки нельзя вызывать повсюду. Вызов функции приостановки из loadContributorsBlocking() приведет к ошибке с сообщением "Функция приостановки 'getOrgRepos' должна вызываться только из сопрограммы или другой функции приостановки".

  1. Скопируйте реализацию loadContributorsBlocking() из src/tasks/Request1Blocking.kt в loadContributorsSuspend() из src/tasks/Request4Suspend.kt.

  2. Измените код так, чтобы вместо функций, возвращающих Call использовались новые функции приостановки.

  3. Запустите программу, выбрав опцию ПРИОСТАНОВИТЬ, и убедитесь, что пользовательский интерфейс остается отзывчивым во время выполнения запросов к GitHub.

Решение задачи 4

Замените .getOrgReposCall(req.org).execute() на .getOrgRepos(req.org) и повторите эту же замену для второго запроса "contributors":

suspend fun loadContributorsSuspend(service: GitHubService, req: RequestData): List<User> {
    val repos = service
        .getOrgRepos(req.org)
        .also { logRepos(req, it) }
        .bodyList()

    return repos.flatMap { repo ->
        service.getRepoContributors(req.org, repo.name)
            .also { logUsers(repo, it) }
            .bodyList()
    }.aggregate()
}
  • loadContributorsSuspend() должна быть определена как функция suspend.

  • Вам больше не нужно вызывать execute, которая ранее возвращала Response, так как теперь функции API возвращают Response напрямую. Обратите внимание, что эта деталь специфична для библиотеки Retrofit. С другими библиотеками API будет отличаться, но концепция остается той же.

Сопрограммы

Код с функциями приостановки выглядит схоже с "блокирующей" версией. Основное отличие от блокирующей версии заключается в том, что вместо блокирования нити сопрограмма приостанавливается:

block -> suspend
thread -> coroutine

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

Запуск новой сопрограммы

Если вы посмотрите, как loadContributorsSuspend() используется в src/contributors/Contributors.kt, вы увидите, что она вызывается внутри launch . launch — это функция библиотеки, которая принимает лямбда-выражение в качестве аргумента:

launch {
    val users = loadContributorsSuspend(req)
    updateResults(users, startTime)
}

Здесь launch запускает новую вычислительную задачу, ответственную за загрузку данных и отображение результатов. Вычисление является приостанавливаемым — при выполнении сетевых запросов оно приостанавливается и освобождает подлежащую нить. При возвращении результата сетевого запроса вычисление возобновляется.

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

Сопрограммы работают поверх нитей и могут быть приостановлены. Когда сопрограмма приостанавливается, соответствующее вычисление приостанавливается, удаляется из нити и сохраняется в памяти. Тем временем нить свободна для выполнения других задач:

Suspending coroutines

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

В примере loadContributorsSuspend() каждый запрос "contributors" теперь ожидает результата, используя механизм приостановки. Сначала отправляется новый запрос. Затем, в ожидании ответа, вся сопрограмма "загрузка участников", запущенная функцией launch , приостанавливается.

Сопрограмма возобновляется только после получения соответствующего ответа:

Suspending request

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

  1. Запустите программу, используя опцию ПРИОСТАНОВИТЬ. Лог подтверждает, что все запросы отправляются в основную нить пользовательского интерфейса:

    2538 [AWT-EventQueue-0 @coroutine#1] INFO  Contributors - kotlin: loaded 30 repos
    2729 [AWT-EventQueue-0 @coroutine#1] INFO  Contributors - ts2kt: loaded 11 contributors
    3029 [AWT-EventQueue-0 @coroutine#1] INFO  Contributors - kotlin-koans: loaded 45 contributors
    ...
    11252 [AWT-EventQueue-0 @coroutine#1] INFO  Contributors - kotlin-coroutines-workshop: loaded 1 contributors
    
  2. Лог может показать, в какой сопрограмме выполняется соответствующий код. Для этого откройте Запуск | Редактировать конфигурации и добавьте параметр VM -Dkotlinx.coroutines.debug:

    Edit run configuration

    Имя сопрограммы будет добавлено к имени нити при запуске main() с этим параметром. Вы также можете изменить шаблон запуска всех файлов Kotlin и включить этот параметр по умолчанию.

Теперь весь код выполняется в одной сопрограмме, "загрузке участников" упомянутой выше, обозначенной как @coroutine#1 . Ожидая результата, не следует повторно использовать нить для отправки других запросов, так как код написан последовательно. Новый запрос отправляется только после получения предыдущего результата.

Функции приостановки обращаются с нитью справедливо и не блокируют ее в ожидании. Однако это пока не вносит никакой параллельности.

Асинхронность

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

Чтобы запустить новую корутину, используйте один из основных строителей корутин: launch, async, или runBlocking. Различные библиотеки могут определять дополнительные строители корутин.

async запускает новую корутину и возвращает объект Deferred. Deferred представляет собой концепцию, известную и другими названиями, такими как Future или Promise. Она хранит вычисление, но откладывает момент получения конечного результата; она обещает результат в некотором будущем.

Основное отличие между async и launch заключается в том, что launch используется для запуска вычисления, от которого не ожидается получения конкретного результата. launch возвращает Job, представляющую корутину. Можно дождаться её завершения, вызвав Job.join().

Deferred — это универсальный тип, который расширяет Job. Вызов async может вернуть Deferred<Int> или Deferred<CustomType>, в зависимости от того, что возвращает лямбда-выражение (последнее выражение внутри лямбды — результат).

Чтобы получить результат корутины, можно вызвать await() на экземпляре Deferred. Пока ожидается результат, корутина, из которой вызван этот await(), приостанавливается:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val deferred: Deferred<Int> = async {
        loadData()
    }
    println("waiting...")
    println(deferred.await())
}

suspend fun loadData(): Int {
    println("loading...")
    delay(1000L)
    println("loaded!")
    return 42
}

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

Посмотрите это видео для лучшего понимания корутин.

Если есть список объектов отложенного выполнения, можно вызвать awaitAll() для ожидания результатов всех из них:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val deferreds: List<Deferred<Int>> = (1..3).map {
        async {
            delay(1000L * it)
            println("Loading $it")
            it
        }
    }
    val sum = deferreds.awaitAll().sum()
    println("$sum")
}

Когда каждый запрос «contributors» запускается в новой корутине, все запросы запускаются асинхронно. Новый запрос может быть отправлен до получения результата по предыдущему:

Concurrent coroutines

Общее время загрузки примерно такое же, как в версии CALLBACKS, но она не требует никаких обратных вызовов. Более того, async чётко выделяет части кода, выполняющиеся параллельно.

Задача 5

В файле Request5Concurrent.kt реализуйте функцию loadContributorsConcurrent(), используя предыдущую функцию loadContributorsSuspend().

Подсказка к задаче 5

Вы можете запускать новые корутины только внутри области видимости корутины. Скопируйте содержимое из loadContributorsSuspend() в вызов coroutineScope, чтобы вы могли там вызывать функции async:

suspend fun loadContributorsConcurrent(
    service: GitHubService,
    req: RequestData
): List<User> = coroutineScope {
    // ...
}

Основывайтесь на следующей схеме:

val deferreds: List<Deferred<List<User>>> = repos.map { repo ->
    async {
        // load contributors for each repo
    }
}
deferreds.awaitAll() // List<List<User>>

Решение для задачи 5

Оборачивайте каждый запрос «contributors» с помощью async, чтобы создать столько корутин, сколько репозиториев. async возвращает Deferred<List<User>>. Это не проблема, потому что создание новых корутин не сильно нагружает ресурсы, так что можно создавать столько, сколько нужно.

  1. Вы больше не можете использовать flatMap, потому что результат map теперь представляет собой список объектов Deferred, а не список списков. awaitAll() возвращает List<List<User>>, поэтому вызовите flatten().aggregate() для получения результата:

    suspend fun loadContributorsConcurrent(
        service: GitHubService, 
        req: RequestData
    ): List<User> = coroutineScope {
        val repos = service
            .getOrgRepos(req.org)
            .also { logRepos(req, it) }
            .bodyList()
    
        val deferreds: List<Deferred<List<User>>> = repos.map { repo ->
            async {
                service.getRepoContributors(req.org, repo.name)
                    .also { logUsers(repo, it) }
                    .bodyList()
            }
        }
        deferreds.awaitAll().flatten().aggregate()
    }
    
  2. Запустите код и проверьте лог. Все корутины всё ещё выполняются в главном потоке пользовательского интерфейса, потому что многопоточность ещё не использована, но вы уже можете увидеть преимущества одновременного выполнения корутин.

  3. Чтобы изменить этот код на выполнение корутин «contributors» в разных потоках из общего пула потоков, укажите Dispatchers.Default в качестве аргумента контекста для функции async:

    async(Dispatchers.Default) { }
    
    • CoroutineDispatcher определяет, на каком или каких потоках должна выполняться соответствующая корутина. Если вы не укажете его в качестве аргумента, async будет использовать диспетчер из внешней области видимости.

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

  4. Измените код в функции loadContributorsConcurrent() для запуска новых корутин в разных потоках из общего пула потоков. Также добавьте дополнительную запись в лог перед отправкой запроса:

    async(Dispatchers.Default) {
        log("starting loading for ${repo.name}")
        service.getRepoContributors(req.org, repo.name)
            .also { logUsers(repo, it) }
            .bodyList()
    }
    
  5. Запустите программу ещё раз. В логе вы сможете увидеть, что каждая корутина может быть запущена на одном потоке из пула потоков и продолжена на другом:

    1946 [DefaultDispatcher-worker-2 @coroutine#4] INFO  Contributors - starting loading for kotlin-koans
    1946 [DefaultDispatcher-worker-3 @coroutine#5] INFO  Contributors - starting loading for dokka
    1946 [DefaultDispatcher-worker-1 @coroutine#3] INFO  Contributors - starting loading for ts2kt
    ...
    2178 [DefaultDispatcher-worker-1 @coroutine#4] INFO  Contributors - kotlin-koans: loaded 45 contributors
    2569 [DefaultDispatcher-worker-1 @coroutine#5] INFO  Contributors - dokka: loaded 36 contributors
    2821 [DefaultDispatcher-worker-2 @coroutine#3] INFO  Contributors - ts2kt: loaded 11 contributors
    

    Например, в этом фрагменте лога, coroutine#4 запускается в потоке worker-2 и продолжается в потоке worker-1.

В src/contributors/Contributors.kt, проверьте реализацию опции CONCURRENT:

  1. Чтобы запустить корутину только в главном потоке пользовательского интерфейса, укажите Dispatchers.Main в качестве аргумента:

    launch(Dispatchers.Main) {
        updateResults()
    }
    
    • Если главный поток занят при запуске новой корутины на нём, корутина приостанавливается и планируется для выполнения в этом потоке. Корутина возобновится только когда поток станет свободным.

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

    • Как вы увидите позже, при вызове loadContributorsConcurrent() из тестов, вы можете вызывать её в контексте с TestDispatcher, что упрощает тестирование. Это делает решение гораздо более гибким.

  2. Для указания диспетчера со стороны вызывающего приложения, внесите следующие изменения в проект, разрешая loadContributorsConcurrent запускать корутины в унаследованном контексте:

    launch(Dispatchers.Default) {
        val users = loadContributorsConcurrent(service, req)
        withContext(Dispatchers.Main) {
            updateResults(users, startTime)
        }
    }
    
    • updateResults() должна вызываться в главном потоке пользовательского интерфейса, поэтому вы вызываете её с контекстом Dispatchers.Main.

    • withContext() вызывает указанный код с заданным контекстом корутины, приостанавливается до его завершения и возвращает результат. Альтернативный, но более подробный способ выразить это — запустить новую корутину и явно дождаться (приостановившись), пока она завершится: launch(context) { ... }.join().

  3. Запустите код и убедитесь, что корутины выполняются в потоках из пула потоков.

Структурированная конкуратность

  • Область корутины отвечает за структуру и отношения «родитель-потомок» между различными корутинами. Новые корутины обычно необходимо запускать внутри области.

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

При использовании launch, async, или runBlocking для запуска новой корутины, они автоматически создают соответствующую область. Все эти функции принимают лямбда-выражение с получателем в качестве аргумента, и CoroutineScope является неявным типом получателя:

launch { /* this: CoroutineScope */ }
  • Новые корутины могут запускаться только внутри области.

  • launch и async объявляются как расширения для CoroutineScope, поэтому неявный или явный получатель всегда должен передаваться при их вызове.

  • Корутина, запущенная с помощью runBlocking, является единственным исключением, потому что runBlocking определена как функция верхнего уровня. Но поскольку она блокирует текущий поток, она предназначена в основном для использования в функциях main() и тестах в качестве мостовой функции.

Новая корутина внутри runBlocking, launch, или async запускается автоматически внутри области:

import kotlinx.coroutines.*

fun main() = runBlocking { /* this: CoroutineScope */
    launch { /* ... */ }
    // the same as:   
    this.launch { /* ... */ }
}

При вызове launch внутри runBlocking, она вызывается как расширение для неявного получателя типа CoroutineScope. В качестве альтернативы, вы можете явно написать this.launch.

Вложенная корутина (запущенная с помощью launch в этом примере) может рассматриваться как дочерняя корутина внешней корутины (запущенной с помощью runBlocking). Это отношение «родитель-потомок» реализуется через области; дочерняя корутина запускается из области, соответствующей родительской корутине.

Можно создать новую область без запуска новой корутины, используя функцию coroutineScope. Чтобы структурированно запускать новые корутины внутри функции suspend без доступа к внешней области, вы можете создать новую область корутины, которая автоматически становится дочерней областью внешней области, из которой вызывается функция suspend. loadContributorsConcurrent() - хороший пример.

Вы также можете запустить новую корутину из глобальной области, используя GlobalScope.async или GlobalScope.launch. Это создаст независимую корутину верхнего уровня.

Механизм структуры корутин называется структурированной конкуратностью. Она предоставляет следующие преимущества по сравнению с глобальными областями:

  • Область, как правило, отвечает за дочерние корутины, срок жизни которых привязан к сроку жизни области.

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

  • Область автоматически ожидает завершения всех дочерних корутин. Поэтому, если область соответствует корутине, родительская корутина не завершается до тех пор, пока все корутины, запущенные в ее области, не завершатся.

При использовании GlobalScope.async, нет структуры, которая связывает несколько корутин в более узкой области. Корутины, запущенные из глобальной области, являются независимыми - их срок жизни ограничен только сроком жизни всего приложения. Можно сохранить ссылку на запущенную из глобальной области корутину и дождаться ее завершения или явно отменить ее, но это не произойдет автоматически, как в случае со структурированной конкуратностью.

Отмена загрузки участников

Создайте две версии функции, которая загружает список участников. Сравните, как ведут себя обе версии, когда вы пытаетесь отменить родительскую корутину. Первая версия будет использовать coroutineScope для запуска всех дочерних корутин, а вторая - GlobalScope.

  1. В Request5Concurrent.kt, добавьте задержку в 3 секунды в функцию loadContributorsConcurrent().

    suspend fun loadContributorsConcurrent(
        service: GitHubService, 
        req: RequestData
    ): List<User> = coroutineScope {
        // ...
        async {
            log("starting loading for ${repo.name}")
            delay(3000)
            // load repo contributors
        }
        // ...
    }
    

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

  2. Создайте вторую версию функции загрузки: скопируйте реализацию loadContributorsConcurrent() в loadContributorsNotCancellable() в Request5NotCancellable.kt и удалите создание новой coroutineScope.

  3. Вызовы async теперь не разрешаются, поэтому запустите их с помощью GlobalScope.async.

    suspend fun loadContributorsNotCancellable(
        service: GitHubService,
        req: RequestData
    ): List<User> {   // #1
        // ...
        GlobalScope.async {   // #2
            log("starting loading for ${repo.name}")
            // load repo contributors
        }
        // ...
        return deferreds.awaitAll().flatten().aggregate()  // #3
    }
    
    • Функция теперь возвращает результат напрямую, а не как последнее выражение внутри лямбда-выражения (строки #1 и #3).

    • Все корутины «contributors» запускаются внутри GlobalScope, а не как дочерние корутины области корутины (строка #2).

  4. Запустите программу и выберите вариант CONCURRENT для загрузки участников.

  5. Дождитесь запуска всех корутин «contributors», а затем нажмите Отмена. В логе нет новых результатов, что означает, что все запросы были действительно отменены:

    2896 [AWT-EventQueue-0 @coroutine#1] INFO  Contributors - kotlin: loaded 40 repos
    2901 [DefaultDispatcher-worker-2 @coroutine#4] INFO  Contributors - starting loading for kotlin-koans
    ...
    2909 [DefaultDispatcher-worker-5 @coroutine#36] INFO  Contributors - starting loading for mpp-example
    /* click on 'cancel' */
    /* no requests are sent */
    
  6. Повторите шаг 5, но на этот раз выберите вариант NOT_CANCELLABLE:

    2570 [AWT-EventQueue-0 @coroutine#1] INFO  Contributors - kotlin: loaded 30 repos
    2579 [DefaultDispatcher-worker-1 @coroutine#4] INFO  Contributors - starting loading for kotlin-koans
    ...
    2586 [DefaultDispatcher-worker-6 @coroutine#36] INFO  Contributors - starting loading for mpp-example
    /* click on 'cancel' */
    /* but all the requests are still sent: */
    6402 [DefaultDispatcher-worker-5 @coroutine#4] INFO  Contributors - kotlin-koans: loaded 45 contributors
    ...
    9555 [DefaultDispatcher-worker-8 @coroutine#36] INFO  Contributors - mpp-example: loaded 8 contributors
    

    В этом случае никакие корутины не отменяются, и все запросы по-прежнему отправляются.

  7. Проверьте, как происходит отмена в программе «contributors». Когда нажимается кнопка Отмена, главная корутина «загрузки» отменяется явно, а дочерние корутины отменяются автоматически:

    interface Contributors {
    
        fun loadContributors() {
            // ...
            when (getSelectedVariant()) {
                CONCURRENT -> {
                    launch {
                        val users = loadContributorsConcurrent(service, req)
                        updateResults(users, startTime)
                    }.setUpCancellation()      // #1
                }
            }
        }
    
        private fun Job.setUpCancellation() {
            val loadingJob = this              // #2
    
            // cancel the loading job if the 'cancel' button was clicked:
            val listener = ActionListener {
                loadingJob.cancel()            // #3
                updateLoadingStatus(CANCELED)
            }
            // add a listener to the 'cancel' button:
            addCancelListener(listener)
    
            // update the status and remove the listener
            // after the loading job is completed
        }
    }   
    

Функция launch возвращает экземпляр Job. Job хранит ссылку на «корутину загрузки», которая загружает все данные и обновляет результаты. Вы можете вызвать функцию-расширение setUpCancellation() для нее (строка #1). передав экземпляр Job в качестве получателя.

Другой способ выразить это - написать явно:

val job = launch { }
job.setUpCancellation()
  • Для удобочитаемости вы можете обратиться к получателю функции setUpCancellation() внутри функции с новой переменной loadingJob (строка #2).

  • Затем вы можете добавить обработчик нажатия кнопки Отмена, чтобы при нажатии корутина loadingJob отменялась (строка #3).

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

Использование контекста внешней области

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

Теперь пришло время узнать, как работает использование диспетчера из внешней области. Новая область, созданная функцией coroutineScope или конструкторами корутин, всегда наследует контекст из внешней области. В этом случае внешняя область - это область, из которой была вызвана функция suspend loadContributorsConcurrent():

launch(Dispatchers.Default) {  // outer scope
    val users = loadContributorsConcurrent(service, req)
    // ...
}

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

suspend fun loadContributorsConcurrent(
    service: GitHubService, req: RequestData
): List<User> = coroutineScope {
    // this scope inherits the context from the outer scope
    // ...
    async {   // nested coroutine started with the inherited context
        // ...
    }
    // ...
}

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

При написании кода с корутинами для приложений с графическим интерфейсом пользователя, например, Android, принято использовать CoroutineDispatchers.Main по умолчанию для верхней корутины, а затем явно указывать другой диспетчер, когда необходимо запустить код в другом потоке.

Отображение прогресса

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

Вы можете отобразить промежуточные результаты раньше и отобразить всех участников после загрузки данных для каждого репозитория:

Loading data

Для реализации этой функциональности в src/tasks/Request6Progress.kt, вам необходимо передать логику обновления интерфейса в качестве обратного вызова, чтобы он вызывался на каждом промежуточном шаге:

suspend fun loadContributorsProgress(
    service: GitHubService,
    req: RequestData,
    updateResults: suspend (List<User>, completed: Boolean) -> Unit
) {
    // loading the data
    // calling `updateResults()` on intermediate states
}

В месте вызова в Contributors.kt, обратный вызов передается для обновления результатов из потока Main для опции PROGRESS:

launch(Dispatchers.Default) {
    loadContributorsProgress(service, req) { users, completed ->
        withContext(Dispatchers.Main) {
            updateResults(users, startTime, completed)
        }
    }
}
  • Параметр updateResults() объявлен как suspend в loadContributorsProgress(). Необходимо вызвать withContext, которая является функцией suspend внутри соответствующего лямбда-аргумента.

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

Задача 6

В файле Request6Progress.kt реализуйте функцию loadContributorsProgress(), которая показывает промежуточный прогресс. Основывайтесь на функции loadContributorsSuspend() из Request4Suspend.kt.

  • Используйте простую версию без конкурентности; вы добавите её позже в следующем разделе.

  • Промежуточный список участников должен отображаться в «агрегированном» состоянии, а не только список пользователей, загруженных для каждого репозитория.

  • Общее количество вкладов для каждого пользователя должно увеличиваться при загрузке данных для каждого нового репозитория.

Решение задачи 6

Для хранения промежуточного списка загруженных участников в «агрегированном» состоянии определите переменную allUsers, которая хранит список пользователей, а затем обновите её после загрузки участников для каждого нового репозитория:

suspend fun loadContributorsProgress(
    service: GitHubService,
    req: RequestData,
    updateResults: suspend (List<User>, completed: Boolean) -> Unit
) {
    val repos = service
        .getOrgRepos(req.org)
        .also { logRepos(req, it) }
        .bodyList()

    var allUsers = emptyList<User>()
    for ((index, repo) in repos.withIndex()) {
        val users = service.getRepoContributors(req.org, repo.name)
            .also { logUsers(repo, it) }
            .bodyList()

        allUsers = (allUsers + users).aggregate()
        updateResults(allUsers, index == repos.lastIndex)
    }
}

Последовательное против параллельного

Обратный вызов updateResults() вызывается после завершения каждого запроса:

Progress on requests

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

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

Concurrent requests

Для добавления конкурентности используйте каналы.

Каналы

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

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

Using channels

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

Using channels with many coroutines

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

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

Channel представлен тремя различными интерфейсами: SendChannel, ReceiveChannel, и Channel, причём последний расширяет первые два. Обычно вы создаёте канал и передаёте его производителям как SendChannel экземпляр, чтобы только они могли отправлять информацию в канал. Вы передаёте канал потребителям как ReceiveChannel экземпляр, чтобы только они могли получать из него. Оба send и receive метода объявлены как suspend.

interface SendChannel<in E> {
    suspend fun send(element: E)
    fun close(): Boolean
}

interface ReceiveChannel<out E> {
    suspend fun receive(): E
}

interface Channel<E> : SendChannel<E>, ReceiveChannel<E>

Производитель может закрыть канал, чтобы указать, что больше элементов не поступает.

В библиотеке определены несколько типов каналов. Они различаются тем, сколько элементов они могут хранить внутри и может ли вызов send() быть приостановлен или нет. Для всех типов каналов вызов receive() ведёт себя аналогично: он получает элемент, если канал не пуст; в противном случае он приостанавливается.

Неограниченный канал

Неограниченный канал — это ближайший аналог очереди: производители могут отправлять элементы в этот канал, и он будет неограниченно расти. Вызов send() никогда не будет приостановлен. Если программе не хватит памяти, вы получите OutOfMemoryException. Различие между неограниченным каналом и очередью состоит в том, что когда потребитель пытается получить из пустого канала, он приостанавливается до тех пор, пока какие-либо новые элементы не будут отправлены.

Unlimited channel
Буферизованный канал

Размер буферизованного канала ограничен указанным числом. Производители могут отправлять элементы в этот канал до тех пор, пока не будет достигнут предел размера. Все элементы хранятся внутри. Когда канал полон, следующий вызов `send` на нём приостанавливается до тех пор, пока не станет доступно больше свободного места.

Buffered channel
Канал "Rendezvous"

Канал "Rendezvous" — это канал без буфера, такой же, как буферизованный канал с нулевым размером. Одна из функций (send() или receive()) всегда приостанавливается, пока не будет вызвана другая.

Если функция send() вызывается, и нет приостановленного вызова receive, готового обработать элемент, то send() приостанавливается. Аналогично, если функция receive вызывается, и канал пуст, или, другими словами, нет приостановленного вызова send(), готового отправить элемент, то вызов receive() приостанавливается.

Название "rendezvous" ("встреча в оговоренное время и месте") относится к тому, что send() и receive() должны "встретиться вовремя".

Rendezvous channel
Конфлюэнтный канал

Новый элемент, отправленный в конфлюэнтный канал, перезапишет ранее отправленный элемент, поэтому получатель всегда получит только последний элемент. Вызов send() никогда не приостанавливается.

Conflated channel

При создании канала укажите его тип или размер буфера (если вам нужен буферизованный канал):

val rendezvousChannel = Channel<String>()
val bufferedChannel = Channel<String>(10)
val conflatedChannel = Channel<String>(CONFLATED)
val unlimitedChannel = Channel<String>(UNLIMITED)

По умолчанию создаётся канал "Rendezvous".

В следующем задании вы создадите канал "Rendezvous", две корутины-производителя и корутину-потребителя:

import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.*

fun main() = runBlocking<Unit> {
    val channel = Channel<String>()
    launch {
        channel.send("A1")
        channel.send("A2")
        log("A done")
    }
    launch {
        channel.send("B1")
        log("B done")
    }
    launch {
        repeat(3) {
            val x = channel.receive()
            log(x)
        }
    }
}

fun log(message: Any?) {
    println("[${Thread.currentThread().name}] $message")
}

Посмотрите это видео для лучшего понимания каналов.

Задача 7

В src/tasks/Request7Channels.kt, реализуйте функцию loadContributorsChannels(), которая запрашивает всех участников GitHub одновременно и показывает промежуточный прогресс одновременно.

Используйте предыдущие функции, loadContributorsConcurrent() из Request5Concurrent.kt и loadContributorsProgress() из Request6Progress.kt.

Подсказка к задаче 7

Разные корутины, которые одновременно получают списки участников для разных репозиториев, могут отправлять все полученные результаты в один и тот же канал:

val channel = Channel<List<User>>()
for (repo in repos) {
    launch {
        val users = TODO()
        // ...
        channel.send(users)
    }
}

Затем элементы из этого канала могут быть получены по одному и обработаны:

repeat(repos.size) {
    val users = channel.receive()
    // ...
}

Поскольку вызовы receive() являются последовательными, дополнительная синхронизация не требуется.

Решение задачи 7

Как и с функцией loadContributorsProgress(), вы можете создать переменную allUsers для хранения промежуточных состояний списка "все участники". Каждый новый список, полученный из канала, добавляется в список всех пользователей. Вы агрегируете результат и обновляете состояние с помощью обратного вызова updateResults.

suspend fun loadContributorsChannels(
    service: GitHubService,
    req: RequestData,
    updateResults: suspend (List<User>, completed: Boolean) -> Unit
) = coroutineScope {

    val repos = service
        .getOrgRepos(req.org)
        .also { logRepos(req, it) }
        .bodyList()

    val channel = Channel<List<User>>()
    for (repo in repos) {
        launch {
            val users = service.getRepoContributors(req.org, repo.name)
                .also { logUsers(repo, it) }
                .bodyList()
            channel.send(users)
        }
    }
    var allUsers = emptyList<User>()
    repeat(repos.size) {
        val users = channel.receive()
        allUsers = (allUsers + users).aggregate()
        updateResults(allUsers, it == repos.lastIndex)
    }
}
  • Результаты для разных репозиториев добавляются в канал, как только они готовы. Сначала, когда все запросы отправлены, и данные не получены, вызов receive() приостанавливается. В этом случае вся корутина "загрузка участников" приостанавливается.

  • Затем, когда список пользователей отправляется в канал, корутина "загрузка участников" возобновляется, вызов receive() возвращает этот список, и результаты немедленно обновляются.

Теперь вы можете запустить программу и выбрать опцию КАНАЛЫ, чтобы загрузить участников и увидеть результат.

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

Тестирование сопроцедур

Теперь давайте протестируем все решения, чтобы проверить, что решение с конкурентными сопроцедурами быстрее, чем решение с функциями suspend, и что решение с каналами быстрее, чем простое решение «прогресс».

В следующей задаче вы сравните общее время выполнения решений. Вы будете имитировать службу GitHub и заставлять эту службу возвращать результаты после заданных таймаутов:

repos request - returns an answer within 1000 ms delay
repo-1 - 1000 ms delay
repo-2 - 1200 ms delay
repo-3 - 800 ms delay

Последовательное решение с функциями suspend должно занимать около 4000 мс (4000 = 1000 + (1000 + 1200 + 800)). Конкурентное решение должно занимать около 2200 мс (2200 = 1000 + max(1000, 1200, 800)).

Для решений, которые показывают прогресс, вы также можете проверить промежуточные результаты с отметками времени.

Соответствующие тестовые данные определены в test/contributors/testData.kt, а файлы Request4SuspendKtTest, Request7ChannelsKtTest и так далее содержат простые тесты, которые используют вызовы имитируемой службы.

Однако здесь есть две проблемы:

  • Эти тесты занимают слишком много времени для выполнения. Каждый тест занимает около 2–4 секунд, и вам нужно каждый раз ждать результатов. Это не очень эффективно.

  • Вы не можете полагаться на точное время выполнения решения, потому что оно все равно занимает дополнительное время на подготовку и выполнение кода. Вы могли бы добавить константу, но тогда время будет отличаться от машины к машине. Задержки имитируемой службы должны быть больше этой константы, чтобы вы могли увидеть разницу. Если константа составляет 0,5 секунды, то задержки в 0,1 секунды будут недостаточными.

Лучшим способом было бы использовать специальные фреймворки для тестирования времени выполнения, выполняя тот же код несколько раз (что увеличивает общее время еще больше), но это сложно изучить и настроить.

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

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

Comparison for total running time

Чтобы использовать виртуальное время, замените вызов runBlocking на runTest. runTest принимает лямбда-расширение для TestScope в качестве аргумента. Когда вы вызываете delay в функции suspend внутри этого специального области действия, delay увеличит виртуальное время вместо задержки в реальном времени:

@Test
fun testDelayInSuspend() = runTest {
    val realStartTime = System.currentTimeMillis() 
    val virtualStartTime = currentTime
        
    foo()
    println("${System.currentTimeMillis() - realStartTime} ms") // ~ 6 ms
    println("${currentTime - virtualStartTime} ms")             // 1000 ms
}

suspend fun foo() {
    delay(1000)    // auto-advances without delay
    println("foo") // executes eagerly when foo() is called
}

Текущее виртуальное время можно проверить, используя свойство currentTime объекта TestScope.

Фактическое время выполнения в этом примере составляет несколько миллисекунд, тогда как виртуальное время равно аргументу задержки, которое равно 1000 миллисекундам.

Чтобы получить полный эффект «виртуального» delay в дочерних сопроцедурах, запустите все дочерние сопроцедуры с помощью TestDispatcher. В противном случае это не сработает. Этот диспетчер автоматически наследуется от других TestScope, если вы не укажете другой диспетчер:

@Test
fun testDelayInLaunch() = runTest {
    val realStartTime = System.currentTimeMillis()
    val virtualStartTime = currentTime

    bar()

    println("${System.currentTimeMillis() - realStartTime} ms") // ~ 11 ms
    println("${currentTime - virtualStartTime} ms")             // 1000 ms
}

suspend fun bar() = coroutineScope {
    launch {
        delay(1000)    // auto-advances without delay
        println("bar") // executes eagerly when bar() is called
    }
}

Если launch вызывается в контексте Dispatchers.Default в приведенном выше примере, тест завершится неудачно. Вы получите исключение, говорящее о том, что задача еще не завершена.

Вы можете протестировать функцию loadContributorsConcurrent() таким образом только в том случае, если она запускает дочерние сопроцедуры с унаследованным контекстом, не изменяя его с помощью диспетчера Dispatchers.Default.

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

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

По умолчанию компилятор выводит предупреждения, если вы используете экспериментальный API тестирования. Чтобы подавить эти предупреждения, аннотируйте функцию теста или весь класс, содержащий тесты, с помощью @OptIn(ExperimentalCoroutinesApi::class). Добавьте аргумент компилятора, указывающий компилятору, что вы используете экспериментальный API:

compileTestKotlin {
    kotlinOptions {
        freeCompilerArgs += "-Xuse-experimental=kotlin.Experimental"
    }
}

В проекте, соответствующем этому учебнику, аргумент компилятора уже добавлен в скрипт Gradle.

Задача 8

Переработайте следующие тесты в tests/tasks/ для использования виртуального времени вместо реального времени:

  • Request4SuspendKtTest.kt

  • Request5ConcurrentKtTest.kt

  • Request6ProgressKtTest.kt

  • Request7ChannelsKtTest.kt

Сравните общее время выполнения до и после применения вашей переработки.

Подсказка для задачи 8

  1. Замените вызов runBlocking на runTest, а System.currentTimeMillis() на currentTime.

    @Test
    fun test() = runTest {
        val startTime = currentTime
        // action
        val totalTime = currentTime - startTime
        // testing result
    }
    
  2. Разкомментируйте утверждения, которые проверяют точное виртуальное время.

  3. Не забудьте добавить @UseExperimental(ExperimentalCoroutinesApi::class).

Решение задачи 8

Вот решения для конкурентных и канальных случаев:

fun testConcurrent() = runTest {
    val startTime = currentTime
    val result = loadContributorsConcurrent(MockGithubService, testRequestData)
    Assert.assertEquals("Wrong result for 'loadContributorsConcurrent'", expectedConcurrentResults.users, result)
    val totalTime = currentTime - startTime

    Assert.assertEquals(
        "The calls run concurrently, so the total virtual time should be 2200 ms: " +
                "1000 for repos request plus max(1000, 1200, 800) = 1200 for concurrent contributors requests)",
        expectedConcurrentResults.timeFromStart, totalTime
    )
}

Сначала убедитесь, что результаты доступны ровно в ожидаемое виртуальное время, а затем проверьте сами результаты:

fun testChannels() = runTest {
    val startTime = currentTime
    var index = 0
    loadContributorsChannels(MockGithubService, testRequestData) { users, _ ->
        val expected = concurrentProgressResults[index++]
        val time = currentTime - startTime
        Assert.assertEquals(
            "Expected intermediate results after ${expected.timeFromStart} ms:",
            expected.timeFromStart, time
        )
        Assert.assertEquals("Wrong intermediate results after $time:", expected.users, users)
    }
}

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

Тесты для оставшихся задач «отложенных» и «прогресса» очень похожи — вы можете найти их в ветке solutions проекта.

Что дальше

  • Посмотрите мастер-класс Асинхронное программирование с Kotlin на KotlinConf.

  • Узнайте больше об использовании виртуального времени и экспериментального пакета тестирования.

Последнее изменение: 25 июля 2023 г.
Основы сопроцедур Отмена и таймауты

© 2010–2023 JetBrains s.r.o. and Kotlin Programming Language contributors
Licensed under the Apache License, Version 2.0.
https://kotlinlang.org/docs/coroutines-and-channels.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API