Отладка корутин в Android — задача, с которой сталкивается каждый разработчик, использующий Kotlin. На один экран могут приходиться десятки вызовов launch и async, но стандартные инструменты показывают потоки, а не корутины. В итоге, когда одна из корутин зависает, разработчик оказывается в тупике: отладчик показывает живой поток, но не показывает, какая корутина на нём выполнялась, в каком suspend‑вызове она остановилась и кто её запустил. Приходится искать причину вслепую — расставлять логи и пытаться воспроизвести проблему вручную. 

Привет, Хабр! Меня зовут Вадим Мезенцев, я занимаюсь разработкой мобильных приложений в Яндекс Go. Сегодня я расскажу, как мы сделали инструмент, который автоматически отслеживает жизненный цикл корутин и показывает их в виде интерактивного дерева — прямо на устройстве, без внешних профайлеров. А самое главное — он в открытом доступе.


Зачем вообще следить за корутинами

Kotlin‑корутины стали стандартом для асинхронного кода в Android. В типичном приложении Яндекса на один экран могут приходиться десятки вызовов launch и async: загрузка данных, анимации, аналитика, обновление кеша. Код выглядит линейно, но под капотом порождается целое дерево параллельных задач.

Проблемы начинаются, когда:

  • корутина зависает на десятки секунд, а вы не понимаете, какая именно и откуда запущена;

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

  • launch вызывается в цикле, создавая сотни Job, и вы не видите этого в стандартном профайлере;

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

Стандартный Android Profiler показывает потоки, но не корутины. Kotlinx‑coroutines‑debug помогает в unit‑тестах, но не на реальном устройстве с живым UI. Так что мы быстро поняли, что там нужен свой инструмент, который:

  • автоматически находит все вызовы launch/async — без ручной расстановки меток;

  • строит дерево parent‑child‑связей между Job;

  • измеряет длительность каждой корутины и фиксирует отмены и исключения;

  • показывает всё это в удобном UI прямо на девайсе.

Так появился Coroutine Tracer — новый плагин в нашей библиотеке Demeter.

Архитектура: от байт‑кода до дерева на экране

Coroutine Tracer работает в три этапа:

На этапе сборки Gradle‑плагин через AGP Instrumentation API находит все вызовы launch и async и вставляет после них вызов нашего хука.

В рантайме хук перехватывает возвращённый Job, запоминает место запуска, поток, диспатчер, время старта и регистрирует callback на завершение через invokeOnCompletion.

Данные попадают через Channel в Room‑базу, где хранятся в плоской таблице с parent‑child‑связями. Поверх этого живёт UI‑плагин на Compose с деревом, фильтрами и экспортом — но это отдельная и не самая интересная часть, в этой статье останавливаться на ней не будем.

Этап 1: ASM‑инструментация байт‑кода

Как работает Gradle‑плагин

Gradle‑плагин регистрирует AsmClassVisitorFactory через AGP Instrumentation API. Это стандартный механизм AGP для трансформации байт‑кода при сборке:

class DemeterCoroutineTracerPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        target.requireAndroidApp()

        target.androidComponents {
            onVariants { variant ->
                val extension = variant.getExtension(DemeterCoroutineTracerExtension::class.java)
                    ?: return@onVariants

                if (!extension.enabled.get()) return@onVariants

                variant.instrumentation.transformClassesWith(
                    CoroutineTracerClassVisitorFactory::class.java,
                    InstrumentationScope.ALL
                ) {
                    it.asmDebug.set(extension.debug)
                    it.includedClasses.set(extension.includedClasses)
                    it.excludedClasses.set(extension.excludedClasses)
                }
            }
        }
    }
}

Ключевой момент — InstrumentationScope.ALL. Мы инструментируем все классы, включая зависимости, но фильтруем их в isInstrumentable:

abstract class CoroutineTracerClassVisitorFactory :
    AsmClassVisitorFactory<CoroutineTracerParams> {

    override fun isInstrumentable(classData: ClassData): Boolean {
        val className = classData.className

        // Пропускаем стандартные библиотеки и сам Demeter.
        if (className.startsWith("java.") 
            className.startsWith("kotlin.") 
            className.startsWith("kotlinx.") ||
            className.startsWith("com.yandex.demeter")
        ) return false

        val includedClasses = parameters.get().includedClasses.getOrElse(emptyList())
        val excludedClasses = parameters.get().excludedClasses.getOrElse(emptyList())

        // Без явного списка включённых пакетов ничего не инструментируем.
        if (includedClasses.isEmpty()) return false

        return includedClasses.any { className.startsWith(it) } &&
            excludedClasses.none { className.startsWith(it) }
    }
}

Это даёт разработчику полный контроль: инструментируются только те пакеты, которые он явно указал в конфигурации. Без includedClasses плагин не инструментирует ничего — zero overhead выставлен по умолчанию.

Самое интересное: перехват launch/async

Сердце инструментации — CoroutineTracerClassVisitor. Он оборачивает каждый метод в адаптер, который отслеживает вызовы корутин‑билдеров:

class CoroutineTracerClassVisitor(
    classVisitor: ClassVisitor,
    private val className: String,
) : ClassVisitor(ASM_API_VERSION, classVisitor) {

    override fun visitMethod(
        access: Int, name: String, descriptor: String,
        signature: String?, exceptions: Array<out String>?,
    ): MethodVisitor? {
        val mv = super.visitMethod(access, name, descriptor, signature, exceptions)
            ?: return null
        return CoroutineTracerMethodAdapter(className, mv, access, name, descriptor)
    }
}

Адаптер наследуется от AdviceAdapter и перехватывает каждый INVOKE:

private class CoroutineTracerMethodAdapter(
    private val className: String,
    methodVisitor: MethodVisitor,
    access: Int,
    private val methodName: String,
    descriptor: String,
) : AdviceAdapter(ASM_API_VERSION, methodVisitor, access, methodName, descriptor) {

    private var lastLineNumber: Int = -1

    override fun visitLineNumber(line: Int, start: Label?) {
        lastLineNumber = line
        super.visitLineNumber(line, start)
    }

    override fun visitMethodInsn(
        opcode: Int, owner: String, name: String,
        descriptor: String, isInterface: Boolean,
    ) {
        // Сначала вызываем оригинальный метод.
        super.visitMethodInsn(opcode, owner, name, descriptor, isInterface)

        // После вызова launch/async на стеке лежит возвращённый Job.
        if (isCoroutineBuilderCall(owner, name)) {
            val launchSite = "$className#$methodName:$lastLineNumber"

            // Stack: [..., Job]
            mv.visitInsn(DUP)               // Stack: [..., Job, Job]
            mv.visitLdcInsn(launchSite)      // Stack: [..., Job, Job, String]
            mv.visitMethodInsn(
                INVOKESTATIC,
                "com/.../CoroutineTracerAsm",
                "onCoroutineLaunched",
                "(Lkotlinx/coroutines/Job;Ljava/lang/String;)V",
                false
            )
            // Stack: [..., Job] — оригинальное возвращаемое значение сохранено.
        }
    }
}

Здесь особенно интересна работа со стеком JVM:

  1. После вызова launch/async на стеке лежит возвращённый Job (или Deferred).

  2. Мы дублируем его (DUP) — одна копия остаётся как оригинальное возвращаемое значение, вторая уходит в наш хук.

  3. Кладём строку с местом вызова (launchSite), включая имя класса, метода и номер строки.

  4. Вызываем статический метод CoroutineTracerAsm.onCoroutineLaunched.

Оригинальный Job остаётся на стеке — вызывающий код работает как раньше, без изменений в логике.

Распознавание корутин‑билдеров

Метод isCoroutineBuilderCall проверяет, что вызов — это именно launch или async из kotlinx.coroutines:

private fun isCoroutineBuilderCall(owner: String, name: String): Boolean {
    if (name != "launch" && name != "async" &&
        name != "launch\$default" && name != "async\$default"
    ) return false

    return owner.startsWith("kotlinx/coroutines/BuildersKt")
}

Может возникнуть вопрос: почему мы проверяем и launch$default? Дело в том, что Kotlin компилирует функции с default‑параметрами в два метода: основную и $default‑версию. Большинство вызовов launch и async в реальном коде используют именно $default‑вариант, поскольку у билдеров есть параметры со значениями по умолчанию (context, start).

Что происходит с байт‑кодом

Допустим, у нас есть код:

class MyViewModel : ViewModel() {
    fun loadData() {
        viewModelScope.launch {
            val data = repository.fetchData()
            _state.value = data
        }
    }
}

После инструментации в байт‑коде метода loadData будет новая вставка (ниже приведён псевдокод):

// Оригинальный вызов launch.
INVOKESTATIC kotlinx/coroutines/BuildersKt.launch$default(...)
// Наша вставка:
DUP                                              // Дублируем Job.
LDC "MyViewModel#loadData:42"                     // Строка с launch site.
INVOKESTATIC CoroutineTracerAsm.onCoroutineLaunched(Job, String)
// Job остаётся на стеке для дальнейшего использования.

Этап 2: рантайм‑отслеживание жизненного цикла

Перехват запуска корутины

Когда инструментированный код выполняется, вызывается CoroutineTracerAsm.onCoroutineLaunched:

object CoroutineTracerAsm {
    private val traceIdGenerator = AtomicLong(0)
    private val queue: Channel<AsmCoroutineMetric> = Channel(CHANNEL_CAPACITY)
    internal val metricsQueue: Flow<AsmCoroutineMetric> get() = queue.receiveAsFlow()

    private val activeCoroutines = ConcurrentHashMap<Long, CoroutineTraceInfo>()
    // Reverse index: Job -> traceId. Позволяет искать родителя за O(N_active),
       а не O(N_active * children) при каждом запуске корутины.
    private val jobToTraceId = ConcurrentHashMap<Job, Long>()

    private val droppedCount = AtomicLong(0)

    @JvmStatic
    fun onCoroutineLaunched(job: Job, launchSite: String) {
        val traceId = traceIdGenerator.incrementAndGet()
        val startTimeNs = System.nanoTime()
        val threadName = Thread.currentThread().name

        val parentTraceId = findParentTraceId(job)
        val depth = if (parentTraceId != null) {
            (activeCoroutines[parentTraceId]?.depth ?: -1) + 1
        } else {
            0
        }

        // AbstractCoroutine из kotlinx-coroutines реализует и Job, и CoroutineScope —
           через это можно достать диспатчер. Деталь реализации, но стабильная между релизами.
        val dispatcherName = (job as? CoroutineScope)
            ?.coroutineContext
            ?.get(ContinuationInterceptor)
            ?.toString()

        val info = CoroutineTraceInfo(
            traceId, parentTraceId, job, launchSite,
            startTimeNs, threadName, depth, dispatcherName,
        )
        activeCoroutines[traceId] = info
        jobToTraceId[job] = traceId

        job.invokeOnCompletion { cause ->
            onCoroutineCompleted(traceId, info, cause)
        }
    }
}

Здесь есть несколько важных деталей:

  • Thread‑safety. Код вызывается из разных потоков (в зависимости от диспатчера), поэтому мы используем AtomicLong для генерации ID и ConcurrentHashMap для хранения активных корутин.

  • Parent‑child‑связи через обратный индекс. В kotlinx.coroutines публичного API «получить родителя Job» нет — есть только Job.children. Если при каждом запуске перебирать все активные корутины и для каждой обходить её children, в горячем цикле (например, в том самом launch в for‑цикле) получится квадратичная сложность. Поэтому мы храним второй индекс — jobToTraceId: Job -> traceId, который ограничивает поиск только активными Job:

private fun findParentTraceId(childJob: Job): Long? {
    // Обходим только активные Job. children по-прежнему придётся итерировать,
       но множество активных Job обычно мало, и identity-сравнение дешёвое.
    return jobToTraceId.entries.firstOrNull { (parentJob, _) ->
        parentJob.children.any { it === childJob }
    }?.value
}

Identity‑сравнение (===) гарантирует, что мы мэтчим именно тот Job, который вернул билдер. ConcurrentHashMap поддерживает weakly‑consistent‑итерацию. Если в момент перебора другой поток удалит запись, итератор не упадёт: в худшем случае — не найдёт родителя и корутина окажется на нулевом уровне.

Глубина вложенности вычисляется инкрементально — на единицу больше, чем у родителя. Корутины без родителя (корни) получают depth = 0.

Отслеживание завершения

Через invokeOnCompletion мы узнаём, когда корутина завершается (штатно, с отменой или же с исключением):

job.invokeOnCompletion { cause ->
    val endTimeNs = System.nanoTime()
    activeCoroutines.remove(traceId)
    jobToTraceId.remove(job)

    val isCancelled = cause is CancellationException
    val exceptionName = cause?.takeIf { it !is CancellationException }
        ?.let { "${it::class.simpleName}: ${it.message}" }

    queue.trySend(
        AsmCoroutineMetric(
            traceId = traceId,
            parentTraceId = parentTraceId,
            launchSite = launchSite,
            startTimeNs = startTimeNs,
            endTimeNs = endTimeNs,
            launchThreadName = threadName,
            completionThreadName = Thread.currentThread().name,
            isCancelled = isCancelled,
            exception = exceptionName,
            depth = depth,
            dispatcherName = dispatcherName,
        )
    ).onFailure {
        // Канал ограничен сверху (CHANNEL_CAPACITY = 10_000), чтобы не съесть всю память,
          если потребитель не успевает читать. На случай переполнения — считаем потери.
        val dropped = droppedCount.incrementAndGet()
        Log.w(TAG, "Coroutine metric dropped (channel full or closed). Total dropped: $dropped")
    }
}

И снова несколько важных моментов:

  • CancellationException ≠ ошибка. Это штатный механизм structured concurrency. Любое другое исключение мы сохраняем отдельным полем — это то, что нужно расследовать.

  • Канал ограничен сверху. Channel(CHANNEL_CAPACITY) с CHANNEL_CAPACITY = 10_000 защищает от OOM, если потребитель не успевает читать. При переполнении trySend возвращает failure — мы инкрементим счётчик потерь и логируем. На UI потерянные метрики видны как пропущенные узлы дерева, но приложение остаётся живым.

  • Уже завершённые Job безопасны. Если Job к моменту регистрации уже завершён, invokeOnCompletion вызовет лямбду синхронно — но к этому моменту мы уже положим запись в activeCoroutines и jobToTraceId, так что remove отработает корректно.

Этап 3: канал → репозиторий → Room

После того как лямбда invokeOnCompletion отправила метрику в канал, её должен кто‑то достать. Это делает CoroutineMetricsHandler — он подписывается на metricsQueue в скоупе, переданном при инициализации плагина (обычно — applicationScope):

internal object CoroutineMetricsHandler {
    private var collectJob: Job? = null
    private lateinit var repository: CoroutineMetricsRepository

    fun init(context: Context, consumerScope: CoroutineScope) {
        repository = CoroutineMetricsRepositoryImpl.getInstance(context)
        startCollecting(consumerScope)
    }

    private fun startCollecting(scope: CoroutineScope) {
        collectJob?.cancel()
        collectJob = scope.launch {
            // Чистим прошлую сессию: события текущей сессии уже лежат в канале
               и будут собраны collect ниже, так что данные не потеряются.
            repository.clear()
            CoroutineTracerAsm.metricsQueue.collect { metric ->
                repository.upsertMetric(metric)
                CoroutineMetricsReportersNotifier.report(metric)
            }
        }
    }
}

Здесь есть нюанс с порядком: сначала clear(), а потом collect. Между этими шагами может прийти событие текущей сессии — оно осядет в Channel, и его вычитает следующий же collect. Так мы гарантированно избавляемся только от данных прошлого запуска приложения.

Хранение в Room

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

@Entity(
    tableName = "coroutine_metrics_raw",
    indices = [
        Index(value = ["parentTraceId"]),
        Index(value = ["startTimeMs"]),
    ]
)
data class CoroutineMetricRawEntity(
    @PrimaryKey val traceId: Long,
    val parentTraceId: Long?,
    val launchSite: String,
    val durationMs: Long,
    val startTimeMs: Long,
    val launchThreadName: String,
    val completionThreadName: String,
    val isCancelled: Boolean,
    val exception: String?,
    val depth: Int,
    val dispatcherName: String?,
    val createdAt: Long = System.currentTimeMillis(),
)

Индекс по parentTraceId нужен, чтобы быстро находить детей при построении поддерева. Индекс по startTimeMs — чтобы листать корни в порядке появления без полного скана.

Восстановление дерева

Главный запрос — рекурсивный CTE. Мы достаём всё поддерево от выбранного корня одним SQL‑запросом, не таская данные по одному:

@Query("""
    WITH RECURSIVE coroutine_tree AS (
        SELECT  FROM coroutine_metrics_raw WHERE traceId = :rootTraceId
        UNION ALL
        SELECT r. FROM coroutine_metrics_raw r
        INNER JOIN coroutine_tree ct ON r.parentTraceId = ct.traceId
    )
    SELECT * FROM coroutine_tree ORDER BY depth ASC, startTimeMs ASC
""")
suspend fun getCoroutineTree(rootTraceId: Long): List<CoroutineMetricRawEntity>

А для общего экрана списка мы используем Flow<List<CoroutineMetricRawEntity>> через стандартный Room‑механизм: любая запись в таблицу автоматически порождает новую эмиссию.

Сама сборка дерева в репозитории — итеративная, без рекурсии (на устройстве легко получить корутины с глубиной в десятки уровней, тогда рекурсия упрётся в StackOverflowError):

private fun buildTree(
    rootTraceId: Long,
    entities: List<CoroutineMetricRawEntity>,
): CoroutineTraceNode? {
    if (entities.isEmpty()) return null
    val rootEntity = entities.firstOrNull { it.traceId == rootTraceId } ?: return null
    val childrenMap = entities.groupBy { it.parentTraceId }
    // Строим листья первыми — к моменту создания родителя его дети уже готовы.
    val built = HashMap<Long, CoroutineTraceNode>(entities.size)
    entities.sortedByDescending { it.depth }.forEach { entity ->
        val childNodes = childrenMap[entity.traceId]
            ?.mapNotNull { built[it.traceId] }
            ?: emptyList()
        built[entity.traceId] = entity.toTraceNode(childNodes)
    }
    return built[rootEntity.traceId]
}

В итоге репозиторий отдаёт UI готовый Flow<List<CoroutineTraceNode>> — список корней, у каждого из которых уже собрано дерево детей.

Заключение

Coroutine Tracer решает знакомую многим Android‑разработчикам боль: невозможность увидеть, что происходит с корутинами в живом приложении. Теперь вместо расстановки логов и отладки по воспроизведению можно увидеть полную картину:

  • Автоматический перехват → ASM‑инструментация находит все launch/async без ручного вмешательства в код.

  • Иерархия → parent‑child‑связи через обратный индекс по Job отражают реальную структуру structured concurrency без квадратичного оверхеда на горячем пути.

  • Хранение → плоская Room‑таблица плюс рекурсивный CTE для восстановления поддеревьев одним запросом; защита от OOM ограниченным каналом.

  • Совместимый экспорт → наружу метрики уезжают через общий для Demeter RawTraceMetric: CSV, Flamegraph и Firefox Profiler работают из коробки.

Инструмент доступен как часть открытой библиотеки Demeter — подключите его к своему проекту и спокойно отслеживайте, что происходит под капотом вашего приложения. Будем рады обратной связи и контрибьюшенам!

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