Отладка для абсолютных новичков


Корутины Kotlin предоставили Android разработчикам модификатор suspend. Изучив его, вы поймете, почему функция suspend не возвращает ничего до тех пор, пока не будет завершена вся начатая работа, и как код может приостановить работу без блокировки потоков.

TL; DR; компилятор Kotlin создаст конечный автомат для каждой функции suspend, которая управляет выполнением корутины за нас!

Корутины 101

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

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

// Упрощенный код, который рассматривает только успешную работу fun loginUser(userId: String, password: String, userResult: Callback<User>) { // Асинхронные обратные вызовы userRemoteDataSource.logUserIn { user -> // Успешный сетевой запрос userLocalDataSource.logUserIn(user) { userDb -> // Результат сохранен в БД userResult.success(userDb) } } }

Эти обратные вызовы могут быть преобразованы в последовательные вызовы функций с помощью корутин:

suspend fun loginUser(userId: String, password: String): User { val user = userRemoteDataSource.logUserIn(userId, password) val userDb = userLocalDataSource.logUserIn(user) return userDb }

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

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

Но что же на самом деле делает компилятор под капотом, когда мы помечаем функцию как suspend?

Под капотом suspend

Возвращаясь к функции suspend loginUser, обратите внимание, что другие функции, которые она вызывает, также являются функциями suspend:

suspend fun loginUser(userId: String, password: String): User { val user = userRemoteDataSource.logUserIn(userId, password) val userDb = userLocalDataSource.logUserIn(user) return userDb } // UserRemoteDataSource.kt suspend fun logUserIn(userId: String, password: String): User // UserLocalDataSource.kt suspend fun logUserIn(userId: String): UserDb

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

Вы правильно поняли, компилятор напишет эти обратные вызовы за вас!

Интерфейс Continuation

Таким образом, функции suspend взаимодействуют друг с другом с помощью объектов Continuation. Continuation — это просто общий интерфейс обратного вызова с некоторой дополнительной информацией. Как мы увидим позже, он будет представлять собой сгенерированный конечный автомат функции suspend.

Давайте взглянем на его определение:

interface Continuation<in T> { public val context: CoroutineContext public fun resumeWith(value: Result<T>) }
  • context будет являться CoroutineContext, который будет использоваться в этом продолжении (continuation).
  • resumeWith возобновляет выполнение корутины с Result, который может содержать либо значение, являющееся результатом вычисления, вызвавшего приостановку, либо исключение.

Примечание: начиная с Kotlin 1.3 и далее, вы также можете использовать функции расширения resume(value: T) и resumeWithException(exception: Throwable), которые являются специализированными версиями вызова resumeWith.

Компилятор заменит модификатор suspend дополнительным параметром completion(типа Continuation) в сигнатуре функции, которая будет использоваться для передачи результата функции suspend вызвавшей ее корутине:

fun loginUser(userId: String, password: String, completion: Continuation<Any?>) { val user = userRemoteDataSource.logUserIn(userId, password) val userDb = userLocalDataSource.logUserIn(user) completion.resume(userDb) }

Наш пример вернет Unit вместо User. Объект User будет “возвращен” в добавленном параметре Continuation.

Байт-код функции suspend фактически возвращает Any?, потому что это тип объединения T | COROUTINE_SUSPENDED. Это позволяет функции по возможности возвращаться синхронно.

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

Вы также можете увидеть интерфейс Continuationв других местах:

• При преобразовании API-интерфейсов на основе обратного вызова в корутины с использованием suspendCoroutine или suspendCancellableCoroutine(предпочтительнее) вы непосредственно взаимодействуете с объектом Continuation, чтобы возобновить корутину, которая была приостановлена после выполнения блока кода, переданного в качестве параметра.

• Вы можете запустить корутину с помощью функции расширения startCoroutineдля функции suspend. Он принимает объект Continuation в качестве параметра, который будет вызван, когда новая корутина завершится либо результатом, либо исключением.

Применение диспетчеров

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

Существует подтип Continuation, называемый DispatchedContinuation, функция resume которого делает вызов Dispatcher доступным в CoroutineContext. Все диспетчеры будут вызывать отправку, кроме Dispatchers.Unconfined, чье переопределение функции isDispatchNeeded (которая вызывается перед dispatch) всегда возвращает false.

Сгенерированный конечный автомат

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

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

fun loginUser(userId: String, password: String, completion: Continuation<Any?>) { // Label 0 -> первое выполнение val user = userRemoteDataSource.logUserIn(userId, password) // Label 1 -> продолжает с userRemoteDataSource val userDb = userLocalDataSource.logUserIn(user) // Label 2 -> продолжает с userLocalDataSource completion.resume(userDb) }

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

fun loginUser(userId: String, password: String, completion: Continuation<Any?>) { when(label) { 0 -> { // Label 0 -> первое выполнение userRemoteDataSource.logUserIn(userId, password) } 1 -> { // Label 1 -> продолжает с userRemoteDataSource userLocalDataSource.logUserIn(user) } 2 -> { // Label 2 -> продолжает с userLocalDataSource completion.resume(userDb) } else -> throw IllegalStateException(...) } }

Этот код является неполным, поскольку различные состояния не имеют возможности обмениваться информацией. Для этого компилятор будет использовать тот же объект Continuation в функции. Вот почему исходный Continuation является Any? вместо возвращаемого типа исходной функции (т. е. User).

Кроме того, компилятор создаст закрытый класс, который 1) содержит необходимые данные и 2) рекурсивно вызывает функцию loginUser для возобновления выполнения. Вы можете проверить приблизительную реализацию сгенерированного класса ниже.

Внимание: комментарии не генерируются компилятором. Я добавил их, чтобы объяснить, что выполняется в коде.

fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) { class LoginUserStateMachine( // параметр completion обратный вызов к функции // которая вызвала loginUser completion: Continuation<Any?> ): CoroutineImpl(completion) { // Локальные переменные функции suspend var user: User? = null var userDb: UserDb? = null // Общие объекты во всех CoroutineImpls var result: Any? = null var label: Int = 0 // эта функции вызывает loginUser снова для вызова триггера // конечного автомата (метка уже будет находится в следующем состоянии) и // результат будет результатом вычисления предыдущего состояния override fun invokeSuspend(result: Any?) { this.result = result loginUser(null, null, this) } } ... }

Поскольку invokeSuspend снова вызовет loginUser только с информацией объекта Continuation, остальные параметры в сигнатуре функции loginUser становятся недействительными. На этом этапе компилятору просто нужно добавить информацию о том, как перемещаться между состояниями.

Первое, что ему нужно сделать, это знать, если 1) это первый раз, когда функция вызывается или 2) функция возобновилась из предыдущего состояния. Так он проверяет, имеет ли переданное continuation тип LoginUserStateMachine или нет:

fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) { ... val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion) ... }

Если это происходит в первый раз, он создаст новый экземпляр LoginUserStateMachine и сохранит полученный экземпляр completion в качестве параметра, чтобы он помнил, как возобновить функцию, вызвавшую его. Если это не так, то он просто продолжит выполнение конечного автомата (функция suspend).

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

/* Copyright 2019 Google LLC. SPDX-License-Identifier: Apache-2.0 */ fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) { ... val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion) when(continuation.label) { 0 -> { // Проверка на ошибки throwOnFailure(continuation.result) // В следующий раз вызова continuation он должен перейти в состояние 1 continuation.label = 1 // Объект continuation передан в logUserIn для продолжения // выполнения этого конечного автомата, когда он закончится userRemoteDataSource.logUserIn(userId!!, password!!, continuation) } 1 -> { // Проверка на ошибки throwOnFailure(continuation.result) // Получить результат предыдущего состояния continuation.user = continuation.result as User // В следующий раз вызова continuation он должен перейти в состояние 1 continuation.label = 2 // Объект continuation передан в logUserIn для продолжения // выполнения этого конечного автомата, когда он закончится userLocalDataSource.logUserIn(continuation.user, continuation) } ... // оставляем последнее состояние на всякий случай } }

Сравните код выше с предыдущими фрагментами. Давайте посмотрим, что генерирует компилятор:

  • Аргументом оператора when является label из экземпляра LoginUserStateMachine.
  • Каждый раз, когда обрабатывается новое состояние, происходит проверка на случай сбоя, когда эта функция была приостановлена.
  • Перед вызовом следующей функции suspend (т. е. logUserIn) label экземпляра LoginUserStateMachine обновляется до следующего состояния.
  • Когда внутри этого конечного автомата происходит вызов другой функции suspend, экземпляр continuation (типа LoginUserStateMachine) передается в качестве параметра. Вызываемая функция suspend также была преобразована компилятором, и это еще один конечный автомат, подобный тому, который принимает объект continuation в качестве параметра! Когда конечный автомат этой функции suspend завершится, она возобновит его выполнение.

Последнее состояние отличается тем, что оно должно возобновить выполнение функции, которая вызвала эту функцию. Как видно из кода, она вызывает resume для переменной cont, хранящейся (во время построения) в LoginUserStateMachine:

/* Copyright 2019 Google LLC. SPDX-License-Identifier: Apache-2.0 */ fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) { ... val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion) when(continuation.label) { ... 2 -> { // Проверка на ошибки throwOnFailure(continuation.result) // Получить результат предыдущего состояния continuation.userDb = continuation.result as UserDb // Продолжает выполнение функции, которая вызвала эту continuation.cont.resume(continuation.userDb) } else -> throw IllegalStateException(...) } }

Компилятор Kotlin делает для нас очень много. Из этой функции suspend:

suspend fun loginUser(userId: String, password: String): User { val user = userRemoteDataSource.logUserIn(userId, password) val userDb = userLocalDataSource.logUserIn(user) return userDb }

Компилятор сгенерировал:

/* Copyright 2019 Google LLC. SPDX-License-Identifier: Apache-2.0 */ fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) { class LoginUserStateMachine( // параметр continuation - это обратный вызов функции, которая вызвала loginUser completion: Continuation<Any?> ): CoroutineImpl(completion) { // объекты для хранения в функции suspend var user: User? = null var userDb: UserDb? = null // Общие объекты для всех CoroutineImpl var result: Any? = null var label: Int = 0 // Эта функция вызывает loginUser снова для вызова триггера // конечного автомата (метка уже будет находиться в следующем состоянии) и // результат будет результатом вычисления предыдущего состояния override fun invokeSuspend(result: Any?) { this.result = result loginUser(null, null, this) } } val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion) when(continuation.label) { 0 -> { // Проверка на ошибки throwOnFailure(continuation.result) // В следующий раз вызова continuation он должен перейти в состояние 1 continuation.label = 1 // Объект continuation передан в logUserIn для продолжения // выполнения этого конечного автомата, когда он закончится userRemoteDataSource.logUserIn(userId!!, password!!, continuation) } 1 -> { // Проверка на ошибки throwOnFailure(continuation.result) // Получение результата предыдущего состояния continuation.user = continuation.result as User // В следующий раз вызова continuation он должен перейти в состояние 2 continuation.label = 2 // Объект continuation передан в logUserIn для продолжения // выполнения этого конечного автомата, когда он закончится userLocalDataSource.logUserIn(continuation.user, continuation) } 2 -> { // Проверка на ошибки throwOnFailure(continuation.result) // Получение результата предыдущего состояния continuation.userDb = continuation.result as UserDb // Продолжает выполнение функции, которая вызвала эту continuation.cont.resume(continuation.userDb) } else -> throw IllegalStateException(...) } }

Зная, что делает компилятор под капотом, вы можете лучше понять, почему функция suspend ничего не вернет, пока не будет завершена вся начатая работа. Кроме того, как код может приостановить работу без блокировки потоков: информация о том, что должно быть выполнено при возобновлении функции, хранится в объекте Continuation!


Перевод статьи Michael Boegner: Debugging for Absolute Beginners


Поделиться статьей:


Вернуться к статьям

Комментарии

    Ничего не найдено.