
Отладка корутин в 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:
После вызова
launch/asyncна стеке лежит возвращённый Job (или Deferred).Мы дублируем его (
DUP) — одна копия остаётся как оригинальное возвращаемое значение, вторая уходит в наш хук.Кладём строку с местом вызова (
launchSite), включая имя класса, метода и номер строки.Вызываем статический метод
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 — подключите его к своему проекту и спокойно отслеживайте, что происходит под капотом вашего приложения. Будем рады обратной связи и контрибьюшенам!