Привет! Меня зовут Абакар, я работаю главным техническим лидером разработки в Альфа-Банке.
Сегодня мы поговорим на тему, связанную с корутинами, а именно погрузимся чуть глубже в недра компилятора Kotlin. На данную тему мы с Александром Гиревым готовили доклад на «Мобиус».
В рамках подготовки доклада нам пришлось заглянуть в святая святых для всех «андроидеров», а именно в исходники компилятора Kotlin. Ну что ж, поглядим, что мы там накопали. Поехали!
Важный дисклеймер: этой статьёй мне хочется лишь пробудить твой интерес и желание покопаться в исходниках компилятора. Какие-то моменты в статье проходятся поверхностно, чтобы она не сильно разрослась.

Давай посмотрим на кусочек кода
class Example {
suspend fun createPost(token: Token, item: Item){
}
}
data class Token(val token: String)
data class Item(val name: String)
Мы видим обычный класс с suspend-функцией. Взглянем, во что превратится эта suspend-функция после преобразований от компилятора:
public final class Example {
@Nullable
public final Object createPost(
@NotNull Token token,
@NotNull Item item,
@NotNull Continuation $completion ) - //А ВОТ И CONTINUATION {
return Unit.INSTANCE;
}
}
Примечание: Как смотреть байткод в IntelliJ IDEA:
Откройте вкладку Tools → Kotlin → Show Kotlin Bytecode.
Нажмите Decompile для просмотра Java-аналога.

Как видим, появился тот самый параметр, про который любят спрашивать и рассказывать на собесах — Continuation
. Все знают, что он появляется в функции.
Но как он там появляется? Откуда он прокидывается и как создается код, который прокидывает этот параметр?
Чтобы найти ответ заглянем в исходники компилятора Kotlin.
Погружаемся в исходники

Стоит отметить, что компилятор языка Kotlin состоит из двух частей: frontend компилятора и backend компилятора.
Посмотрим внутрь frontend:

А теперь поищем, в каком же месте нашего компилятора находится код, ответственный за обработку функций. Нашли мы его в классе TypeResolver
:

На скриншоте выше один из параметров метода createFunctionType
называется hasSuspendModifier
. То есть в зависимости от того, является ли обрабатываемая функция suspend или нет, отличается логика работы компилятора.
В свою очередь, сам метод createFunctionType
возвращает SimpleType
.
SimpleType
— абстрактный класс, представляющий часть системы типов компилятора. Используется для внутреннего представления типов во время анализа кода.
Давай теперь провалимся в createFunctionType
и посмотрим, что происходит там:

Параметр suspendFunction
передается в функцию getFunctionDescriptor
. Если заглянем в эту функцию, то увидим :

Здесь также есть условие, что если это suspend-функция, то вызывается соответствующий метод builtIns.getSuspendFunction(parameterCount)
.
Погружаемся дальше — в метод getSuspendFunction
:

Этот метод возвращает ClassDescriptor
.
ClassDescriptor
— это интерфейс в компиляторе Kotlin (из пакетаorg.jetbrains.kotlin.descriptors
), который представляет описание класса во время компиляции. Он содержит метаинформацию о классе.
Давай теперь посмотрим, что происходит в getSuspendFunctionName
:

Этот метод возвращает обычный String
. И следом сразу провалимся в FunctionTypeKind.SuspendFunction
:

Хочу обратить внимание, что метод prefixForTypeRender
переопределен только у SuspendFunction
. У Function
, KFunction
и KSuspendFunction
этот метод возвращает null.
Мы довольно сильно забурились внутрь (хотя и сделали это поверхностно). Но теперь у тебя может возникнуть самый главный вопрос: «А где тут прокидывается Continuation?».
Про это пока ничего не было, так что давай попробуем посмотреть в FirElementSerializer
:

FirElementSerializer
— это компонент в компиляторе Kotlin, отвечающий за сериализацию и десериализацию FIR (Frontend Intermediate Representation). Подробнее почитать про FIR.
ConeClassLikeType
— это абстрактный класс в компиляторе Kotlin, используемый в K2 Frontend (новой версии фронтенда) для представления типов, связанных с классами, интерфейсами или объектами.
В рамках этой статьи не будем погружаться в suspendFunctionTypeToFunctionTypeWithContinuation
, так как нам интереснее раскопать кодогенерацию на уровне backend компилятора.
Но по названию можем понять, что Continuation
вступает в игру уже на уровне frontend-компилятора. Вызываемый в FirElemetSerializer
метод typeOrTypealiasProto
,скриншот, которого находится выше, возвращает ProtoBuf.Type.Builder
.
ProtoBuf.Type.Builder
в компиляторе Kotlin — это класс, используемый для построения описаний типов данных в бинарном формате, который применяется для сериализации структур данных в рамках компилятора. Это нужно, чтобы передавать структуры между разными модулями компилятора.
Итого, что мы получаем на данный момент:
Frontend компилятора K2 на основе нашего исходного кода создаёт FIR представление.
FIR представление преобразуется в IR (Intermediate Representation).
IR подаётся на вход одному из вариантов бэкенда компилятора (нас интересует JVM backend). IR (Intermediate Representation) в компиляторе Kotlin — это промежуточное представление кода, которое используется для преобразования исходного кода Kotlin в машинный код или байт-код целевой платформы (например, JVM, JS или Native).
Backend компилятора уже обрабатывает эту информацию, оптимизирует и генерирует необходимый код (в нашем случае добавляет Continuation)
Идем в backend компилятора

Ты можешь подумать, что ключевое слово suspend обрабатывается только frontend-частью компилятора, но это не так. Его обработку также можно увидеть и в backend-части, если покопаться в исходниках компилятора. Но мы не просто будем смотреть исходники backend компилятора, мы ещё его и подебажим!
На все узлы компилятора Kotlin есть большое количество тестов, а при запуске тестов, всё очень удобно можно продебажить. Мы будем дебажить через тесты в FirLoadK2CompiledJvmKotlinTestGenerated
и JvmAbiConsistencyTestRestGenerated
.
Обрати внимание: если хочется проверить, как backend компилятора подставляет Continuation
в suspend-функцию, то она не должна быть пустышкой (в ином случае компилятор проведёт оптимизации и не докинет Continuation
). Пример снизу нам вполне подходит:

Ну что, поехали дебажить!

Опущу какое-то количество подробностей, чтобы сразу провалиться в класс под названием ClassCodegen
.
ClassCodegen
генерирует байт-код для классов и методов, используя IR-представление. То самое IR-представление, которое backend компилятора получил от frontend части компилятора.
На уровне ClassCodegen
мы попадаем в метод generate
. Будет проще разобраться, если ты уже когда-либо сталкивался с кодогенерацией. Например, копался в кодогенераторе библиотеки Dagger2 и знаешь, что такое паттерн Visitor, который широко применяется в сфере кодогенерации. Но даже если никогда не сталкивался, сейчас попробуем всё разобрать.

В методе generate
мы попадаем в цикл for
, который пробегается по методам из IR-представления. Напомню, что IR прилетает нашему backend от frontend компилятора. Так как у нашей тестируемой функции dummy не было параметров, то единственным её параметром должен быть Continuation
, который backend компилятора и должен сгенерировать. В дебаге мы увидим, как раз описание этого параметра:
Убеждаемся, что это наша suspend-функция dummy()
.

symbol
= Continuation
.

Далее мы проваливаемся в метод generateMethod
. В теле этого метода есть проверка (method.hasContinuation()
) на то, нужен ли Continuation
генерируемой функции или нет. На основе этого условия у нас начинает создаваться Continuation
.

Далее мы проваливаемся в node.acceptWithStateMachine
. В докладах часто говорится, что в корутинах генерируется стейт-машина. Теперь ты знаешь место в компиляторе, где это происходит :)
Если начать проваливаться дальше вглубь, то попадем в метод performTransformations
, который принимает на вход классMethodNode
.
Класс
MethodNode
предназначен для представления и манипуляции методами JVM-байткода в виде структурированного дерева. Он используется для чтения, модификации и генерации методов в классах.
Сам метод performTransformations
, находится в CoroutineTransformerMethodVisitor
.
Класс
CoroutineTransformerMethodVisitor
в JVM бекенде компилятора Kotlin отвечает за трансформацию байткода методов, связанных с корутинами, чтобы обеспечить корректную работу приостанавливаемых функций (suspend-функций) на уровне JVM.
Мы можем посмотреть по дебагу, какие параметры лежат в инстансе MethodNode
, который пришел в наш метод, там мы узрим название нашей функции — dummy. Также в панели дебага есть упоминание Continuation
:

В методе perfromTransformation
есть один интересный трюк: в нём фейковый Continuation
подменяется на настоящий. То есть до этого компилятор водил нас за нос фейком (конечно же, это шутка).


Но что же происходит в методе replaceFakeContinuationsWithRealOnes
? Давай увидим!

У нас подменяются фейковые Continuation
на настоящие. А в чем разница между ними и зачем вообще нужны фейковые? Почему сразу не поставить настоящие ?
Оптимизации — причина всего

Вообще компилятор очень любит оптимизации и старается делать так, чтобы наш исходный код компилировался максимально быстро, а результирующий машинный код так же быстро работал.
Fake Continuation: временные объекты, создаваемые на ранних этапах компиляции для упрощения анализа и трансформации. Они не содержат полной логики для работы с состояниями корутин, но позволяют компилятору строить промежуточное представление кода (IR).
Real Continuation: Финальные реализации, которые содержат всю логику для управления состоянием корутины (например, сохранение локальных переменных, переходы между метками
label
, вызовыresumeWith
).
То есть фейковые Continuation
нужны только для того, чтобы создать промежуточное представление — IR, они помогают ускорить работу frontend компилятора. Но вот на этапе JVM backend, когда уже генерируется код, нам нужны реальные Continuation
, и, соответственно, в этом методе они и подставляются. А теперь давай посмотрим, что будет, если выполнение метода пойдёт дальше:

Почему-то у нас fakeContinuations
= 0. Возникает вопрос: «А почему так?»
На самом деле всё довольно логично — фейковые Continuations добавляются, только если в нашей suspend-функции есть приостановки. В ином случае в них нет смысла — они не принесут никакой оптимизации. Напомню, что у нас простая suspend dummy функция, в которой нет никаких приостановок, именно поэтому у нас и нет фейковых
Continuation
.
Финальный рывок

А теперь давай вернёмся в ClassCodegen
. И провалимся дальше с того места, где мы остановились. В конечном итоге получим инстанс ClassCodegen
для генерации уже самого Continuation
.

Вот тут и начинается генерация нашего Continuation
, который подставится параметром нашей dummy-функции. Фух, ну вот мы прошли большой путь просто для того, чтобы в нашу функцию добавился ещё один параметр, компилятор позаботился о нас, чтобы мы сами не писали руками машинерию корутин.
В целом, если ты посмотришь на JVM бэкенд компилятора, то увидишь там большое количество функциональности, связанной с оптимизациями, которые приводят к более эффективному результирующему байткоду. Также в CoroutineTransformerMethodVisitor
есть много функциональности, связанной с кодогенерацией корутин. Всё это мы рассматривать не будем, кроме одного интересного примерчика, связанного с LVT.
LVT (Local Variable Table) — это структура данных в JVM-байткоде, которая хранит информацию о локальных переменных метода (их имена, типы, область видимости).

Созерцаем параметр, также связанный с Continuation
, а конкретно его добавлением в LVT-таблицу. Если посмотреть дальше по методу, то увидим, что в зависимости от флага, параметр completion
либо добавляется в LVT, либо не добавляется.

У тебя может возникнуть вопрос, а где я могу это увидеть ? На самом деле это можно посмотреть даже в обычной вкладке с байткодом.
Без SUSPEND
и параметра completion
в LVT.

С completion
в LVT.

Также важно отметить, что CoroutineTransformerMethodVisitor
находится в пакете с комментарием:
Old (classic) JVM backend. It is not used by default since Kotlin 1.5, and is being removed (KT-71197). However, some code there is also being used by the new JVM backend, mainly: bytecode inliner, bytecode optimizations, coroutine codegen.
И это ещё один момент, который говорит о сложности создания компиляторов и добавления изменений в них: приходится хранить какие-то сущности в коде для того, чтобы не нарушать обратную совместимость с предыдущими версиями компилятора.
Схема

Давай теперь взглянем на визуальное представление, в котором находятся сущности, что мы рассматривали сегодня. Опять же — схема верхнеуровневая, но поможет закрепить в голове то, что мы прошли сегодня:

Пакет
core
— это ядро компилятора, содержащее базовые компоненты для анализа, преобразования и генерации кода. Он отвечает за ключевые этапы компиляции и предоставляет общие утилиты, используемые всеми модулями компилятора. Практически все модули компилятора Kotlin зависят отcore
.
Итоги

Хотелось показать тебе, что компиляторы это не что-то сверхъестественное. В них действительно можно покопаться и даже чуть глубже разобраться в том, как работает та или иная языковая фича.
Но не стоит забывать, что это всё равно довольно сложное техническое творение и при создании любого компилятора решается большое количество проблем. Дизайн языка программирования нетривиальная задача, но это не мешает тебе забуриться в исходники :)
Что мы с тобой не разобрали в статье, но можно было:
Как в компиляторе при добавлении новых фич продумывается совместимость с существующими фичами + предыдущими версиями компилятора.
Какие проблемы решает появление FIR (frontend intermediate representation).
Как в компиляторе Kotlin используется кодогенерация для создания большого количества сущностей.
И даже этих пунктов недостаточно, чтобы разобрать механизмы и схему работы компилятора глубоко и осмысленно. Если тебе интересно глубже занырнуть в кодогенерацию корутин и того, как они обрабатываются компилятором, тебе очень поможет эта ссылка на GitHub.
На данную темы мы с Александром Гиревым готовили доклад на Мобиус. Можешь заскочить на хабр Саши, он пишет крутые статьи! Также я веду YouTube канал на котором выпускаю видео по разного рода темам из IT. Подписывайся, если интересно!
P.S.
Если тебе показалась интересной тема компиляторов то вот мои рекомендации:
Ульман Ахо, «Компиляторы: принципы, технологии и инструменты».
Том Стюарт, «Теория вычислений для программистов».
Подписывайтесь на Телеграм-канал Alfa Digital — там мы постим новости, опросы, видео с митапов, краткие выжимки из статей, иногда шутим.