
Отладка корутин в 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 — подключите его к своему проекту и спокойно отслеживайте, что происходит под капотом вашего приложения. Будем рады обратной связи и контрибьюшенам!






















