Отладка корутин в Android — задача, с которой сталкивается каждый разработчик, использующий Kotlin. На один экран могут приходиться десятки вызовов launch и async, но стандартные инструменты показывают потоки, а не корутины. В итоге, когда одна из корутин зависает, разработчик оказывается в тупике: отладчик показывает живой поток, но не показывает, какая корутина на нём выполнялась, в каком suspend‑вызове она остановилась и кто её запустил. Приходится искать причину вслепую — расставлять логи и пытаться воспроизвести проблему вручную. Привет, Хабр! Меня зовут Вадим Мезенцев, я занимаюсь разработкой мобильных приложений в Яндекс Go. Сегодня я расскажу, как мы сделали инструмент, который автоматически отслеживает жизненный цикл корутин и показывает их в виде интерактивного дерева — прямо на устройстве, без внешних профайлеров. А самое главное — он в открытом доступе. Читать далее

Отладка корутин в 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 с деревом, фильтрами и экспортом — но это отдельная и не самая интересная часть, в этой статье останавливаться на ней не будем.
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 выставлен по умолчанию.
Сердце инструментации — 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 отработает корректно.
После того как лямбда 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. Так мы гарантированно избавляемся только от данных прошлого запуска приложения.
Метрика — это плоская строка в одной таблице. Никаких джойнов на запись, никаких связанных сущностей. Дерево восстанавливается при чтении.
@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 — подключите его к своему проекту и спокойно отслеживайте, что происходит под капотом вашего приложения. Будем рады обратной связи и контрибьюшенам!
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | «Это уже тысячу раз делали»: как мы добавили медиаленту в Яндекс Еду для iOS. А потом переделали | 0 | 8.46 | 19-06-2026 |
| 2 | Как я сделал сканер под iOS и Android для диагностики Wi-Fi-сети | 0 | 7.86 | 05-06-2026 |
| 3 | Платформа плагинов в Трекере, или Как мы пустили сторонний код в продукт с 600 000 пользователей | 0 | 11.48 | 27-07-2026 |
| 4 | Мне надоело писать один и тот же код. Поэтому я сделал Featuregen | 3 | 6 | 09-07-2026 |
| 5 | Один файл, одна команда: как мы упростили запуск dev-окружения с помощью Runium | 0 | 12.11 | 24-07-2026 |
| 6 | Опенсорсная Весёлая Ферма: поднимаем preview‑окружения без боли | 0 | 8.1 | 30-07-2026 |
| 7 | Как мигрировать Flutter-приложение на SPM — гайд с граблями, костылями и скриптами | 0 | 8.24 | 13-07-2026 |
| 8 | Как я собрал корпоративного AI‑ассистента в Mattermost для девопс‑задач | 0 | 8.29 | 22-07-2026 |
| 9 | Платформа плагинов в Трекере, или Как мы пустили сторонний код в продукт с 600 000 пользователей | 0 | 11.48 | 27-07-2026 |
| 10 | [Перевод] Хорошие инструменты — невидимы | 0 | 7.72 | 26-07-2026 |