Привет! Меня зовут Абакар, я работаю главным техническим лидером разработки в Альфа-Банке.

Сегодня мы поговорим на тему, связанную с корутинами, а именно погрузимся чуть глубже в недра компилятора 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:

  1. Откройте вкладку Tools → Kotlin → Show Kotlin Bytecode.

  2. Нажмите Decompile для просмотра Java-аналога.

Как посмотреть байткод
Как посмотреть байткод

Как видим, появился тот самый параметр, про который любят спрашивать и рассказывать на собесах — Continuation. Все знают, что он появляется в функции.

Но как он там появляется? Откуда он прокидывается и как создается код, который прокидывает этот параметр?

Чтобы найти ответ заглянем в исходники компилятора Kotlin.

Погружаемся в исходники

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

Посмотрим внутрь frontend:

frontend компилятора
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 — это класс, используемый для построения описаний типов данных в бинарном формате, который применяется для сериализации структур данных в рамках компилятора. Это нужно, чтобы передавать структуры между разными модулями компилятора.

Итого, что мы получаем на данный момент:

  1. Frontend компилятора K2 на основе нашего исходного кода создаёт FIR представление.

  2. FIR представление преобразуется в IR (Intermediate Representation).

  3. IR подаётся на вход одному из вариантов бэкенда компилятора (нас интересует JVM backend). IR (Intermediate Representation) в компиляторе Kotlin — это промежуточное представление кода, которое используется для преобразования исходного кода Kotlin в машинный код или байт-код целевой платформы (например, JVM, JS или Native).

  4. Backend компилятора уже обрабатывает эту информацию, оптимизирует и генерирует необходимый код (в нашем случае добавляет Continuation)

Идем в backend компилятора

Копаем глубже
Копаем глубже

Ты можешь подумать, что ключевое слово suspend обрабатывается только frontend-частью компилятора, но это не так. Его обработку также можно увидеть и в backend-части, если покопаться в исходниках компилятора. Но мы не просто будем смотреть исходники backend компилятора, мы ещё его и подебажим!

На все узлы компилятора Kotlin есть большое количество тестов, а при запуске тестов, всё очень удобно можно продебажить. Мы будем дебажить через тесты в FirLoadK2CompiledJvmKotlinTestGenerated и JvmAbiConsistencyTestRestGenerated.

Обрати внимание: если хочется проверить, как backend компилятора подставляет Continuation в suspend-функцию, то она не должна быть пустышкой (в ином случае компилятор проведёт оптимизации и не докинет Continuation). Пример снизу нам вполне подходит:

Пример тестовых данных в continuationInLvn.kt
Пример тестовых данных в continuationInLvn.kt

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

Опущу какое-то количество подробностей, чтобы сразу провалиться в класс под названием ClassCodegen.

ClassCodegen генерирует байт-код для классов и методов, используя IR-представление. То самое IR-представление, которое backend компилятора получил от frontend части компилятора.

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

Вот как выглядит первая часть метода generate
Вот как выглядит первая часть метода generate

В методе 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.

Если тебе показалась интересной тема компиляторов то вот мои рекомендации:

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


  1. nihil-pro
    28.05.2025 16:12

    Вот вроде как интересно, но очень напрягает когда в меня тычут. Я один такой?


    1. Ab0cha Автор
      28.05.2025 16:12

      Спасибо вам за фидбэк !)
      Судя по реакциям вы не один такой. Использовал такой стиль повествования для того, чтобы контент было проще воспринимать)
      Но учту ваш комментарий на будущее


  1. leni8ec
    28.05.2025 16:12

    Статья супер, давно так не зачитывался, спасибо!

    Прям блаженство среди "ии генерации".


    1. Ab0cha Автор
      28.05.2025 16:12

      благодарю за фидбэк !)