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

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

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

import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking

data class UserDTO(
    val id: Long,
    val login: String
)

suspend fun getUserByToken(token: String): UserDTO {
    delay(100)
    val id = 123L //предположим, что здесь обращение в кэш, который по токену пользователя находит его id
    return getUserById(id)
}

suspend fun getUserById(id: Long): UserDTO {
    delay(100) //тут происходит обращение в БД
    //предположим, данные в кэше и в БД рассинхронизированы и пользователя с данным id нет, поэтому мы кидаем исключение
    throw Exception("user not found")
}

fun main() {
    try {
        runBlocking {
            getUserByToken("secretToken")
        }
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

Данных код выведет такой стектрейс:

java.lang.Exception: user not found
  at MainKt.getUserById(main.kt:18)
  at MainKt$getUserById$1.invokeSuspend(main.kt)
  at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
  at kotlinx.coroutines.DispatchedTaskKt.resume(DispatchedTask.kt:234)
  at kotlinx.coroutines.DispatchedTaskKt.dispatch(DispatchedTask.kt:166)
  at kotlinx.coroutines.CancellableContinuationImpl.dispatchResume(CancellableContinuationImpl.kt:397)
  at kotlinx.coroutines.CancellableContinuationImpl.resumeImpl(CancellableContinuationImpl.kt:431)
  at kotlinx.coroutines.CancellableContinuationImpl.resumeImpl$default(CancellableContinuationImpl.kt:420)
  at kotlinx.coroutines.CancellableContinuationImpl.resumeUndispatched(CancellableContinuationImpl.kt:518)
  at kotlinx.coroutines.EventLoopImplBase$DelayedResumeTask.run(EventLoop.common.kt:494)
  at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:279)
  at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:85)
  at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:59)
  at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source)
  at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking$default(Builders.kt:38)
  at kotlinx.coroutines.BuildersKt.runBlocking$default(Unknown Source)
  at MainKt.main(main.kt:23)
  at MainKt.main(main.kt)

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

Куда же делся пропавший вызов? Ответ прост и кроется в реализации корутин: при "пробуждении" (вызове метода resumeWith) корутина не восстанавливает весь свой стек вызова, а лишь вызывает одну функцию, которая находится на вершине стека всей корутины. После завершения этой функции вызывается следующая по порядку и т.д.

Соответственно, если в корутине кидается исключение, в её стектрейс попадает только функция на вершине стека этой самой корутины.

Причина ясна. Ничего нельзя поделать, отладка - сложная штука, жизнь - боль :(

Но погодите, команда Kotlin'а о нас подумала и сделала специальный дебаг-режим для корутин, в котором данная проблема решается.

Запускает наш пример в этом самом дебаг-режиме(для этого, например, достаточно добавить ключ -ea в строке запуска JVM) и видим... что ничего не изменилось.

В чем же причина? После долгого вчитывания в код Kotlin stdlib понимаем, что механика восстановления стектрейса работает только для случая, когда исключение передаётся при "пробуждении корутины с исключением" (вызовом resumeWith c исключением). Исключения, созданные в коде самой корутины, (как в нашем случае) никак не изменяются. :-(

Я написал библиотеку, реализующую другой подход для восстановления стека корутин:

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

  2. Подменяем реализацию "пробуждения": перед "обычным пробуждением" вызываем наши классы-заглушки в порядке стека корутины.

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

Библиотека называется Stacktrace-decoroutinator, активно использует кодогенерацию в рантайме и MethodHandle API. Поэтому работает для JVM >= 1.8 и для Android API >= 26.

Модифицируем наш пример, добавим зависимость dev.reformator.stacktracedecoroutinator:stacktrace-decoroutinator-jvm:2.1.0 и в методе main инициализацию:

import dev.reformator.stacktracedecoroutinator.runtime.DecoroutinatorRuntime
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking

data class UserDTO(
    val id: Long,
    val login: String
)

suspend fun getUserByToken(token: String): UserDTO {
    delay(100)
    val id = 123L //сдесь должно быть обращение в кеш, который по токену пользователя находит его id
    return getUserById(id)
}

suspend fun getUserById(id: Long): UserDTO {
    delay(100) //тут происходит обращение в БД
    //предположим, данные в кеше и в БД рассинхронизированы и пользователя с данным id нет, поэтому мы кидаем исключение
    throw Exception("user not found")
}

fun main() {
    DecoroutinatorRuntime.load() // загружаем Stacktrace-decoroutinator
    try {
        runBlocking {
            getUserByToken("secretToken")
        }
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

Запускает, видим стектрейс с пропавшим методом getUserByToken:

java.lang.Exception: user not found
  at MainKt.getUserById(main.kt:19)
  at MainKt$getUserById$1.invokeSuspend(main.kt)
  at kotlin.coroutines.jvm.internal.BaseContinuationImpl.decoroutinatorResumeWith$lambda-1(continuation-stdlib.kt:47)
  at MainKt.getUserByToken(main.kt:13)
  at MainKt$main$1.invokeSuspend(main.kt:26)
  at kotlin.coroutines.jvm.internal.BaseContinuationImpl.decoroutinatorResumeWith(continuation-stdlib.kt:177)
  at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(continuation-stdlib.kt:21)
  at kotlinx.coroutines.DispatchedTaskKt.resume(DispatchedTask.kt:234)
  at kotlinx.coroutines.DispatchedTaskKt.dispatch(DispatchedTask.kt:166)
  at kotlinx.coroutines.CancellableContinuationImpl.dispatchResume(CancellableContinuationImpl.kt:397)
  at kotlinx.coroutines.CancellableContinuationImpl.resumeImpl(CancellableContinuationImpl.kt:431)
  at kotlinx.coroutines.CancellableContinuationImpl.resumeImpl$default(CancellableContinuationImpl.kt:420)
  at kotlinx.coroutines.CancellableContinuationImpl.resumeUndispatched(CancellableContinuationImpl.kt:518)
  at kotlinx.coroutines.EventLoopImplBase$DelayedResumeTask.run(EventLoop.common.kt:494)
  at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:279)
  at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:85)
  at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:59)
  at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source)
  at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking$default(Builders.kt:38)
  at kotlinx.coroutines.BuildersKt.runBlocking$default(Unknown Source)
  at MainKt.main(main.kt:25)
  at MainKt.main(main.kt)

В ближайшее время собираюсь использовать свою библиотеку в продакшн среде. Буду рад, если кто-нибуть присоединится)

Комментарии (6)


  1. michailnikolaev
    08.01.2022 21:09
    +2

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


    1. DenisB12 Автор
      08.01.2022 21:37

      Зачем классы-заглушки?

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

      suspend fun fun1() {
          fun2()
          delay(10)
      }
      
      suspend fun fun2() {
          fun3()
          delay(10)
      }
      
      suspend fun fun3() {
          delay(10)
          throw Exception()
      }

      Мы вызваем fun1, после этого вызываются fun2 и fun3 и корутина "засыпает".


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

      C моей библиотекой происходит по-другому: генерируется класс-заглушка, содержащий методы fun1 и fun2, fun1 вызывает fun2, а fun2 "будит" корутину. В результате стек вызова содержит все 3 метода. И стектрейс исключения содержит их все.

      Сколько их генериться? 

      Столько же, сколько содержится классов с suspend-функциями.

      Когда?

      Для класса A генерируется заглушка во время первого "пробуждения" корутины, содержащей в стеке методы класса A. После этого заглушка кешируется и повторные "пробуждения" не приводят к генерации заглушки.

      Можно же, наверное, создать руками любой стектрейс, не?

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


      1. michailnikolaev
        09.01.2022 12:52
        +1

        А как вы понимаете, какая функцию какую вызывает в классе А (ведь все уже скомпилировано)? Ведь там может быть сколь угодно жуткая логика, не?

        Как это будет работать вот для такого случая (для многократных вызовов fun1). Не будет ли иногда в стектрейсе лишнего fun2?

        suspend fun fun1() {
            if ((0..1).random() == 0) {
              fun2("throw fun2")
            } else {
              fun3("directly fun3")
            }
            delay(10)
        }
        
        suspend fun fun2(x: String) {
            fun3(x)
            delay(10)
        }
        
        suspend fun fun3(x: String) {
            delay(10)
            throw Exception(x)
        }


        1. DenisB12 Автор
          09.01.2022 13:07
          +1

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

          Это обеспечивается за счет использования MethodHandle API. Просто, грубо говоря, при вызове метода заглушки я ему передаю массив MethodHandle[] которые нужно вызвать.

          Конкретный порядок я определяю именно в момент "пробуждения" корутины. В вашем примере все будет работать

          upd:
          Если вопрос в том, как я определяю стек вызова во время "пробуждения" - в Kotlin stdlib есть функция для этого.


  1. Sigest
    09.01.2022 08:59
    +1

    Я попробую вашу либу, которая для JVM. У меня есть бек полностью на котлине + спринг, правда он сделан на блокирующих библиотеках, но я его потихоньку мигрирую на реактивные с использованием корутин. Подключу и буду юзать, спасибо за хорошую идею


    1. DenisB12 Автор
      09.01.2022 09:46

      ???? Баги/вопросы/предложения/замечания можете оставлять в Github issue, Gitter или в личном сообщении мне)